Как стать автором
Обновить
ЮMoney
Всё о разработке сервисов онлайн-платежей

SonarQube в действии: плагины как ключевой элемент контроля качества в отделе

Время на прочтение11 мин
Количество просмотров900

Привет! Меня зовут Глеб, я старший backend-разработчик в ЮMoney. В прошлом году моя команда активно занималась внедрением и развитием инфраструктуры статического анализа на базе SonarQube. Итогом нашей деятельности стало превращение SonarQube из простого статического анализатора в полноценную платформу для автоматизации многих процессов контроля качества — от сопровождения кодовой базы и отказа от Kotlin до проверки обратной совместимости в OpenAPI-спецификациях и миграциях баз данных. Расскажу, какое место занимает этот инструмент в нашей системе контроля качества и как именно мы этого добились.

Программа минимум: коробочные возможности инструмента

Для начала отмечу, что SonarQube из коробки имеет множество полезных возможностей для контроля качества:

  • Поддерживает много разнообразных языков и технологий.

  • Позволяет гибко настраивать наборы ограничений для различных сценариев использования.

  • Хорошо интегрируется с инструментами CI/CD и IDE.

  • Может расширяться за счёт Plugin API и Webhooks.

Поэтому даже без дополнительных доработок мы использовали SonarQube крайне активно.

Мы анализируем изменения для master и feature веток с каждым новым коммитом и блокируем merge, если SonarQube обнаружил какие-либо проблемы с качеством нового кода. Например, в ветке есть новые issue или security hotspot, или процент покрытия кода тестами меньше минимально необходимого. Кроме того, мы ежедневно анализируем master-ветку на случай, если в SonarQube появились новые правила, которые нарушаются существующим кодом. Для отслеживания таких нарушений у нас есть отдельный механизм Health Check, про который мы писали статью на Хабре.

Одним словом, даже базовых возможностей SonarQube достаточно для повышения качества проектов. Однако мы решили на этом не останавливаться и расширить стандартный набор возможностей за счёт плагинов собственной разработки. Так появился набор из 45 дополнительных правил статического анализа, о которых я расскажу дальше.

Задачи, которые решают плагины собственной разработки

Постепенный отказ от определённых технологий (на примере Kotlin)

В течение достаточно длительного периода мы в отделе в экспериментальном режиме внедряли поддержку Kotlin. Production код на Kotlin особой популярности не получил, в основном на этом языке мы писали тесты. На диаграммах ниже представлены данные об использовании Kotlin в наших проектах.

Доля проектов, в которых есть тесты на Kotlin
Доля проектов, в которых есть тесты на Kotlin

Как вы можете заметить, несмотря на все свои преимущества, Kotlin находится в явном меньшинстве по сравнению с нашей огромной кодовой базой на Java.

При этом, на поддержку инфраструктуры Kotlin тоже приходится тратить ресурсы, что особенно заметно при любых масштабных обновлениях. Например, при обновлении до новых версий Java, Spring Boot или Gradle. Кроме того, при разработке приходится переключать контекст между языками: в одном компоненте у вас только Kotlin-тесты, в другом – только Java, в третьем – сборная солянка, и не очень понятно, как правильнее писать новые тесты. Поэтому, несмотря на популярность технологии, мы всё же решили постепенно от неё отказаться.

Чтобы не тратить все имеющиеся в наличии ресурсы разработки на отказ от Kotlin, этот процесс должен был быть постепенным. Поскольку мы на Kotlin писали в основном только тесты, мы приняли решение запретить написание новых тестов. При этом на случай хотфиксов требовалось сохранить возможность корректировать уже написанные тесты. Для реализации этих требований на свет появился Kotlin Refusal Plugin.

Этот плагин работает максимально просто:

  • Анализирует git diff для ветки;

  • Ищет новые Kotlin-классы и новые методы в существующих;

  • Выставляет Issue, если в ветке появились новые тесты.

Выглядит это так:

Пример работы Kotlin Refusal Plugin
Пример работы Kotlin Refusal Plugin

Интеграция сторонних инструментов SAST на примере Semgrep

