Реестр уязвимостей: поля, статусы, сроки и закрытие задач

Отчёты сканера лежат в одной папке, заявки в трекере, исключения в чьей-то голове, а на вопрос «сколько у нас открытых критичных уязвимостей и кто за них отвечает» никто не отвечает без часа сбора данных. Реестр уязвимостей решает именно это: одна запись на пару «узел — уязвимость», у которой есть владелец, срок, статус и подтверждение закрытия. Ниже поля, статусы, рабочий вариант на SQLite со скриптом импорта и правила, которые делают реестр процессом, а не таблицей для отчёта.
Откуда в реестр попадают находки: сканирование Greenbone, мониторинг источников, SAST и SCA, пентест. Как расставлять приоритеты — в основной статье.
Поля карточки
Минимальный набор для третьего уровня зрелости: тип уязвимости, уровень критичности, статус устранения, срок и ответственный. На практике нужно чуть больше, чтобы с реестром можно было работать.
| Поле | Пример | Зачем |
|---|---|---|
| Узел (актив) | srv-app-02 | Связь с реестром активов: владелец и критичность берутся оттуда |
| Идентификатор | CVE-2024-6387 или название теста сканера | Ключ записи; вместе с узлом даёт уникальность |
| Тип | ОС, приложение, конфигурация, зависимость, периметр | Определяет, кто исправляет и каким сроком |
| Критичность по CVSS | 8.1 | Входной сигнал для приоритета |
| Приоритет | P1–P4 | Итог приоритизации с учётом KEV, EPSS и доступности |
| KEV и EPSS | да; 0,62 | Подтверждают срочность в карточке |
| Источник | скан от 03.11, задача «Внутренняя сеть» | Откуда узнали и где проверять повторно |
| Дата обнаружения и последнего появления | 03.11 и 03.12 | Возраст находки, расчёт просрочки |
| Срок устранения | 17.11 | Дата обнаружения плюс срок по приоритету |
| Ответственный и заявка | ИТ-отдел, ссылка на заявку | Кому писать и где смотреть ход работ |
| Статус | в работе | См. следующий раздел |
| Подтверждение закрытия | номер отчёта повторного скана | Основание для статуса «закрыто» |
| Исключение | ссылка на форму принятия риска | Если срок не будет выдержан |
Жизненный цикл записи
| Статус | Что означает | Кто переводит |
|---|---|---|
| Новая | Пришла из скана, владелец ещё не назначен | Автоматически при импорте |
| Разобрана | Приоритет, срок и владелец назначены, ложные срабатывания отсеяны | Специалист по ИБ |
| В работе | Создана заявка, идёт исправление или подготовка | Владелец |
| Исправлена | Владелец сообщил, что сделано; подтверждения ещё нет | Владелец |
| Закрыта | Повторный скан не нашёл уязвимость либо принято решение о риске | Специалист по ИБ, не владелец |
| Исключение | Риск принят руководством до даты пересмотра | Руководитель, по форме |
| Ложное срабатывание | Уязвимости нет, указано обоснование | Специалист по ИБ |
Важное правило: «закрыта» ставит тот, кто проверяет, а не тот, кто исправляет. Если владелец сам закрывает собственные заявки, реестр показывает то, что он хотел бы видеть. Статус «Исправлена» как раз нужен, чтобы между «сказал, что сделал» и «убедились» была граница.
Сроки по приоритету
| Приоритет | Срок устранения | Эскалация при просрочке |
|---|---|---|
| P1 | 3 дня | День 1: владелец и его руководитель; день 3: директор, решение о снятии сервиса или временной мере |
| P2 | 14 дней | День 14: руководитель владельца; день 21: директор |
| P3 | 30 дней | Через 7 дней после срока: руководитель владельца |
| P4 | В плановом обновлении (до 90 дней) | Раз в квартал сводка просроченного |
Сроки здесь взяты из матрицы основной статьи; подставьте свои и закрепите в регламенте. Если срок не записан, просрочки не существует, и показывать на отчёте нечего.
Реестр на SQLite: схема
Пока у вас сотни записей, а не десятки тысяч, система учёта не нужна: достаточно файла базы, который можно открывать и таблицей, и запросами. Схема:
CREATE TABLE IF NOT EXISTS vuln (
id INTEGER PRIMARY KEY,
asset TEXT NOT NULL,
vuln_key TEXT NOT NULL,
title TEXT,
type TEXT,
cvss REAL,
priority TEXT,
kev INTEGER DEFAULT 0,
epss REAL,
source TEXT,
found_date TEXT,
last_seen TEXT,
due_date TEXT,
owner TEXT,
ticket TEXT,
status TEXT DEFAULT 'new',
evidence TEXT,
exception_ref TEXT,
closed_date TEXT,
UNIQUE(asset, vuln_key)
);
Ожидаемо: после sqlite3 vuln.db < schema.sql появляется файл базы. Если нужен просмотр глазами, откройте его в любом клиенте SQLite или экспортируйте в CSV командой .mode csv и .output.
Импорт отчёта сканера
Скрипт читает экспорт Greenbone в CSV, добавляет новые находки, обновляет дату последнего появления у существующих и закрывает записи, которых в свежем скане уже нет. Закрывает он их только для узлов, которые были в охвате скана (список в файле), и записывает имя отчёта как подтверждение. Названия колонок экспорта сверьте с вашим файлом: в разных версиях они могут отличаться.
#!/usr/bin/env python3
"""Импорт отчёта Greenbone (CSV) в реестр уязвимостей SQLite.
Использование: import_report.py vuln.db report.csv scope.txt [мин_CVSS]
scope.txt: адреса узлов, которые вошли в охват этого скана, по одному в строке."""
import csv
import datetime as dt
import os
import sqlite3
import sys
SLA = {'P1': 3, 'P2': 14, 'P3': 30, 'P4': 90}
def starter_priority(cvss):
"""Стартовый приоритет по CVSS; P1 выставляется отдельно по KEV и доступности."""
return 'P2' if cvss >= 9 else 'P3' if cvss >= 7 else 'P4'
db, report, scope = sys.argv[1], sys.argv[2], sys.argv[3]
min_cvss = float(sys.argv[4]) if len(sys.argv) > 4 else 4.0
today = dt.date.today()
scoped = {l.strip() for l in open(scope, encoding='utf-8') if l.strip()}
con = sqlite3.connect(db)
con.execute('PRAGMA foreign_keys=ON')
seen = set()
added = updated = 0
with open(report, newline='', encoding='utf-8') as f:
for row in csv.DictReader(f):
try:
cvss = float(row.get('CVSS') or row.get('Severity') or 0)
except ValueError:
continue
if cvss < min_cvss:
continue
asset = row['IP']
key = (row.get('CVEs') or '').split(',')[0].strip() or row['NVT Name']
seen.add((asset, key))
cur = con.execute('SELECT id, status FROM vuln WHERE asset=? AND vuln_key=?', (asset, key)).fetchone()
if cur is None:
prio = starter_priority(cvss)
due = today + dt.timedelta(days=SLA[prio])
con.execute('INSERT INTO vuln(asset, vuln_key, title, type, cvss, priority, source, found_date, last_seen, due_date, status) '
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
(asset, key, row['NVT Name'], 'сканер', cvss, prio, os.path.basename(report), today, today, due, 'new'))
added += 1
else:
# находка вернулась: закрытая запись открывается снова
status = 'reopened' if cur[1] == 'closed' else cur[1]
con.execute('UPDATE vuln SET last_seen=?, status=?, closed_date=NULL WHERE id=?', (today, status, cur[0]))
updated += 1
# закрытие: запись была открыта, узел в охвате, находки в этом скане нет
closed = 0
for vid, asset, key in con.execute("SELECT id, asset, vuln_key FROM vuln WHERE status IN ('fixed','in_progress','triaged','reopened','new')").fetchall():
if asset in scoped and (asset, key) not in seen:
con.execute("UPDATE vuln SET status='closed', closed_date=?, evidence=? WHERE id=?",
(today, os.path.basename(report), vid))
closed += 1
con.commit()
print(f'добавлено {added}, обновлено {updated}, закрыто по скану {closed}')
Ожидаемо: строка вида «добавлено 12, обновлено 40, закрыто по скану 7». Обратите внимание на логику закрытия: запись закрывается только если узел был в охвате скана. Иначе узел, который просто не просканировали, «исчезнет» из проблем. Это тот самый случай «пустой отчёт выглядит как чистый».
Полезные запросы, которые можно сохранить как отчёты:
-- просроченные, по убыванию просрочки
SELECT asset, vuln_key, priority, owner, due_date, julianday('now') - julianday(due_date) AS days_late
FROM vuln WHERE status NOT IN ('closed','exception','false_positive') AND due_date < date('now')
ORDER BY days_late DESC;
-- открытое по приоритетам
SELECT priority, count(*) FROM vuln WHERE status NOT IN ('closed','exception','false_positive') GROUP BY priority;
-- среднее время закрытия, дни
SELECT priority, round(avg(julianday(closed_date) - julianday(found_date)), 1) AS mttr_days
FROM vuln WHERE status = 'closed' GROUP BY priority;
Ожидаемо: первая выдаёт список, с которого начинается еженедельная встреча; вторая показывает, сколько открыто по приоритетам; третья даёт метрику «время закрытия» из основной статьи. Подробнее о метриках: метрики ИБ для руководства.
Заявки владельцам
Реестр хранит состояние, а работает владелец в своём трекере. Поэтому для приоритетов P1 и P2 заявка создаётся автоматически. Пример для GitLab (подойдёт и другой трекер с API):
curl -sS --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/42/issues" \
--data-urlencode "title=[P2] Обновить openssl на srv-app-02" \
--data-urlencode "description=Находка сканера CVE-2024-XXXXX, отчёт 03.11. Срок: 17.11. Подтверждение: повторный скан." \
--data "labels=уязвимость,P2" \
--data "due_date=2026-11-17"
Ожидаемо: ответ JSON с номером заявки в поле iid; этот номер записывается в поле «заявка» реестра. Токен храните в переменной среды или менеджере секретов, не в скрипте.
Закрытие: два допустимых основания
Запись закрывается только в двух случаях. Первый — повторный скан показывает, что находки нет, и номер отчёта лежит в карточке. Второй — руководитель компании подписал принятие риска, и в карточке ссылка на решение.
Принятие риска (форма)
Запись реестра: srv-legacy-01, CVE-2023-XXXXX, приоритет P2
Почему не исправляем: приложение несовместимо с новой версией библиотеки, замена запланирована на 2 квартал
Компенсирующая мера: порт закрыт со всех адресов, кроме сервера приложений; усиленный журнал доступа
Остаточный риск: средний, доступ только из внутренней сети
Срок пересмотра: 30.01
Принял риск: директор по ИТ, подпись, дата
Согласовал: ответственный за ИБ
Без срока пересмотра такая форма превращается в вечное «не трогайте». Раз в квартал просматривайте все исключения: актуальны ли меры и не вышло ли исправление.
Какие артефакты хранить
| Что хранить | Что показывает |
|---|---|
| Реестр с реальными записями и выгрузка статусов | Все найденные уязвимости регистрируются централизованно |
| Регламент ведения реестра с ответственным за актуальность | Реестр не умирает после ухода энтузиаста |
| Три закрытые записи с номерами повторных сканов | Закрытие идёт только по подтверждению |
| Подписанные формы принятия риска | Исключения оформлены решением руководства |
| Отчёт о просрочке на последнюю неделю | Сроки контролируются, а не только назначаются |
Диагностика
| Симптом | Причина и что делать |
|---|---|
| Реестр содержит записи, которых нет в сканере | Закрытие делали вручную без отчёта. Введите правило: закрывает ИБ, подтверждение обязательно |
| Число открытых растёт каждый месяц | Находки приходят быстрее, чем закрываются. Посмотрите по владельцам: возможно, перегружена одна команда |
| Одна уязвимость записана десятками строк на разные узлы | Это нормально; группируйте заявку по пакету, а записи оставьте по узлам |
| Владельцы не отвечают на заявки | Нет эскалации по срокам. Включите правило из таблицы и еженедельный разбор просроченного |
| Запись открывается снова после закрытия | Исправление откатили или обновили образ из старого. Ищите источник: золотой образ |
Где это в модели зрелости
Материал относится к направлению «Управление уязвимостями» (хаб: уязвимости, карта: карта зрелости). Это ядро третьего уровня: централизованный реестр с обязательными полями, сроки по уровням критичности и закрытие только по подтверждению. Когда реестр превышает несколько тысяч записей, переходите на систему вроде DefectDojo или на модуль управления уязвимостями вашего сканера.
Как проверить
- Выберите пять случайных записей: у каждой есть узел, критичность, статус, срок и ответственный.
- Выберите три закрытые: в каждой номер отчёта повторного скана или подписанное принятие риска.
- Запрос на просроченные возвращает список, и по каждой строке есть заявка и эскалация.
- Импорт нового отчёта на тестовой копии базы добавляет новые записи и закрывает исправленные, но не трогает узлы вне охвата.
Источники
- NIST SP 800-40 Rev. 4, управление корпоративными исправлениями: csrc.nist.gov.
- CIS Control 7: cisecurity.org/controls.
- GitLab, API задач (issues): docs.gitlab.com.
- OWASP DefectDojo: github.com/DefectDojo.


















