ASOC на коленке: как я навайбкодил замену DefectDojo для своих задач с обогащением из БДУ ФСТЭК

от автора

Когда я начал разбираться, чем в мире опенсорса можно закрыть задачу ASOC / Vulnerability Management, выбор оказался довольно грустным. По сути единственный известный вариант это DefectDojo. Сам я его в проде не тащил, но от коллег регулярно слышал одну и ту же боль: на больших объёмах он начинает захлёбываться, и тебе просто больше не хочется заходить, а аналогов с человеческим видом и БДУ ФСТЭК «из коробки» в опенсорсе я просто не нашёл. Так и появилась моя ASOC-платформа: Go + PostgreSQL + Redis Streams + React, развёртывание одной командой docker compose up, миллион записей без тормозов (почти), обогащение из 7 источников, формула приоритизации, которая учитывает не только CVSS, но ещё EPSS, CISA KEV и БДУ ФСТЭК. В статье расскажу про архитектурные решения, грабли и почему я выкинул ORM ещё до первой строчки SQL.

Это не статья про готовый коммерческий продукт и не пиар-релиз. Скорее разбор того, как и почему был спроектирован Red Lycoris, опенсорс-платформа для централизованного хранения, дедупликации, обогащения и приоритизации уязвимостей. Я делаю её один, и если кому-то она пригодится, буду только рад. Если найдёте, где я ошибся в архитектуре, буду рад вдвойне.

Страница дашбордов

Страница дашбордов

Что такое ASOC и зачем он вообще нужен

ASOC (Application Security Orchestration and Correlation) — это слой между разными инструментами безопасности и людьми, которым нужно принимать решения по их результатам: AppSec-командой, разработчиками, тимлидами и иногда менеджерами продукта.

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

Когда AppSec-процесс в компании начинает развиваться, обычно появляется не один универсальный сканер, а набор инструментов под разные типы проверок. Конкретный стек у всех разный: где-то используют open-source, где-то коммерческие решения, где-то всё держится на скриптах в CI. Но сами категории проверок часто повторяются:

  • SAST — статический анализ кода: Semgrep, OpenGrep, SonarQube, CodeQL или аналоги.

  • SCA — анализ зависимостей и open-source компонентов: Trivy, Grype, Snyk, Mend и другие решения.

  • DAST — динамическое тестирование приложений: OWASP ZAP, Burp Suite, Acunetix или похожие инструменты.

  • IaC — проверка инфраструктурного кода и конфигураций: Checkov, tfsec, Trivy config и аналоги.

  • Secrets — поиск секретов, токенов и ключей: TruffleHog, Gitleaks и другие сканеры.

  • Контейнеры — анализ образов и пакетов внутри них: Trivy image, Grype и похожие решения.

Проблема начинается не из-за самих инструментов. Каждый из них может быть полезен в своей зоне. Проблема в том, что они говорят на разных языках: у каждого свой формат отчёта, своя модель критичности, своё понимание дубликатов и свой способ описывать одну и ту же уязвимость.

Например, один и тот же CVE-2021-44228 может всплыть сразу в нескольких местах: SCA-сканер найдёт его в зависимостях, контейнерный сканер внутри собранного образа, другой инструмент в lockfile или SBOM. Формально это несколько записей, но по смыслу они могут относиться к одной проблеме в одном продукте.

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

Вот здесь и появляется смысл ASOC: не заменить сканеры, а собрать их результаты, нормализовать данные, связать находки между собой и помочь команде понять, что чинить в первую очередь.

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

Обычно он закрывает несколько задач:

  1. Импорт и парсинг — принимает отчёты от разных инструментов и приводит их к единой модели данных.

  2. Нормализация — выравнивает названия полей, severity, статусы, типы уязвимостей и метаданные.

  3. Дедупликация — склеивает повторяющиеся находки, чтобы одна проблема не висела в системе тремя разными critical-записями.

  4. Приоритизация — помогает понять, что чинить первым, потому что “50 critical от сканера” и реальные возможности команды разработки это разные вселенные.

  5. API и интерфейс — даёт CI/CD понятный способ отправлять результаты, а AppSec и разработчикам нормальный экран для разбора, фильтрации и принятия решений.

Важно не путать ASOC со сканером, SIEM или ASM/ASMP.