У SonarQube широкие возможности по контролю качества кода, однако с конкретным типом задач обычно лучше справляются инструменты с более узким профилем. Один из таких – Semgrep, инструмент статического анализа, предназначенный специально для поиска уязвимостей в исходном коде. Он с этим и справляется лучше, и база уязвимостей у него побольше, чем у SonarQube.

Но есть у Semgrep один серьёзный недостаток: это CLI-инструмент. Поэтому просто взять и настроить его интеграцию с инструментами CI/CD, чтобы блокировать merge задач с уязвимостями и периодически мониторить master-ветку, не выйдет. Кроме того, возникает вопрос: где смотреть результаты анализа и как управлять найденными уязвимостями? С подобными задачами уже прекрасно справляется SonarQube, поэтому мы и решили попробовать подключить Semgrep в качестве плагина. Так у нас появился Semgrep Plugin.

Схема работы плагина достаточно проста:

  • Найти diff текущей ветки с master (если анализируем feature ветку).

  • Подгрузить правила для языков, код на которых есть в проекте.

    • В дефолтном наборе правил достаточно много, на их загрузку уходит много времени, поэтому есть смысл в подобной оптимизации.

  • Запустить Semgrep CLI для анализируемого кода.

  • Преобразовать отчёт Semgrep в набор Sonar Issues.

По такой схеме мы импортировали из Semgrep 516 правил для интересующих нас языков программирования и технологий.

Теперь, если, например, попытаться использовать небезопасную версию алгоритма RSA в своём коде, можно получить подобное замечание:

Пример работы Semgrep Plugin
Пример работы Semgrep Plugin

Контроль безопасности миграций в базах данных

Миграции баз данных, несмотря на своё удобство, нередко могут становиться источником серьёзных (а порой и неожиданных) проблем на проде:

Категория проблемы

Чем опасна

Пример

Внесение обратно несовместимых изменений

Риск потери критичных данных

DELETE запрос без WHERE

Невозможность работы предыдущей версии релиза с новым состоянием базы данных

Удаление обязательного столбца из кода и таблицы в базе в одном релизе

Операции с долгими блокировками и полным сканированием таблиц

Невозможность работы с таблицами до окончания миграции

Создание индекса без опции CONCURRENTLY

Использование неподходящих типов данных

Неожиданные ошибки при переполнении типа

Использование integer для идентификаторов

Чтобы избежать подобных проблем, нужно проверять, насколько миграции корректны и безопасны.

Для себя мы выделили следующие требования к миграциям:

  • Мы полностью запрещаем любые запросы, которые приводят к:

    • Долгим блокировкам;

    • Полному сканированию таблиц;

    • Очистке и удалению таблиц.

  • Мы требуем, чтобы при миграциях сохранялась совместимость старой версии приложения с новым состоянием базы данных.

Для проверки этих требований мы тоже решили использовать SonarQube и разработали PostgreSQL plugin.

Что он делает:

  • Анализирует SQL-скрипты с помощью ANTLR;

  • Заводит issue для всех запрещённых операций;

    • Обратно несовместимые операции плагин дополнительно помечает тегом breaking-change.

Так у нас появилось 15 правил для контроля безопасности миграций. Вот примеры некоторых из них.

Замечание: самую сложную часть – разбор SQL запроса, за нас реализует ANTLR. Поэтому в правилах достаточно расширить предоставляемый библиотекой парсер и переопределить методы, посещающие проверяемые правилом контексты запроса.

Обновление таблицы без опции WHERE:

Пример запроса

UPDATE t SET c = ‘v’;

Код

public void enterUpdatestmt(PostgreSQLParser.UpdatestmtContext ctx) {
   if (ctx.where_or_current_clause().a_expr() == null) {
        saveNewIssue(ctx);
    }
}

Удаление таблицы:

Пример запроса

DROP TABLE IF EXISTS t;

Код

public void enterDropstmt(PostgreSQLParser.DropstmtContext ctx) {
   if (ctx.object_type_any_name() != null
        && ctx.object_type_any_name().TABLE() != null) {
        saveNewIssue(ctx);
    }
}

Объявление колонки для идентификатора с типом, который может переполниться:

Пример запроса

CREATE TABLE t ( id BIGINT );

Код

public void enterColumnDef(PostgreSQLParser.ColumnDefContext ctx) {
   if ((ctx.colid().getText().equals("id")
         || ctx.colid().getText().endsWith("_id"))
        && ILLEGAL_TYPES.stream()
        .anyMatch(it -> it.equalsIgnoreCase(ctx.typename().getText()))) {
        saveNewIssue(ctx);
    }
}

Использование RESTART WITH:

ALTER SEQUENCE serial RESTART WITH 105;

Код

public void enterAlterseqstmt(PostgreSQLParser.AlterseqstmtContext ctx) {
   if (ctx.seqoptlist() != null) {
        var seqOptElem = ctx.seqoptlist().seqoptelem();
        if (seqOptElem != null) {
            seqOptElem.stream()
                .filter(seqOptElemContext ->
                        seqOptElemContext.RESTART() != null)
                .findAny()
                .ifPresent(this::saveNewIssue);
        }
    }

Пример работы правил представлен ниже:

Пример работы PostgreSQL Plugin
Пример работы PostgreSQL Plugin

Внимательный читатель, скорее всего, заметит, что обратно несовместимые изменения плагин, конечно, нашёл, но где гарантии, что старая версия приложения будет совместима с обновленным состоянием базы данных? С этой задачей сам по себе плагин, разумеется, не справится, ему потребуется помощь тестировщика. Соответственно, плагин должен каким-то образом уведомить тестировщика о необходимости проверки обратной совместимости. Тестировщик при проверке задачи, в первую очередь, будет опираться на задание на тестирование, которое для него написал разработчик. В нашем случае это отдельное поле в нашем менеджере задач.

Соответственно, при обнаружении обратно несовместимых изменений в этом поле должно появиться соответствующее уведомление. И тут нам на помощь приходят SonarQube Webhooks.

Схема применения SonarQube Webhooks
Схема применения SonarQube Webhooks

У нас есть отдельное приложение, управляющее нашим релизным циклом. Мы добавили для него поддержку вебхуков SonarQube. По завершении анализа релиза происходит следующее:

  • SonarQube отправляет в наше приложение уведомление о завершении анализа.

  • Приложение запрашивает список замечаний для релиза.

  • При наличии замечаний:

    • Ищет задачу, в рамках которой ведётся разработка (у нас номер задачи зашит в название git ветки).

    • Переводит задачу в тестирование (если она вдруг была готова к релизу, но были добавлены новые изменения).

    • В задание на тестирование добавляет список найденных замечаний.

Выглядеть это может таким образом:

Пример задания на тестирование
Пример задания на тестирование

Валидация OpenAPI спецификаций

Прежде чем обсуждать непосредственно валидацию, стоит сделать замечание о том, что мы, собственно, валидируем. Глобально у нас в компании можно выделить несколько типов API в зависимости от сценариев их использования:

  • Внутренние API — используются при взаимодействии компонентов внутри системы;

  • Внешние API — публичные API, предназначенные для вызова внешними партнёрами;

  • Мобильные API — API для мобильных приложений ЮMoney;

  • Kafka API — асинхронные API для взаимодействия компонентов через Apache Kafka.

К различным типам API у нас применяются различные наборы требований, которые нужно учитывать при проектировании подобных спецификаций. Кроме того, у нас все спецификации описываются в формате OpenAPI в YAML файлах, поэтому, независимо от типа, любая спецификация должна быть корректной с точки зрения формата YAML и стандарта OpenAPI. Отслеживать эти требования можно разными способами. Более того, мы достаточно долгое время использовали для этих целей отдельный Gradle-плагин. Однако данный подход был достаточно неудобным как в использовании, так и в сопровождении. Поэтому, поскольку SonarQube уже успел стать основным инструментом контроля качества в отделе, мы решили перенести в него функциональность для валидации спецификаций в виде нового плагина – OpenAPI plugin. Для его реализации мы использовали форк библиотеки societe-generale/sslr-yaml-parser (которую, к сожалению, уже удалили с GitHub), она отвечает за разбор спецификации с точки зрения формата YAML. Поверх этой библиотеки мы сделали собственную реализацию грамматики для стандарта OpenAPI, с помощью которой реализовали 26 правил для проверки требований к спецификациям.

Примеры правил:

Сегменты URL-path следует именовать в kebab-case формате:

paths:
  /kebab/case-path: …
  /camelCasePath: …
  /snake_case/path: …

Код

public List<RuleIssue> perform(@Nonnull OpenApiDocument<SonarProjectFileInfo> specification) {
    return specification.getRoot()
        .objectAt(JP_PATHS)
        .entrySet().stream()
        .filter(not(this::pathSegmentsMatch))
        .map(e -> issue(MESSAGE, e.getValue()))
        .collect(Collectors.toList());
}

private boolean pathSegmentsMatch(Map.Entry<String, ? extends OpenApiNode<?>> pathEntry) {
     return Arrays.stream(pathEntry.getKey()
                         .split("/"))
        .filter(not(String::isEmpty))
        .filter(not(isPathVariable))
        .allMatch(isKebabCase);
}

Это правило одно из самых простых. В нём мы вручную по JSON Pointer извлекаем нужный узел спецификации и проверяем его ключи на соответствие шаблону.

В запросах API разрешены только media-type application/json и multipart/form-data:

paths:
  /some-path:
    post:
      requestBody:
        content:
          application/xml:
            schema:
              type: string

Код

var issues = new RuleIssueCollector(this);
var allowedMediaTypes = Set.of(
    "application/json",
    "multipart/form-data"
);
var jpContent = JsonPointer.compile("/content");
requestBodyVisitor.visitor(issues)
    .scan(specification.getRoot(),
          response -> response
                      .objectKeysAt(jpContent)
                      .stream()
                      .filter(it -> !allowedMediaTypes
                                      .contains(it.getNode().stringValue()))
                      .forEach(it -> issues.addIssue(issue(MESSAGE, it)))
         );
return issues.getIssues();

Это правило более сложное. Поскольку блоков content в спецификации может быть достаточно много, а посетить их нужно все, для реализации этого правила нам потребовалось использовать паттерн Visitor.

Обязательные поля должны присутствовать в схеме:

SomeObject:
  type: object
  properties:
    id:
      type: string
  required:
    - id
    - missingField

Код

var issues = new RuleIssueCollector(this);
SCHEMA_VISITOR.visitor(issues).scan(specification.getRoot(), schema -> {
    var schemaLayers = collectNodeOverridingLayers(schema);
  
    var requiredNodes = schemaLayers.stream()
        .flatMap(it -> it.arrayAt(JP_REQUIRED).stream())
        .collect(Collectors.toList());
  
    var allPropertyNames = schemaLayers.stream()
        .flatMap(it -> it.objectAt(JP_PROPERTIES).keySet().stream())
        .collect(Collectors.toSet());

    requiredNodes.stream()
        .filter(it -> !allPropertyNames.contains(it.getNode().stringValue()))
        .map(it -> issue(MESSAGE, it))
        .forEach(issues::addIssue);
});
return issues.getIssues();

Это правило с технической точки зрения более сложное чем предыдущие. Помимо использования паттерна Visitor этому правилу приходится учитывать тонкости обработки ссылок и наследования в OpenAPI спецификациях, которые легко могут превратить спецификацию из дерева в граф с циклами. Такая логика, в основном, сокрыта за методом collectNodeOverridingLayers(), однако всё равно можно заметить, что вместо извлечения списка required полей конкретной схемы правилу приходится работать со списком всех возможных наследников, переопределяющих список required полей.

Вот пример замечания для последнего из правил:

Пример работы OpenAPI Plugin
Пример работы OpenAPI Plugin

Как видите, правило смогло учесть нюансы наследования и заметить, что в получившемся в результате наследования объекте действительно не хватает одного из required полей.

Отслеживание обратно несовместимых изменений в API

Ещё одна боль — обратно несовместимые изменения в API. Наверняка кто-нибудь из читателей сталкивался с ситуацией, когда соседняя команда внезапно сделала один из параметров запроса обязательным, после чего ваши запросы перестали проходить валидацию в их сервисе, или вдруг расширила список значений в перечислении в ответе, и тут уже начали падать вы при десериализации полностью корректного с точки зрения коллег ответа. Чтобы с подобными ситуациями бороться, следует достаточно осторожно вносить подобные изменения в API. Мы придерживаемся следующих требований:

  • Если обновляется внутренний API, то необходимо подготовить клиентов к работе с его новой версией, или хотя бы убедиться, что на их стороне ничего не сломается.

  • Если обновляется внешний API, то лучше ещё раз хорошо подумать и вообще отказаться от обратно несовместимых изменений.

  • Независимо от сценария обратно несовместимые изменения обязательно нужно задокументировать. У нас для этого используются файлы CHANGELOG.md, в которых мы описываем список всех изменений, выполненных в рамках конкретной задачи. Кроме описания изменений у нас ещё требуется указать тип внесённых изменений – MAJOR (обратно несовместимые изменения), MINOR (новая функциональность), PATCH (мелкие правки). Ну и чтобы все точно обратили внимание на обратно несовместимые изменения, в CHANGELOG.md их дополнительно нужно пометить тегом breaking changes.

Для проверки этих требований мы тоже решили использовать SonarQube Plugin – API Changes plugin. Для своей работы он использует инструмент Oasdiff – запускает его (как Semgrep) и потом преобразует его отчёты в список issues. Кроме того, плагин следит, чтобы разработчик не забыл пометить изменения в спецификации как мажорные.

Смотрим на примере. Предположим, у нас в схеме ответа есть перечисление KeyType, и мы хотим расширить его, добавив новое значение SomeNewKeyType:

KeyType:
  type: string
  enum:
    - OldKeyType
    - SomeNewKeyType

Кроме того, внезапно оказалось, что наш метод не будет работать без параметра expiresAt, который изначально был объявлен необязательным. Поэтому мы решили в рамках текущей задачи заодно исправить эту недоработку и потребовать обязательность этого параметра.

requestBody:
  type: object
  properties: 
    …
    expiresAt:
      type: string
  required:
    - otherField
    - expiresAt

По завершении анализа спецификации мы сразу же получим пачку замечаний от плагина:

Пример замечаний об обратно несовместимых изменениях в API
Пример замечаний об обратно несовместимых изменениях в API

Кроме того, я не пометил изменения как MAJOR, за что получил ещё одно замечание:

Пример замечания о некорректном CHANGELOG
Пример замечания о некорректном CHANGELOG

К сожалению, по аналогии с миграциями в базах данных, одной лишь проверкой в SonarQube безопасности внесения подобных изменений не обеспечить, поэтому нам снова нужна помощь тестировщика. Этот плагин тоже опирается на ранее описанную логику с обновлением задания на тестирование – тут оно будет выглядеть следующим образом:

Пример задания на тестирование
Пример задания на тестирование

Итоги

SonarQube стал для нас не просто SAST-инструментом, а полноценной платформой контроля качества и безопасности. Плагины собственной разработки позволяют:

  • Контролировать отказ от ненужных технологий;

  • Интегрировать сторонние инструменты вроде Semgrep;

  • Проверять безопасность миграций и консистентность API.

И всё это — в едином окне SonarQube, с понятным UI и интеграцией с нашим CI/CD.

Если вы сейчас выбираете инструмент SAST, рекомендую попробовать SonarQube. Вероятнее всего, он закроет значительную часть ваших потребностей. Если же вам нужны некоторые специфичные проверки, вы всегда можете пойти по нашему пути и реализовать их в виде SonarQube плагинов собственной разработки.


Сейчас в команду бэкенд-разработки ЮMoney ищем Java-разработчика. Откликайтесь на вакансию, если хотите присоединиться к нам и вместе делать финтех-продукты, которыми пользуются миллионы людей. 😉

Подписывайтесь на нас в Telegram, чтобы не пропускать новости и статьи о технологиях в финтехе.

Теги:
Хабы:
+8
Комментарии0

Публикации

Информация

Сайт
jobs.yoomoney.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
yooteam