惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Visual Studio Blog
爱范儿
爱范儿
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
雷峰网
雷峰网
V
V2EX
博客园_首页
Engineering at Meta
Engineering at Meta
博客园 - 聂微东
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Apple Machine Learning Research
Apple Machine Learning Research
GbyAI
GbyAI
H
Help Net Security
A
About on SuperTechFans
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Blog — PlanetScale
Blog — PlanetScale
W
WeLiveSecurity
云风的 BLOG
云风的 BLOG
D
Docker
Security Archives - TechRepublic
Security Archives - TechRepublic
Help Net Security
Help Net Security
N
News and Events Feed by Topic
Simon Willison's Weblog
Simon Willison's Weblog
G
Google Developers Blog
A
Arctic Wolf
T
The Blog of Author Tim Ferriss
博客园 - 叶小钗
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Google DeepMind News
Google DeepMind News
博客园 - 三生石上(FineUI控件)
aimingoo的专栏
aimingoo的专栏
Hacker News: Ask HN
Hacker News: Ask HN
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - 司徒正美
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
P
Privacy International News Feed
T
Troy Hunt's Blog
T
Tenable Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Recorded Future
Recorded Future
F
Fortinet All Blogs
D
DataBreaches.Net
B
Blog
T
Threat Research - Cisco Blogs
MyScale Blog
MyScale Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
The GitHub Blog
The GitHub Blog
Security Latest
Security Latest
M
MIT News - Artificial intelligence

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
ASOC на коленке: как я навайбкодил замену DefectDojo для своих задач с обогащением из БДУ ФСТЭК
nefrit0n · 2026-05-10 · via Все публикации подряд на Хабре

Когда я начал разбираться, чем в мире опенсорса можно закрыть задачу 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.03

score := (raw / maxRaw) * 10.0
return 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);

-- Частичный индекс для находок с CVE
CREATE 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.git
cd red-lycoris

cp env.example .env
# Заполнить POSTGRES_PASSWORD, BOOTSTRAP_ADMIN_EMAIL, BOOTSTRAP_ADMIN_PASSWORD

docker compose build --no-cache
docker 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.