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

0
18
Обложка статьи: Реестр уязвимостей: поля, статусы, сроки и закрытие задач

Отчёты сканера лежат в одной папке, заявки в трекере, исключения в чьей-то голове, а на вопрос «сколько у нас открытых критичных уязвимостей и кто за них отвечает» никто не отвечает без часа сбора данных. Реестр уязвимостей решает именно это: одна запись на пару «узел — уязвимость», у которой есть владелец, срок, статус и подтверждение закрытия. Ниже поля, статусы, рабочий вариант на 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 или на модуль управления уязвимостями вашего сканера.

Как проверить

  1. Выберите пять случайных записей: у каждой есть узел, критичность, статус, срок и ответственный.
  2. Выберите три закрытые: в каждой номер отчёта повторного скана или подписанное принятие риска.
  3. Запрос на просроченные возвращает список, и по каждой строке есть заявка и эскалация.
  4. Импорт нового отчёта на тестовой копии базы добавляет новые записи и закрывает исправленные, но не трогает узлы вне охвата.

Источники

Комментарии закрыты.