ASOC сам ничего не сканирует. Он принимает результаты от других инструментов и помогает с ними работать.
SIEM больше про события безопасности, логи и мониторинг.
ASM/ASMP — про внешнюю поверхность атаки: домены, IP, открытые сервисы и экспозицию в интернете.
ASOC — про AppSec-процесс внутри SDLC: код, зависимости, контейнеры, IaC, секреты и результаты проверок в CI/CD.

Почему я не остался на DefectDojo

DefectDojo — самый известный open-source ASOC. Но после отзывов коллег и первичного изучения я понял, что для моей задачи он подходит не так хорошо.

Производительность. На объёмах около ~100k находок интерфейс уже может становиться тяжёлым: фильтры открываются долго, список работает не так отзывчиво, как хотелось бы. На 1M+ записей без отдельной оптимизации жить становится сложно. Классический Django + ORM + пагинация через OFFSET дают о себе знать, особенно когда начинаешь смотреть планы запросов на больших таблицах.

Нет БДУ ФСТЭК из коробки. Для российских пользователей это важный источник данных: где-то критичный, где-то просто очень удобный. В стандартном наборе чаще встречаются NVD, EPSS, иногда KEV. БДУ можно добавить самостоятельно, но это уже отдельная разработка, а не готовая возможность платформы.

Устаревший интерфейс. Bootstrap 3 и Django-шаблоны работают, но в 2026 году от ASOC хочется другого уровня UX: быстрых таблиц на большие объёмы, нормальной фасетной фильтрации, сохранения состояния в URL и возможности отправить коллеге ссылку уже с нужными фильтрами.

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

Я не агитирую против DefectDojo. Для многих команд это всё ещё более зрелый и безопасный выбор, чем мой ASOC, особенно на умеренных объёмах. Просто мне нужен был другой фокус: большие данные, российский контекст, закрытый контур и интерфейс, рассчитанный на быстрый разбор находок.

Что должно было получиться

На старте я сформулировал для себя несколько требований:

Требование

Зачем

Развёртывание у себя, опционально без интернета

Целевая аудитория — банки, госструктуры и телеком. Для таких организаций облачное решение часто не вариант по требованиям безопасности и compliance.

БДУ ФСТЭК как первоклассный источник

Для российских команд это не просто “ещё одна база”, а важный источник для сверки, обогащения и приоритизации уязвимостей.

1M+ записей без тормозов

Даже не самая большая компания за пару лет может накопить сотни тысяч находок, особенно если сканирование встроено в CI/CD.

Один бинарник + Postgres + Redis

Без обязательного Kubernetes и сложной инфраструктуры. Платформа должна запускаться на обычной виртуалке.

Понятный REST API + OpenAPI

Находки чаще приезжают из CI/CD, скриптов и автоматизации, а не через ручную загрузку в интерфейсе. Хотя ручной импорт тоже нужен.

Современный интерфейс

ASOC не должен выглядеть как админка из 2012 года. Нужен понятный, быстрый и аккуратный UI без визуального перегруза.

Русский язык по умолчанию

Целевая аудитория — российские команды, поэтому интерфейс, статусы и описания должны быть понятны без постоянного переключения контекста.

Название Red Lycoris появилось без большой бренд-стратегии. Я просто часто видел этот цветок в аниме.

Стек и почему именно такой

Стек я выбирал не по принципу “что сейчас модно”, а под конкретные ограничения: закрытый контур, большие объёмы данных, простое развёртывание и предсказуемая поддержка.

Слой

Выбор

Почему

Серверная часть

Go 1.25 + chi router

Один статический бинарник, быстрый старт, хорошая конкурентность и удобная модель для фоновой обработки.

База

PostgreSQL 16

JSONB для enrichment-данных, материализованные представления для тяжёлых витрин, частичные индексы для частых выборок и партиционирование для журнала аудита.

Очередь

Redis 7 Streams

Для фоновых задач нужна надёжная очередь с группами потребителей. Kafka для развёртывания на одной ноде была бы избыточной.

Клиентская часть

React 18 + TypeScript strict + Vite

TanStack Table, Query и Virtual закрывают основные задачи тяжёлого интерфейса: таблицы, кэширование, фильтры и виртуализацию.

Стилизация

Tailwind CSS + shadcn/ui

