Привет Хабр! Меня зовут Владислав Столяров, я руковожу группой анализа безопасности продуктов в компании МойОфис. Полгода назад я узнал, что в статическом анализаторе Svace можно создавать собственные диагностические правила. Это показалось мне очень любопытным. В статье я делюсь своим неоднозначным опытом и рассказываю о попытке реализовать такую кастомную диагностику. Но обо всём по порядку.
Схема работы диагностических правил
У анализатора Svace есть два внешних способа анализа кода через Svace Light API и Svace IR API.
Про Svace Light API трудно сказать что-то особенно интересное. Это обычный поиск паттернов в коде при помощи регулярных выражений. В рамках реализованных с этим API диагностических правил можно искать простые проблемные конструкции.
Svace IR API c точки зрения документации выглядело более перспективно. Однако, только на первый взгляд. Меня смутило очень небольшое количество языковых конструкций, которые можно посетить для анализа кода (я увидел это на основе вывода декомпилированного бинарного файла SvaceIRModuleObserver.class
).
Если описать увиденное, то по сути, мы можем:
Зайти в сравнение при помощи
assumeBoolean
.Зайти в бинарную операцию при помощи
binaryOperation
.Зайти в операцию по приведению типа при помощи
cast
.Обойти глобальные переменные при помощи
globalVariable
.
Дополнительно стоит отметить, что происходит обход кода не в его первоначальном виде и не в виде AST, а в виде промежуточного представления Svace. Некоторые инструкции из кода заменены методами load, store и прочими. Код разбит на IR процедуры с IR операндами.
К сожалению, я не нашёл документации о том, как выглядит Svace IR, сильно ли он отличается, например, от LLVM IR.
Давайте перейдём к моей попытке реализации диагностического правила и посмотрим, что же получилось сделать.
Имплементация правила
Правило для Svace реализуется как проект на языке Java. По идее можно использовать любую систему сборки – я применял Gradle. По сути, это динамически линкующиеся плагины для анализатора в виде JAR-файлов. Для того, чтобы реализовать правило вам нужны:
Статический анализатор Svace для доступа к публичному интерфейсу API (
svace-api.jar
, он лежит в папке lib дистрибутива Svace).Gradle— у меня версия 8.0.
Java — у меня версия 17.
Для начала мы создаём Gradle-проект. Как это сделать можно прочитать в официальной документации.
Для того, чтобы использовать svace-api.jar
, его нужно добавить в свой сценарий сборки.
plugins {
// ...
id 'java-library'
}
dependencies {
// ...
compileOnlyApi files("/opt/svace-$version$/lib/svace-api.jar")
}
sourceSets {
main.java.srcDirs = ['src']
}
Также я напрямую указал версию Java 17.
plugins {
// ...
id 'java-library'
}
dependencies {
// ...
compileOnlyApi files("/opt/svace-$version$/lib/svace-api.jar")
}
sourceSets {
main.java.srcDirs = ['src']
}
compileJava {
options.release.set(17)
}
Механизм пользовательских проверок Svace применяет систему модулей Java. Поэтому для того, чтобы плагин был доступен анализатору, нужно создать для него модуль, поместив специальный файл дескриптора модуля module-info.java
в папку src в корне проекта Gradle и описать его зависимость от модуля svace.api
с помощью директивы requires.
module svace.plugin.example {
requires svace.api;
provides ru.isp.svaceapi.ir.SvaceIRPlugin with SvaceIRPluginDemo;
}
Также этот модуль должен описывать, что он предоставляет либо ru.isp.svaceapi.ir.SvaceIRPlugin
для Svace IR API (как в примере выше), либо ru.isp.svaceapi.light.SvaceLightPlugin
для Svace Light API (как в примере ниже).
module svace.plugin.example {
requires svace.api;
provides ru.isp.svaceapi.light.SvaceLightPlugin with SvaceIRLightPluginDemo;
}
Тут SvaceIRPluginDemo
и SvaceIRLightPluginDemo
названия классов, которые будут объявлены в файле с непосредственной имплементацией диагностического правила.
Затем реализуем само диагностическое правило. Сначала нам нужно зарегистрировать новый тип предупреждения и указать его свойства при помощи метода registerWarining
.
@Override
public void registerWarningType(SvaceUserWarningRegister register) {
WarningTypeProperties props = WarningTypeProperties.create(Language.*)
.setSeverity(/*...*/)
.setCweSet(/*...*/)
.setDescription(/*...*/);
YourWarning = register.registerWarning("YOU_WARNING_NAME", props);
}
Например, тут для предупреждения указывается язык (для того, чтоб работало для всех языков нужно указать Language.ANY
). Далее при помощи функций set* указываются:
Severity (Critical, Major, Minor, Normal).
Номер CWE.
Текстовое описание предупреждения.
Затем нужно будет использовать YourWarning
при выдаче предупреждения. Каждый плагин может регистрировать любое количество предупреждений.
Теперь обратимся к самому API. Как говорилось выше, Svace предоставляет два его вида: Svace IR API и Svace Light IR API.
Svace Light IR API позволяет искать проблемные места в коде при помощи регулярных выражений и их комбинаций. Нам нужно переопределить функцию createChecker
и зарегистрировать в ней свой паттерн.
Override
public void createChecker(SvaceLightCheckerBuilder builder){
builder.createRegexpForTokenChecker(warning, "/*regex*/", "/*message*/", /*...*/);
}
Тут нужно отметить, что, как я понял, это не совсем поиск по регуляркам в исходном коде, а поиск по регуляркам в Svace IR. Поэтому стоит учитывать его особенности, например, что в Svace IR названия переменных начинаются со знака вопроса.
Svace IR API — это более тяжеловесный инструмент для работы с большей функциональностью. Там диагностические правила выглядят уже посложнее. Приведу полный код одного из них.
package div.by.one;
import ru.isp.svaceapi.*;
import ru.isp.svaceapi.ir.*;
import ru.isp.svaceapi.ir.operand.SvaceIRConstant;
import ru.isp.svaceapi.ir.operand.SvaceIROperand;
import ru.isp.svaceapi.ir.operand.SvaceIRSymbol;
import ru.isp.svaceapi.operand.SvaceOperand;
import ru.isp.svaceapi.warning.*;
import ru.isp.svaceapi.ir.instr.SvaceIRBinaryOperation;
import java.io.PrintStream;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class DivByOnePlugin extends AbstractSvaceIRPlugin implements SvaceIRPlugin {
private SvaceUserWarning divByOneWarning = null;
@Override
public String name() {
return "Mod by one";
}
@Override
public void registerWarningType(SvaceUserWarningRegister register) {
WarningTypeProperties props = WarningTypeProperties.create(Language.CXX)
.setVisibility(Visibility.Enabled)
.setSeverity(Severity.Critical)
.setCweSet(682)
.setDescription("Checker for incorrect binary operation.");
divByOneWarning = register.registerWarning("MOD_BY_ONE", props);
log.println("Warning type: " + divByOneWarning.name());
}
@Override
public void newIRModule(SvaceIRModule module, SvaceIRModulePublisher observer) {
observer.subscribe(new DivByOneChecker(log, divByOneWarning, warningEmitter));
log.println("Register: " + module.name());
}
private static class DivByOneChecker extends AbstractSvaceIRModuleObserver {
private final SvaceUserWarning warning;
private final SvaceUserWarningEmitter warningEmitter;
private final PrintStream log;
public DivByOneChecker(PrintStream log, SvaceUserWarning warning, SvaceUserWarningEmitter warningEmitter) {
this.warning = warning;
this.warningEmitter = warningEmitter;
this.log = log;
}
private Optional<Integer> getConstantIntegerValue(SvaceIROperand operand) {
Optional<SvaceIRConstant> intConst = operand.castToConstant();
if (intConst.isPresent())
return intConst.get().getIntegerValue();
return Optional.empty();
}
@Override
public void binaryOperation(SvaceIRSymbol res, SvaceIROperand left, SvaceIROperand right, SvaceIRBinaryOperation operation)
{
if (!operation.equals(SvaceIRBinaryOperation.UMOD) && !operation.equals(SvaceIRBinaryOperation.SMOD))
{
return;
}
Optional<Integer> val = right.getIntegerValue();
if (val.isPresent() && val.get() == 1)
{
SvaceIRLocation loc = getLastLoc();
if (loc != null) {
warningEmitter.emit(warning, "Result of modulo by 1 allways is 0." , loc);
}
}
}
}
}
Примерно так должен выглядеть чекер для поиска бинарных операций с попыткой взятия остатка от деления на 1. Такая операция бессмысленна, потому что результат всегда будет равен 0.
Давайте разберёмся, что тут происходит. В начале мы переопределяем несколько методов. Имя диагностического правила и registerWarningType
, который мы рассматривали выше.
private SvaceUserWarning divByOneWarning = null;
@Override
public String name() {
return "Mod by one";
}
@Override
public void registerWarningType(SvaceUserWarningRegister register) {
WarningTypeProperties props = WarningTypeProperties.create(Language.CXX)
.setVisibility(Visibility.Enabled)
.setSeverity(Severity.Critical)
.setCweSet(682
.setDescription("Checker for incorrect binary operation.")
divByOneWarning = register.registerWarning("MOD_BY_ONE", props);
log.println("Warning type: " + divByOneWarning.name());
}
Имя пригодится нам для регистрации нового IR модуля. Это тоже необходимая операция.
@Override
public void newIRModule(SvaceIRModule module, SvaceIRModulePublisher observer) {
observer.subscribe(new DivByOneChecker(log, divByOneWarning, warningEmitter));
log.println("Register: " + module.name());
}
Далее нам нужно создать класс своего чекера и объявить в нём необходимые поля.
private static class DivByOneChecker extends AbstractSvaceIRModuleObserver {
private final SvaceUserWarning warning;
private final SvaceUserWarningEmitter warningEmitter;
private final PrintStream log;
public DivByOneChecker(PrintStream log, SvaceUserWarning warning, SvaceUserWarningEmitter warningEmitter) {
this.warning = warning;
this.warningEmitter = warningEmitter;
this.log = log;
}
Функциональность для написания диагностик реализована через классический для статических анализаторов метод «Посетитель». Нам нужно переопределить метод посещения нужной нам языковой конструкции. В данном случае это бинарное выражение. Внутри мы смотрим на то, что происходит нужная нам бинарная операция. Далее, мы берём значение правого операнда и проверяем, что оно есть и равно единице.
@Override
public void binaryOperation(SvaceIRSymbol res, SvaceIROperand left, SvaceIROperand right, SvaceIRBinaryOperation operation)
{
if (!operation.equals(SvaceIRBinaryOperation.UMOD) &&
!operation.equals(SvaceIRBinaryOperation.SMOD))
{
return;
}
Optional<Integer> val = right.getIntegerValue();
if (val.isPresent() && val.get() == 1)
{
SvaceIRLocation loc = getLastLoc();
if (loc != null) {
warningEmitter
.emit(warning, "Result of modulo by 1 allways is 0." , loc);
}
}
}
Таким образом при помощи IR API Svace можно реализовывать простые диагностические правила.
Сборка готового диагностического правила
Как я уже говорил выше, диагностическое правило Svace это набор Java классов. Анализатор работает с ним как с плагином, собранным в JAR-файл. Сборка Gradle проекта в JAR-файл происходит при помощи команды:
gradle jar
При успешном выполнении команды, в папке build/libs должен появится JAR-файл с диагностическим правилом. Далее его необходимо положить по пути: svace-$version$/plugins
. Затем информацию о нём можно получить при помощи команды:
svace warning -i $WARNING_NAME$
Например, информацию об имплементированном выше диагностическом правиле можно получить при помощи команды:
svace warning -i USER.MOD_BY_ONE
Вывод анализатора будет таким:
##############################
# Situation: Quality
# Reliability: Unknown
# Severity: Critical
# Group:
# Available languages: CXX
# Detection tools: SvaceIR-API
# CWEs: CWE682
# Description: Checker for incorrect binary operation.
#
USER.MOD_BY_ONE = true
Отладка диагностических правил
К сожалению, запустить отладчик и посмотреть на таинство статического анализа Svace нет возможности. Единственное, что можно сделать – логировать с помощью AbstractSvaceAPIPlugin.provideLog
. Если отнаследоваться от этого класса (или от его наследника AbstractSvaceIRPlugin
), то можно использовать переменную log
чтобы печатать информацию, которая будет сохранена в .svace-dir/analyze-res/plugins
.
Возьмём наше диагностическое правило USER.MOD_BY_ONE
и попробуем залогировать результаты его работы. Для этого напишем в правиле такой код:
log.println("new binary operation\n");
log.println("left variable value: " + left.getIntegerValue() + "\n");
log.println("right variable value: " + right.getIntegerValue() + "\n");
log.println("____________________________________________________\n");
Тут я хочу логировать каждую бинарную операцию, в которую мы заходим при проверке файла, и получать значение левого и правого операнда.
Качество работы диагностических правил
Для проверки качества работы мы используем реализованное правило USER.MOD_BY_ONE
, систему логирования, которую мы разместим под проверкой на то, что бинарная операция — взятие остатка от деления (таким образом, будут логироваться только mod операции) и тестовый файл:
define ONE 1
#define TEN 10
unsigned v = TEN % ONE; // <=
constexpr int f (bool b)
{
int a = 1;
int c = 1;
if (b)
{
return a % c; // <=
}
return c % a; // <=
}
template <class T>
T var = 1;
constexpr int q (bool b)
{
int a = 1;
if (b)
{
return a % var<int>; // <=
}
return var<int> % a; // <=
}
constexpr unsigned foo (unsigned del) noexcept
{
return 10 / del;
}
int qqqq(unsigned short* pFlag, int index)
{
auto bbb = index % 1; // <=
constexpr auto f = foo(10);
return index % f; // <=
}
int main()
{
}
Итак, в этом тестовом примере я, с помощью комментария // <=
, пометил все места, где диагностика должна выдавать срабатывания. Как уже говорилось выше, соберём диагностическое правило, положим JAR-файл в папку plugins и запустим анализ. Вот результат лога работы анализатора:
Warning type: USER.MOD_BY_ONE
Register: main.dfc721dd39906efe190759e3d4445655.bc
new binary operation
left variable value: Optional.empty
right variable value: Optional.empty
____________________________________________________
new binary operation
left variable value: Optional.empty
right variable value: Optional.empty
____________________________________________________
new binary operation
left variable value: Optional.empty
right variable value: Optional.empty
____________________________________________________
new binary operation
left variable value: Optional.empty
right variable value: Optional.empty
____________________________________________________
new binary operation
left variable value: Optional.empty
right variable value: Optional[1]
____________________________________________________
new binary operation
left variable value: Optional.empty
right variable value: Optional.empty
____________________________________________________
Таким образом, анализатор задетектил 6 бинарных операций взятия остатка от деления. Из 12 раз он обсчитал только одно значение — захардкоженную единицу тут:
auto bbb = index % 1;
Соответственно на данный фрагмент кода выдано только одно срабатывание, негусто.
Из этого небольшого теста можно сделать вывод, что мы имеем доступ к промежуточному представлению кода без углублённой информации состоянии переменных. В свою очередь отмечу, что такой голый API не говорит о том, что функциональность самого анализатора Svace урезана. Она лишь сообщает о том, что разработчики представили наружу очень малую часть используемой ими функциональности.
Выводы
Возможность создавать собственные диагностические правила в проприетарных статических анализаторах — это задача с множеством нюансов. Многие компании предлагают не только свой продукт, но и его поддержку, что подразумевает выполнение запросов клиентов, включая создание новых правил. Эти запросы часто реализуются в зависимости от условий покупки, типа и количества лицензий, а также ресурсов команды, занимающейся разработкой. Имплементация новых правил является одним из способов развития анализатора. Если клиенты начнут самостоятельно разрабатывать и делиться своими правилами в open-source — это может снизить одну из возможностей для роста продукта. С другой стороны, команды разработчиков могут использовать лучшие практики клиентов и адаптировать их под свои нужды. Получается палка о двух концах.
Из действительно хорошего можно отметить: удобство имплементации, сборки и применения диагностических правил — они легко интегрируются в существующую систему анализа.
Разработчики анализатора в одной из будущих версий обещают расширить эту функциональность. Надеюсь, что после этого возможность писать кастомные правила заиграет новыми красками в сочетании с уже имеющимся удобством их использования. Я, в свою очередь, её обязательно оценю.