Позволяет быстро собирать аккуратный интерфейс без ручной CSS-фермы. Тёмная тема — по умолчанию.

Доступ к БД

pgx/v5, без ORM

Нужен полный контроль над SQL, индексами, планами запросов и поведением на больших таблицах.

Миграции

golang-migrate

Простые SQL-миграции без лишней магии и скрытых абстракций.

Развёртывание

Docker Compose

Без обязательного Kubernetes. Один up -d -и платформа поднимается на обычной виртуалке.

Отдельно я зафиксировал несколько принципиальных решений в CLAUDE.md репозитория. Они уже несколько раз спасали проект от моих же импульсов “а давай прикрутим ещё вот это”:

  • никакого ORM — только чистый SQL через pgx/v5;

  • никакой пагинации через OFFSET — только курсорная пагинация;

  • никаких новых зависимостей без явного обоснования;

  • никаких абстракций “на будущее” — интерфейс появляется только тогда, когда есть минимум две реализации.

Я понимаю, что фраза “без ORM” для многих звучит спорно. Дальше расскажу, почему в этом проекте это оказалось не ограничением, а осознанным техническим решением.

Архитектура: куда что течёт

Я не художник, поэтому изображение тоже рисовало ИИ

Я не художник, поэтому изображение тоже рисовало ИИ

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

В DefectDojo при импорте нужно заранее указать тип сканера. Я хотел сделать иначе: пользователь загружает файл, а платформа сама пытается понять, что перед ней.

Сейчас в платформе есть 10 встроенных парсеров:

Парсер

Формат

Тип находок

SARIFParser

SARIF 2.1.0

универсальный формат для Semgrep, CodeQL и других

TrivyParser

Trivy JSON

SCA, IaC, secrets

GrypeParser

Grype JSON

SCA

SemgrepParser

Semgrep native JSON

SAST

GosecParser

gosec JSON / SARIF

Go SAST

ZAPParser

ZAP JSON

DAST

TruffleHogParser

TruffleHog v3: NDJSON и array

Secrets

GitleaksParser

Gitleaks JSON

Secrets

CheckovParser

Checkov JSON

IaC

GenericParser

Generic JSON

универсальный запасной вариант

Автоопределение сделано максимально просто: каждый парсер реализует CanParse([]byte) bool и проверяет характерные признаки своего формата. Например, для SARIF:

func (p *SARIFParser) CanParse(data []byte) bool {    var probe struct {        Schema  string `json:"$schema"`        Version string `json:"version"`    }    if err := json.Unmarshal(data, &probe); err != nil {        return false    }    schema := strings.ToLower(strings.TrimSpace(probe.Schema))    version := strings.TrimSpace(probe.Version)    return strings.Contains(schema, "sarif") || version == "2.1.0"}

Дальше парсеры пробуются по порядку. Первый, кто распознал формат, получает файл на разбор:

var parsers = []struct {    name   string    parser Parser}{    {"sarif", &SARIFParser{}},    {"trivy", &TrivyParser{}},    {"semgrep", &SemgrepParser{}},    // ... остальные    {"generic", &GenericParser{}},}func DetectAndParse(ctx context.Context, data []byte) (string, []domain.Finding, error) {    for _, p := range parsers {        if p.parser.CanParse(data) {            findings, err := p.parser.Parse(ctx, data)            if err != nil {                return p.name, nil, err            }            return p.name, findings, nil        }    }    return "", nil, errors.New("unsupported format: no parser matched the input")}

Последним в списке стоит GenericParser. Он умеет читать простой JSON с минимальным набором полей и нужен как запасной путь для самописных сканеров или внутренних инструментов, под которые ещё нет отдельного парсера.

Ничего интересного, простая страница загрузки результатов через UI

Ничего интересного, простая страница загрузки результатов через UI

Дедупликация: где начинается настоящая боль

Дедупликация — одна из самых неприятных задач в ASOC. На словах всё просто: если находка уже есть, не нужно создавать вторую. На практике сразу возникает вопрос: а что считать дублем?

Вариантов много, и у каждого есть минусы:

  1. cve + component — подходит для SCA, но не работает для SAST-находок без CVE.

  2. file_path + line — полезно для кода, но плохо подходит для SCA, где один pom.xml может породить десятки CVE.

  3. rule_id + file — схлопнет разные срабатывания одного правила в одном файле.

  4. Полный хэш от всей находки — почти ничего не схлопнет, потому что достаточно измениться одному полю.

В итоге я остановился на гибридном подходе: для отпечатка берутся стабильные поля, которые хорошо описывают природу находки, а результат прогоняется через SHA256.

func CalculateFingerprint(f *Finding) string {  var cveID string  if len(f.CVEIDs) > 0 {  cveID = f.CVEIDs[0]  }    var cweID string  if len(f.CWEIDs) > 0 {  cweID = fmt.Sprintf("%d", f.CWEIDs[0])  } else {  cweID = "0"  }    var ruleID string  if f.RuleID != nil {  ruleID = *f.RuleID  }    parts := []string{  strconv.Itoa(int(f.Kind)),  strings.ToLower(ruleID),  strings.ToLower(cveID),  strings.ToLower(f.FilePath),  strconv.Itoa(f.LineStart),  strconv.Itoa(f.LineEnd),  cweID,  strings.ToLower(f.Component),  strings.ToLower(f.ComponentVersion),  }    input := strings.Join(parts, "\x00")  h := sha256.Sum256([]byte(input))    return fmt.Sprintf("%x", h)  }

Важная деталь: в отпечаток входит Kind, то есть тип находки. Благодаря этому SCA и SAST не схлопываются в одну запись, даже если у них совпал CVE. Это разные классы проблем, и аналитик должен видеть их отдельно.

При повторном импорте, если такой отпечаток уже есть в базе, новая находка не создаётся. Вместо этого обновляются поля существующей записи:

  • last_seen = now();

  • times_seen += 1.

Так система понимает, что проблема всё ещё актуальна, но не засоряет интерфейс одинаковыми строками.

Отдельно ведётся история событий в таблице finding_events: смена статуса, назначение ответственного, комментарии и другие действия. Это нужно не только для удобства, но и для аудита. Вопрос “кто, когда и почему закрыл эту уязвимость” в таких системах возникает регулярно.

Для секретов используется отдельная логика отпечатка. Значение секрета нельзя хранить в базе в открытом виде, поэтому в fingerprint попадает только хэш от стабильных признаков, а сам секрет в БД не сохраняется.

Основная страница находок (уязвимостей)

Основная страница находок (уязвимостей)

Обогащение: 7 источников, 3 воркера, Redis Streams

Голый CVE без контекста почти бесполезен. Запись вида “CVE-2024-XXXXX, High” сама по себе мало что говорит команде. Другое дело, когда рядом есть EPSS, KEV, БДУ ФСТЭК, CWE, ссылки на источники и понимание, насколько уязвимость реально опасна именно сейчас.

Например: “CVE-2024-XXXXX, High, EPSS 87%, есть в CISA KEV, срок исправления через 5 дней, есть связь с БДУ ФСТЭК”. Это уже не просто строка в отчёте, а основание для приоритизации.

Какие источники используются

Источник

Что даёт

Частота обновления

Объём

NVD API 2.0

CVSS v2/v3.1/v4.0, описания, CPE matches, ссылки

каждые 2 часа, инкрементально

~340k CVE

EPSS / FIRST

Вероятность эксплуатации в ближайшие 30 дней

ежедневно

~330k CVE + история

CISA KEV

Уязвимости, которые уже эксплуатируются в реальности

каждые 6 часов

~1.5k записей

БДУ ФСТЭК

Российский каталог уязвимостей, связи с CVE и CWE

еженедельно

~87k записей

OSV

Уязвимости в open-source экосистемах

ежедневно

~606k записей

CWE / MITRE

Классификация типов слабостей

ежемесячно

~960 записей

NVD CPE

Словарь продуктов и платформ для сопоставления

еженедельно

~1.6M записей

Все справочники зеркалируются локально. Это важно для закрытого контура: если у развёртывания нет доступа в интернет, платформа продолжает работать по последнему загруженному слепку. Синхронизация просто останавливается, но обогащение и приоритизация не превращаются в тыкву.

Как это работает

Технически обогащение построено на Redis Streams и группе из трёх воркеров. Каждое сообщение в очереди — это finding_id.

Воркер забирает находку из БД, проходит по локальным справочникам, добавляет найденные данные в JSONB-поля по каждому источнику и после этого пересчитывает priority_score.

Отдельную Kafka для такой задачи тащить не хотелось. Здесь сценарий простой: взять сообщение из очереди, обработать, подтвердить. Redis Streams для этого достаточно, особенно если платформа должна нормально жить на одной виртуалке.

Подтверждение через XACK выполняется только после успешной обработки. Если воркер падает на середине, сообщение остаётся неподтверждённым и позже может быть забрано другим воркером. Повторная обработка безопасна: идемпотентность держится на уровне БД через INSERT ... ON CONFLICT DO UPDATE, поэтому повторное обогащение не ломает данные и не создаёт мусор.

Пример обогащения БДУ ФСТЭК на одной находке

Пример обогащения БДУ ФСТЭК на одной находке

Приоритизация: формула, а не просто “critical”

Если в системе 50k находок со статусом critical, значит сама критичность уже мало помогает. Нужна оценка, которая учитывает не только severity от сканера, но и реальный контекст: эксплуатируется ли уязвимость, есть ли она в KEV, насколько высокий EPSS, затронут ли внешний сервис и доступно ли исправление.

В Red Lycoris для этого считается priority_score:

priority_score = cvss_base × 0.30  + epss × 100 × 0.25  + (kev_bonus + urgency_bonus) × 0.20  + bdu_bonus × 0.10  + recency × 0.10  + exposure × 0.05  + trend_bonus × 0.05  + fix_bonus × 0.03

Где:

  • cvss_base — наивысший доступный CVSS: v4.0 → v3.1 → v2.0;

  • epss × 100 — вероятность эксплуатации в ближайшие 30 дней, приведённая к шкале 0..100;

  • kev_bonus — бонус за наличие в CISA KEV, отдельно учитывается связь с ransomware;

  • urgency_bonus — чем ближе KEV-дедлайн, тем выше вклад;

  • bdu_bonus — дополнительный вес, если уязвимость есть в БДУ ФСТЭК;

  • recency — свежесть уязвимости с экспоненциальным затуханием;

  • exposure — коэффициент доступности сервиса: интернет, внутренний контур, изолированная среда;

  • trend_bonus — рост EPSS за последнюю неделю;

  • fix_bonus — наличие информации об исправлении, например в OSV.

После этого сырое значение нормализуется в диапазон 0..10:

raw := baseScore*0.30 +    epssScore*100.0*0.25 +    (kevBonus+urgencyBonus)*0.20 +    bduBonus*0.10 +    recency*0.10 +    exposure*0.05 +    trendBonus*0.05 +    fixBonus*0.03score := (raw / maxRaw) * 10.0return math.Min(10.0, math.Max(0.0, math.Round(score*100)/100))

Смысл в том, что CVSS — это только часть оценки, а не единственный источник правды. CVE с CVSS 9.8, но почти нулевым EPSS, без KEV и без признаков эксплуатации, не всегда должна быть первой в очереди. А вот Medium, который уже попал в KEV и имеет дедлайн через несколько дней, должен подниматься выше.

Витрина для быстрых списков

Считать эту формулу на лету при каждом открытии списка находок — плохая идея. Поэтому итоговый priority_score хранится отдельно и пересчитывается при обогащении находки, а также периодически обновляется фоновым процессом.

Для интерфейса это уже готовая витрина: список просто сортируется по priority_score DESC, без тяжёлой математики на горячем пути. Пользователь открывает таблицу и сразу видит не просто “critical от сканера”, а порядок, в котором находки действительно стоит разбирать.

Пример расчет приоритета

Пример расчет приоритета

Производительность: что я вынес

Это, наверное, самая болезненная и самая полезная часть проекта.

Курсорная пагинация вместо OFFSET

Классическая пагинация выглядит так:

SELECT *  FROM findings  WHERE project_id = $1  ORDER BY created_at DESC  LIMIT 50 OFFSET 50000;

Проблема в том, что при большом OFFSET PostgreSQL всё равно должен пройти лишние строки: найти первые 50000 + 50, отбросить 50000 и вернуть только 50. На больших таблицах это быстро превращается в неприятную задержку в интерфейсе.

Вместо этого я использую курсорную пагинацию, она же keyset pagination:

SELECT *FROM findingsWHERE project_id = $1  AND (first_seen, id) < ($cursor_first_seen, $cursor_id)ORDER BY first_seen DESC, id DESCLIMIT 51; -- +1, чтобы понять, есть ли следующая страница

Курсор — это base64-закодированный JSON с двумя полями:

type findingsCursor struct {    FirstSeen time.Time `json:"first_seen"`    ID        uuid.UUID `json:"id"`}

Под такой запрос нужен составной индекс:

CREATE INDEX idx_findings_project_first_seen_id    ON findings (project_id, first_seen DESC, id DESC);

В итоге скорость листания почти не зависит от номера страницы: база идёт по индексу и забирает следующие 50 строк, а не пересчитывает всё с начала. На dev-стенде с 1M записей серверная часть укладывалась в приемлемое время без ощущения, что “страница 1000” превращается в пытку.

Минус очевидный: нельзя мгновенно прыгнуть на страницу 1000. Но для ASOC это почти не нужно. В реальной работе пользователь фильтрует, сортирует и ищет конкретные находки, а не листает таблицу как телефонную книгу.

pgx/v5 + чистый SQL, без ORM

Этот пункт обычно вызывает больше всего споров.

ORM удобен, когда нужно быстро делать типовые операции: получить запись по ID, сохранить, удалить, подтянуть связи. Но в моём случае значительная часть запросов — это сложные выборки с фильтрами, JOIN, фасетами, счётчиками и сортировкой по рассчитанным полям.

На таких задачах ORM начинает мешать:

  1. Простая строчка в коде может превращаться в несколько запросов к базе.

  2. Становится сложнее понять, какой SQL реально ушёл в PostgreSQL.

  3. Оптимизация запроса превращается в борьбу не только с базой, но и с абстракцией поверх неё.

С pgx/v5 всё прямолинейно: какой SQL написал, такой и ушёл в базу.

const q = `    SELECT f.id, f.title, f.severity, f.status,           fs.priority_score, fs.is_kev, fs.is_bdu,           p.name AS project_name    FROM findings f    LEFT JOIN finding_scores fs ON fs.finding_id = f.id    LEFT JOIN projects p ON p.id = f.project_id    WHERE f.project_id = $1      AND f.severity = ANY($2)      AND (f.first_seen, f.id) < ($3, $4)    ORDER BY f.first_seen DESC, f.id DESC    LIMIT $5`rows, err := r.pool.Query(    ctx,    q,    projectID,    severities,    cursor.FirstSeen,    cursor.ID,    limit,)

Никакой скрытой магии. Если запрос медленный, я беру его, запускаю EXPLAIN ANALYZE, смотрю план и правлю SQL или индексы. Изменение схемы — отдельная SQL-миграция, а не побочный эффект изменения модели.

Индексы под конкретные запросы

Я не стал делать “индексы на всё”. Каждый индекс занимает место, замедляет вставки и обновления, а иногда ещё и путает планировщик. Поэтому индексы добавлялись под конкретные сценарии интерфейса и API.

-- Курсорная пагинацияCREATE INDEX idx_findings_project_first_seen_id    ON findings (project_id, first_seen DESC, id DESC);-- Фасетная агрегация по типу, статусу и проектуCREATE INDEX idx_findings_kind_status_project    ON findings (finding_kind, status, project_id);-- Частичный индекс для находок с CVECREATE INDEX idx_findings_has_cve    ON findings (id)    WHERE cve_ids IS NOT NULL AND array_length(cve_ids, 1) > 0;-- Частичный индекс для находок с доступным исправлениемCREATE INDEX idx_findings_fixed_version_present    ON findings (id)    WHERE fixed_version IS NOT NULL;

Частичные индексы здесь особенно полезны. Если интерфейсу нужно быстро показать, например, сколько находок имеют CVE или доступный фикс, базе не обязательно каждый раз проходить всю таблицу. Она может использовать маленький целевой индекс только по нужному подмножеству данных.

Главный вывод простой: производительность появилась не из-за одного “секретного” приёма, а из-за набора скучных решений — курсорная пагинация, понятный SQL, индексы под реальные запросы и отсутствие лишних абстракций на горячем пути.

Безопасность самой платформы

ASOC сам становится частью security-процесса, поэтому странно было бы относиться к его безопасности по остаточному принципу. Платформа хранит чувствительные данные: результаты сканирований, секреты в отчётах, историю уязвимостей, статусы, комментарии и действия пользователей.

Аутентификация

Для интерфейса используются cookie-сессии:

Set-Cookie: rl_session=<opaque_token>; HttpOnly; Secure; SameSite=Strict; Path=/
  • HttpOnly — cookie недоступна из JavaScript и хуже крадётся через XSS.

  • Secure — отправляется только по HTTPS.

  • SameSite=Strict — снижает риск CSRF-атак.

Для CI/CD используются Bearer-токены. Они привязываются к конкретному проекту, могут иметь срок действия и ротируются через интерфейс.

Первичный администратор создаётся при первом запуске из переменных окружения:

BOOTSTRAP_ADMIN_EMAIL=BOOTSTRAP_ADMIN_PASSWORD=

После первого входа пароль нужно сменить. Это простая защита от ситуации, когда временный bootstrap-пароль случайно остаётся постоянным.

Для входа действует rate limit: 5 попыток за 15 минут на пару IP + email. При превышении платформа возвращает HTTP 429 и заголовок Retry-After.

Пароли хранятся через bcrypt. Никаких MD5, SHA1 или самодельных “быстрых” схем.

Ролевой доступ

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

Глобальные роли:

Роль

Возможности

admin

Полный доступ к платформе

auditor

Просмотр находок, журнал аудита и экспорт

member

Работа с находками в назначенных проектах

viewer

Только просмотр в назначенных проектах

Роли внутри проекта:

Роль

Возможности в проекте

viewer

Просмотр

triager

Изменение статусов, назначение ответственных, импорт

project_admin

Управление участниками, токенами и настройками проекта

Один пользователь может быть администратором в одном проекте, triager в другом и viewer в третьем. На уровне БД это хранится в таблице user_project_roles(user_id, project_id, role).

Защита от случайных критичных действий

Некоторые действия в системе специально защищены от “сам себе отстрелил ногу”:

  • нельзя удалить или понизить последнего активного глобального администратора;

  • нельзя удалить последнего администратора проекта;

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

  • при смене пароля сбрасываются все активные сессии пользователя.

Перед удалением или понижением роли платформа проверяет, что в системе останется хотя бы один активный администратор: CountActiveAdmins() > 1. Для проектов работает аналогичная проверка.

Журнал аудита

Все изменяющие действия пишутся в audit_log: кто выполнил действие, когда, над какой сущностью и что изменилось. Для части операций сохраняется состояние “до” и “после”, чтобы можно было восстановить контекст решения.

Это важно не только для безопасности, но и для обычной эксплуатации. Вопрос “кто закрыл эту находку и почему?” в AppSec возникает постоянно.

Что платформа не закрывает

Здесь важно быть честным: ASOC — это не магический купол безопасности вокруг самого себя.

  • Сетевой периметр остаётся задачей nginx, firewall и инфраструктурных настроек.

  • TLS обычно завершается на обратном прокси.

  • MFA пока нет, это запланировано на 0.4.0.

  • Секреты интеграций лучше хранить во внешнем Vault, а не в .env.

  • Supply chain полностью не закрыт: если используются образы из Docker Hub, для закрытого контура нужно своё зеркало и контроль артефактов.

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

Самая простая страница создания пользователя

Самая простая страница создания пользователя

Интерфейс: дизайн без боли

Клиентская часть — это React 18, TypeScript strict, TanStack Query/Table/Virtual, Tailwind CSS и shadcn/ui. Тёмная тема используется по умолчанию, акцентный цвет — #930000, оттенок красного ликориса. Остальная палитра максимально нейтральная: интерфейс не должен спорить с данными.

Несколько принципов, которые я для себя зафиксировал:

  1. #930000 только для главного действия на странице.
    Одна основная кнопка, всё остальное — нейтральное. Так пользователю проще понять, куда нажимать.

  2. Состояние фильтров хранится в адресной строке.
    Можно собрать нужную выборку и отправить ссылку коллеге: например, “вот эти critical-находки с KEV-дедлайном меньше недели”. Для AppSec-разбора это сильно удобнее, чем объяснять фильтры словами.

  3. Всё, что больше 50 строк, виртуализируется.
    Большие таблицы не должны убивать браузер. Для этого используется TanStack Virtual, а высота строк рассчитывается заранее.

  4. Загрузка данных — только через TanStack Query.
    Никаких ручных useEffect для запросов. Это правило отдельно записано в CLAUDE.md, потому что я сам несколько раз нарушал его “по-быстрому” — и каждый раз потом находил лишний запрос или странное состояние загрузки.

Интерфейс ещё активно шлифуется. Если на скриншотах что-то выглядит неидеально, скорее всего, это уже в списке на переделку.

Развёртывание

Целевой сценарий развёртывания — одна виртуалка и несколько команд:

git clone https://github.com/nefrit0n/red-lycoris.gitcd red-lycoriscp env.example .env# Заполнить POSTGRES_PASSWORD, BOOTSTRAP_ADMIN_EMAIL, BOOTSTRAP_ADMIN_PASSWORDdocker compose build --no-cachedocker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

После запуска поднимается минимальный стек:

  • backend — Go-бинарник в Alpine-образе, без root, uid 1000;

  • frontend — nginx на stable-alpine, отдельный пользователь USER 101;

  • postgres:16 — основная база данных;

  • redis:7 — очередь и служебные фоновые задачи.

Для production-сценария есть отдельный профиль docker-compose.prod.yml: лимиты ресурсов, healthcheck-и, JSON-логирование и более строгие настройки контейнеров.

TLS/HTTPS я намеренно вынес за пределы приложения. Обычно в такой инфраструктуре перед платформой уже стоит внешний nginx, reverse proxy или балансировщик, который отвечает за сертификаты, маршрутизацию и сетевой периметр (но в будущем попытаюсь интегрировать).

Что дальше

Сейчас платформа находится в состоянии 0.1.0b — это первый публичный бета-релиз. На версию 0.2.0 я пока закладываю несколько направлений:

  • Импорт SBOM — поддержка CycloneDX и SPDX с последующим обогащением через БДУ ФСТЭК и другие локальные справочники.

  • Новые парсеры — список ещё формируется. Если у вас есть инструмент, который хочется попробовать подключить, буду рад добавить поддержку его формата.

  • Auto-resolution — автоматическое закрытие находок, которые больше не появляются в новых результатах сканирования.

Дальше буду смотреть по обратной связи и реальным сценариям использования. На этапе 0.1.0b легко придумать большой roadmap на год вперёд, но сейчас важнее другое: стабилизировать базовые сценарии, закрыть обещанное в 0.2.0 и не превратить проект в набор красивых, но недоделанных идей.

Выводы

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

В итоге архитектура получилась достаточно прямолинейной:

  • парсеры — контракт CanParse + Parse;

  • дедупликация — одна функция CalculateFingerprint;

  • обогащение — фоновые воркеры на Redis Streams;

  • приоритизация — отдельная формула и готовая витрина для интерфейса;

  • API — курсорная пагинация на всех больших списках.

Главные уроки:

  1. Производительность нужно закладывать с самого начала.
    Курсорная пагинация, целевые индексы и отдельные витрины для тяжёлых данных сильно проще проектировать сразу, чем добавлять через год, когда половина интерфейса уже завязана на старую модель.

  2. Минимальный стек часто выигрывает.
    Redis Streams вместо Kafka, pgx вместо ORM, Docker Compose вместо обязательного Kubernetes. Меньше движущихся частей — проще отладка, развёртывание и сопровождение.

  3. Скучные технологии обычно оказываются лучшими.
    Go, PostgreSQL, Redis, React — без экзотики и архитектурного цирка. Такой стек проще понять, поддерживать и объяснять новым людям.

  4. Безопасность самой платформы не опциональна.
    ASOC хранит чувствительные данные и участвует в security-процессе. Поэтому аутентификация, RBAC, аудит, rate limit и аккуратная работа с секретами — это не “потом”, а базовый минимум.

Если у вас есть опыт централизованной работы с уязвимостями, расскажите в комментариях, что используете и что в этом больше всего раздражает. Особенно интересно: импорт отчётов, дубли, приоритизация, интерфейс, интеграции с CI/CD или работа в закрытом контуре.

Если хотите поднять Red Lycoris у себя и потыкать — буду рад обратной связи. Для проекта на стадии 0.1.0b это сейчас самый ценный источник развития.

Репозиторий: github.com/nefrit0n/red-lycoris
Лицензия: Apache 2.0.

ссылка на оригинал статьи https://habr.com/ru/articles/1033530/