Главная/CI/CD/Урок

16.4. Сканирование и SBOM

Цели

После этого материала вы сможете:

  • объяснить, почему один и тот же образ вчера был «чистым», а сегодня нет;
  • настроить политику блокировки, не превращающую pipeline в постоянно красный;
  • разделить сканирование исходников и образа и поставить их на разные этапы;
  • сделать исключения из сканирования срочными, а не вечными;
  • объяснить, чем SBOM из сборки отличается от SBOM, восстановленного сканером;
  • выбрать действие при уязвимости в базовом образе.

Предварительные знания

Ключевые термины

ТерминОбъяснение
CVEИдентификатор известной уязвимости
fixed versionВерсия пакета, где уязвимость устранена
unfixedУязвимость без доступного исправления
SBOMПеречень компонентов артефакта
attestationЗаверенное утверждение, привязанное к образу
.trivyignoreФайл исключений сканера

Теория

Результат сканирования меняется без изменения образа

text
понедельник:  образ sha256:8a3f...  → 0 находок critical
четверг:      образ sha256:8a3f...  → 3 находки critical

Образ тот же, digest тот же. Изменилась база данных уязвимостей: за три дня опубликованы новые CVE, затрагивающие уже установленные пакеты.

Два следствия:

«Зелёная» сборка не остаётся зелёной. Проверка при сборке говорит о состоянии на момент сборки, и только.

Нужно сканирование по расписанию. Опубликованные образы проверяют регулярно — иначе уязвимость обнаружится, когда кто-то соберёт новую версию, то есть случайно (урок 16.1).

Что блокирует, а что нет

Единственный работающий критерий — наличие действия.

НаходкаБлокируетДействие
critical, есть исправлениеДаОбновить пакет или базовый образ
high, есть исправлениеДаТо же
critical, исправления нетНетДействия не существует
medium и нижеНетПлановое обновление
Уязвимость в dev-зависимости, не попавшей в образНетЕё нет в артефакте
bash
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 ОБРАЗ

Флаг --ignore-unfixed — центральный в этой команде. Без него pipeline становится постоянно красным при первой же уязвимости без патча, и дальше происходит известное: красный перестаёт быть сигналом (урок 15.1).

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

Два разных сканирования

Что сканируетсяКогдаЧто находитСкорость
Исходники и файлы зависимостейВ быстрых проверкахCVE в зависимостях приложенияСекунды
Собранный образПосле сборкиТо же плюс системные пакеты базового образаДесятки секунд

Первое стоит поставить рано: оно дёшево и отсеивает часть проблем до сборки (урок 16.1).

Второе незаменимо: большинство находок в типовом образе приходится на системные пакеты, которых в requirements.txt нет вовсе.

Исключения должны истекать

text
# .trivyignore
CVE-2026-12345

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

Формат с датой:

text
# .trivyignore
# Ложное срабатывание: пакет не используется в рантайме.
# Пересмотреть: 2026-10-01, ответственный: команда платформы
CVE-2026-12345 exp:2026-10-01

После даты исключение перестаёт действовать, и находка возвращается — это и есть напоминание.

Если формат сканера дат не поддерживает, дату держат в комментарии и проверяют отдельным шагом.

Правило: исключение без даты пересмотра — не исключение, а забывание.

SBOM: из сборки или из сканера

ИсточникЧто содержитТочность
Сборщик (sbom: true)Что было установлено во время сборкиВысокая: наблюдение
Сканер по готовому образуЧто удалось распознать в файловой системеНиже: восстановление

Сборщик видит команды установки и их результат. Сканер видит только итоговые файлы и восстанавливает список по метаданным пакетных менеджеров.

Разница проявляется на пакетах, установленных нестандартно: скопированный бинарник, распакованный архив, собранное из исходников. Сборщик их зафиксирует по контексту, сканер — может не распознать.

Практика: генерировать SBOM при сборке и хранить как attestation рядом с образом.

yaml
- uses: docker/build-push-action@v6
  with:
    push: true
    sbom: true
    provenance: mode=max

Ограничение из урока 11.6: attestations живут в registry и теряются при --load. То есть образ, собранный локально для тестов, их не имеет — и это нормально.

Уязвимость в базовом образе

Самый частый случай: находка не в вашем коде, а в системном пакете базового образа.

Вариант действияОценка
Пересобрать на свежем базовом образеОсновной: обычно достаточно
Обновить пакет внутри DockerfileРаботает, но расходится с базой; сложно поддерживать
Сменить базовый образ на меньшийХорошо: меньше пакетов — меньше находок
Ждать обновления базыПриемлемо, если исправления пока нет
ИгнорироватьТолько с датой пересмотра

Первый вариант проверяется одной командой: обновить закреплённый digest базового образа и пересобрать. Если находка исчезла — исправление уже вышло.

Смена базового образа на distroless или -slim сокращает число находок радикально: значительная часть CVE приходится на пакеты, которые приложению не нужны (урок 5.10).


Внутренний механизм

Как сканер определяет версию пакета

Сканер читает метаданные пакетных менеджеров внутри образа:

ИсточникЧто даёт
/var/lib/dpkg/statusПакеты Debian и Ubuntu
/lib/apk/db/installedПакеты Alpine
*.dist-info/METADATAПакеты Python
package-lock.jsonПакеты Node.js

Затем версии сопоставляются с базой уязвимостей.

Отсюда ограничение: бинарник, скопированный в образ без метаданных пакета, сканеру невидим. Уязвимость в нём не будет найдена — не потому, что её нет, а потому, что сканер не знает о его существовании.

Это же объясняет, почему distroless-образы дают мало находок: в них меньше пакетов с метаданными.

Почему исправление может отсутствовать

Уязвимость публикуется, когда о ней стало известно. Исправление выходит, когда сопровождающий его выпустил, — иногда через недели, иногда никогда, если пакет заброшен.

Поле fixed version в отчёте пусто ровно в этом случае. Никакое действие с вашей стороны не изменит ситуацию: обновляться не на что.

Возможные ответы: смена пакета, отказ от него, компенсирующая мера (ограничение доступа, сетевая изоляция) — но не блокировка сборки.


Команды и примеры

Политика блокировки

bash
mkdir -p /tmp/scan && cd /tmp/scan

cat > policy.py <<'PY'
"""Политика реагирования на находки сканера.

Единственный работающий критерий блокировки — наличие действия.
Находка без доступного исправления действия не порождает.
"""
from __future__ import annotations

import json

FINDINGS = [
    {"id": "CVE-2026-1001", "severity": "CRITICAL", "pkg": "libssl3",
     "fixed": "3.0.15-1", "in_image": True},
    {"id": "CVE-2026-1002", "severity": "CRITICAL", "pkg": "libtiff",
     "fixed": None, "in_image": True},
    {"id": "CVE-2026-1003", "severity": "HIGH", "pkg": "requests",
     "fixed": "2.32.5", "in_image": True},
    {"id": "CVE-2026-1004", "severity": "HIGH", "pkg": "zlib",
     "fixed": None, "in_image": True},
    {"id": "CVE-2026-1005", "severity": "MEDIUM", "pkg": "curl",
     "fixed": "8.12.0-1", "in_image": True},
    {"id": "CVE-2026-1006", "severity": "CRITICAL", "pkg": "pytest",
     "fixed": "9.1.2", "in_image": False},
    {"id": "CVE-2026-1007", "severity": "LOW", "pkg": "perl-base",
     "fixed": None, "in_image": True},
]

BLOCKING_SEVERITY = {"CRITICAL", "HIGH"}


def decide(finding: dict) -> tuple[bool, str]:
    if not finding["in_image"]:
        return False, "пакета нет в образе — уязвимость к артефакту не относится"
    if finding["severity"] not in BLOCKING_SEVERITY:
        return False, "ниже порога — плановое обновление"
    if finding["fixed"] is None:
        return False, "исправления нет — действия не существует"
    return True, f"обновить до {finding['fixed']}"


def main() -> None:
    print(f"  {'CVE':<16} {'уровень':<10} {'пакет':<12} {'исправление':<12} "
          f"{'блокирует':<11} основание")
    print("  " + "─" * 108)
    blocking = 0
    for f in FINDINGS:
        block, reason = decide(f)
        if block:
            blocking += 1
        print(f"  {f['id']:<16} {f['severity']:<10} {f['pkg']:<12} "
              f"{str(f['fixed'] or '—'):<12} {'ДА' if block else 'нет':<11} {reason}")

    total = len(FINDINGS)
    crit_high = sum(1 for f in FINDINGS if f["severity"] in BLOCKING_SEVERITY)
    print()
    print(f"  всего находок: {total}")
    print(f"  уровня CRITICAL и HIGH: {crit_high}")
    print(f"  блокируют сборку: {blocking}")
    print()
    print("  Без --ignore-unfixed блокировали бы все "
          f"{crit_high} — включая те, где обновляться не на что.")
    print("  Pipeline стал бы постоянно красным, и его начали бы обходить.")
    print()
    print(json.dumps({"всего": total, "блокируют": blocking,
                      "critical_high": crit_high}, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

echo "═══ политика реагирования ═══"
python3 policy.py

Ожидаемый вывод:

text
═══ политика реагирования ═══
  CVE              уровень    пакет        исправление  блокирует   основание
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
  CVE-2026-1001    CRITICAL   libssl3      3.0.15-1     ДА          обновить до 3.0.15-1
  CVE-2026-1002    CRITICAL   libtiff      —            нет         исправления нет — действия не существует
  CVE-2026-1003    HIGH       requests     2.32.5       ДА          обновить до 2.32.5
  CVE-2026-1004    HIGH       zlib         —            нет         исправления нет — действия не существует
  CVE-2026-1005    MEDIUM     curl         8.12.0-1     нет         ниже порога — плановое обновление
  CVE-2026-1006    CRITICAL   pytest       9.1.2        нет         пакета нет в образе — уязвимость к артефакту не относится
  CVE-2026-1007    LOW        perl-base    —            нет         ниже порога — плановое обновление

  всего находок: 7
  уровня CRITICAL и HIGH: 4
  блокируют сборку: 2

  Без --ignore-unfixed блокировали бы все 4 — включая те, где обновляться не на что.
  Pipeline стал бы постоянно красным, и его начали бы обходить.

Четыре находки высокого уровня, блокируют две. Разница — наличие исправления.

Строка CVE-2026-1006 показывает третий случай: уязвимость в pytest, который есть в тестовом образе и отсутствует в production (урок 15.2). К артефакту она отношения не имеет.

Результат меняется со временем

bash
cd /tmp/scan
cat > drift.py <<'PY'
"""Один образ, разные дни: результат сканирования меняется.

База уязвимостей обновляется; образ — нет.
"""
from __future__ import annotations

from datetime import date, timedelta

IMAGE_DIGEST = "sha256:8a3f2b1c4d5e6f7a"
BUILD_DATE = date(2026, 7, 1)

# Когда какая уязвимость стала известна и когда вышло исправление
CVES = [
    {"id": "CVE-2026-2001", "published": date(2026, 6, 15),
     "fixed_on": date(2026, 6, 20), "severity": "HIGH"},
    {"id": "CVE-2026-2002", "published": date(2026, 7, 10),
     "fixed_on": date(2026, 7, 25), "severity": "CRITICAL"},
    {"id": "CVE-2026-2003", "published": date(2026, 7, 18),
     "fixed_on": None, "severity": "CRITICAL"},
    {"id": "CVE-2026-2004", "published": date(2026, 7, 29),
     "fixed_on": date(2026, 7, 30), "severity": "HIGH"},
]


def scan_on(day: date) -> dict[str, object]:
    known = [c for c in CVES if c["published"] <= day]
    blocking = [c for c in known
                if c["fixed_on"] is not None and c["fixed_on"] <= day]
    return {"известно": len(known), "блокируют": len(blocking),
            "ids": [c["id"] for c in blocking]}


def main() -> None:
    print(f"  образ: {IMAGE_DIGEST} (собран {BUILD_DATE}, не менялся)\n")
    print(f"  {'дата проверки':<16} {'известно CVE':>14} {'блокируют':>11}  какие")
    print("  " + "─" * 74)
    for offset in (0, 10, 20, 25, 30):
        day = BUILD_DATE + timedelta(days=offset)
        r = scan_on(day)
        print(f"  {day.isoformat():<16} {r['известно']:>14} {r['блокируют']:>11}  "
              f"{', '.join(r['ids']) or '—'}")

    print()
    print("  Образ не менялся ни разу. Менялась база уязвимостей.")
    print()
    print("  Два следствия:")
    print("    1. «Зелёная» сборка не остаётся зелёной.")
    print("    2. Опубликованные образы нужно сканировать по расписанию,")
    print("       иначе уязвимость обнаружится случайно — при следующей сборке.")
    print()
    print("  Строка 2026-07-21: CVE-2026-2002 уже известна, но ещё не")
    print("  блокирует — исправление вышло только 25-го. До этой даты")
    print("  находка идёт в отчёт, а не в блокировку.")


if __name__ == "__main__":
    main()
PY

echo "═══ дрейф результата ═══"
python3 drift.py

Ожидаемый вывод:

text
═══ дрейф результата ═══
  образ: sha256:8a3f2b1c4d5e6f7a (собран 2026-07-01, не менялся)

  дата проверки      известно CVE   блокируют  какие
  ──────────────────────────────────────────────────────────────────────────
  2026-07-01                    1           1  CVE-2026-2001
  2026-07-11                    2           1  CVE-2026-2001
  2026-07-21                    3           1  CVE-2026-2001
  2026-07-26                    3           2  CVE-2026-2001, CVE-2026-2002
  2026-07-31                    4           3  CVE-2026-2001, CVE-2026-2002, CVE-2026-2004

  Образ не менялся ни разу. Менялась база уязвимостей.
  ...

Строка 2026-07-21 — та, ради которой сделана модель: уязвимость известна три дня, но исправления ещё нет, и она не блокирует. Пятого числа исправление выходит, и та же находка становится блокирующей.

Это и есть аргумент за сканирование по расписанию: момент появления исправления не связан с вашими сборками.

Сканирование исходников и образа

bash
cd /tmp/scan
mkdir -p src

cat > requirements.txt <<'EOF'
click==8.3.0
httpx==0.28.1
EOF
cat > src/__init__.py <<'PY'
PY
cat > src/app.py <<'PY'
"""Приложение для сканирования."""
from __future__ import annotations


def main() -> int:
    print("работает")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PY

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS production
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
ENTRYPOINT ["python", "-m", "src.app"]
EOF

echo "═══ два вида сканирования ═══"
cat > two-scans.py <<'PY'
"""Сканирование исходников и образа: что находит каждое."""
from __future__ import annotations

SCANS = [
    {
        "цель": "исходники и requirements.txt",
        "этап": "быстрые проверки, до сборки",
        "время": "секунды",
        "находит": ["CVE в зависимостях приложения"],
        "не находит": ["системные пакеты базового образа",
                       "пакеты, установленные вне менеджера"],
    },
    {
        "цель": "собранный образ",
        "этап": "после сборки",
        "время": "десятки секунд",
        "находит": ["CVE в зависимостях приложения",
                    "CVE в системных пакетах базового образа"],
        "не находит": ["пакеты без метаданных: скопированные бинарники"],
    },
]

TYPICAL_SPLIT = {"системные пакеты базового образа": 34,
                 "зависимости приложения": 6}


def main() -> None:
    for s in SCANS:
        print(f"  {s['цель']}")
        print(f"    этап:  {s['этап']}, {s['время']}")
        print(f"    находит:     {'; '.join(s['находит'])}")
        print(f"    НЕ находит:  {'; '.join(s['не находит'])}")
        print()

    total = sum(TYPICAL_SPLIT.values())
    print("  Типичное распределение находок в образе Python-приложения:")
    for source, n in TYPICAL_SPLIT.items():
        print(f"    {source:<38} {n:>3} ({n / total * 100:.0f} %)")
    print()
    print("  Большинство находок — в пакетах, которых в requirements.txt нет.")
    print("  Поэтому сканирование исходников не заменяет сканирование образа.")
    print()
    print("  Порядок в pipeline:")
    print("    быстрые проверки → сканирование исходников (секунды)")
    print("    после сборки     → сканирование образа (десятки секунд)")


if __name__ == "__main__":
    main()
PY
python3 two-scans.py

echo "═══ фактический запуск сканера ═══"
docker build -q --target production -t scan:demo . > /dev/null 2>&1
if command -v trivy > /dev/null 2>&1; then
    trivy image --severity HIGH,CRITICAL --ignore-unfixed --quiet scan:demo 2>&1 \
        | head -20 | sed 's/^/  /'
else
    cat <<'TXT'
  trivy НЕ УСТАНОВЛЕН — сканирование НЕ ВЫПОЛНЯЛОСЬ.

  Команды для справки:
    trivy fs --severity HIGH,CRITICAL --ignore-unfixed .
    trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 scan:demo

  Запуск через container, если инструмент не установлен локально:
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
        aquasec/trivy:latest image --severity HIGH,CRITICAL scan:demo
TXT
fi

Ожидаемый вывод:

text
═══ два вида сканирования ═══
  исходники и requirements.txt
    этап:  быстрые проверки, до сборки, секунды
    находит:     CVE в зависимостях приложения
    НЕ находит:  системные пакеты базового образа; пакеты, установленные вне менеджера

  собранный образ
    этап:  после сборки, десятки секунд
    находит:     CVE в зависимостях приложения; CVE в системных пакетах базового образа
    НЕ находит:  пакеты без метаданных: скопированные бинарники

  Типичное распределение находок в образе Python-приложения:
    системные пакеты базового образа           34 (85 %)
    зависимости приложения                      6 (15 %)

  Большинство находок — в пакетах, которых в requirements.txt нет.
  Поэтому сканирование исходников не заменяет сканирование образа.

  Порядок в pipeline:
    быстрые проверки → сканирование исходников (секунды)
    после сборки     → сканирование образа (десятки секунд)
═══ фактический запуск сканера ═══
  trivy НЕ УСТАНОВЛЕН — сканирование НЕ ВЫПОЛНЯЛОСЬ.

  Команды для справки:
    trivy fs --severity HIGH,CRITICAL --ignore-unfixed .
    trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 scan:demo
  ...

Сканер не установлен, и результат не имитируется. Распределение находок приведено как типичное значение, а не как измерение этого образа.

Исключения с датой пересмотра

bash
cd /tmp/scan
cat > .trivyignore <<'EOF'
# Формат: CVE [exp:ГГГГ-ММ-ДД]
# Исключение без даты пересмотра — не исключение, а забывание.

# Пакет присутствует в образе, но не используется в рантайме:
# он подтянут как зависимость сборки и не импортируется.
# Пересмотреть: 2026-10-01, ответственный: команда платформы
CVE-2026-1002 exp:2026-10-01

# Исправление не выпущено. Компенсирующая мера: сервис не принимает
# файлы от пользователей, путь эксплуатации закрыт.
# Пересмотреть: 2026-09-01, ответственный: команда безопасности
CVE-2026-1004 exp:2026-09-01

# ПРОСРОЧЕНО — попадёт в отчёт проверки
CVE-2026-0999 exp:2026-06-01
EOF

cat > check-ignores.py <<'PY'
"""Проверка файла исключений: даты, обоснования, просроченные записи."""
from __future__ import annotations

import json
import re
import sys
from datetime import date
from pathlib import Path

CVE_LINE = re.compile(r"^\s*(CVE-\d{4}-\d+)\s*(?:exp:(\d{4}-\d{2}-\d{2}))?\s*$")
TODAY = date(2026, 7, 31)


def parse(path: Path) -> list[dict[str, object]]:
    entries: list[dict[str, object]] = []
    comment_lines: list[str] = []
    for raw in path.read_text().splitlines():
        line = raw.rstrip()
        if line.startswith("#"):
            comment_lines.append(line.lstrip("# ").strip())
            continue
        if not line.strip():
            comment_lines.clear()
            continue
        m = CVE_LINE.match(line)
        if not m:
            continue
        cve, expiry = m.group(1), m.group(2)
        entries.append({
            "cve": cve,
            "истекает": expiry,
            "обоснование": " ".join(comment_lines) or None,
        })
        comment_lines.clear()
    return entries


def audit(entries: list[dict[str, object]]) -> list[dict[str, object]]:
    out = []
    for e in entries:
        problems: list[str] = []
        if not e["истекает"]:
            problems.append("нет даты пересмотра — исключение вечное")
        else:
            exp = date.fromisoformat(str(e["истекает"]))
            if exp < TODAY:
                problems.append(f"просрочено {(TODAY - exp).days} дн. назад")
        if not e["обоснование"]:
            problems.append("нет обоснования")
        out.append({**e, "проблемы": problems})
    return out


def main(path: str) -> int:
    entries = audit(parse(Path(path)))
    print(f"  {'CVE':<18} {'истекает':<14} {'статус':<12} обоснование / проблема")
    print("  " + "─" * 100)
    bad = 0
    for e in entries:
        if e["проблемы"]:
            bad += 1
            status = "ТРЕБУЕТ"
            detail = "; ".join(e["проблемы"])
        else:
            status = "действует"
            detail = str(e["обоснование"])[:52]
        print(f"  {e['cve']:<18} {str(e['истекает'] or '—'):<14} {status:<12} {detail}")

    print()
    print(f"  записей: {len(entries)}, требуют внимания: {bad}")
    print()
    print("  Просроченное исключение перестаёт действовать — находка")
    print("  возвращается в отчёт. Это и есть напоминание пересмотреть.")
    print()
    print(json.dumps({"записей": len(entries), "проблемных": bad},
                     ensure_ascii=False))
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else ".trivyignore"))
PY

echo "═══ проверка исключений ═══"
python3 check-ignores.py .trivyignore || true

Ожидаемый вывод:

text
═══ проверка исключений ═══
  CVE                истекает       статус       обоснование / проблема
  ────────────────────────────────────────────────────────────────────────────────────────────────────
  CVE-2026-1002      2026-10-01     действует    Пакет присутствует в образе, но не используется в ра
  CVE-2026-1004      2026-09-01     действует    Исправление не выпущено. Компенсирующая мера: сервис
  CVE-2026-0999      2026-06-01     ТРЕБУЕТ      просрочено 60 дн. назад

  записей: 3, требуют внимания: 1

  Просроченное исключение перестаёт действовать — находка
  возвращается в отчёт. Это и есть напоминание пересмотреть.

Третья запись просрочена на 60 дней и найдена проверкой. Без даты она осталась бы вечной и невидимой.

SBOM: сборка против восстановления

bash
cd /tmp/scan
cat > sbom-sources.py <<'PY'
"""Чем SBOM из сборки отличается от восстановленного сканером."""
from __future__ import annotations

CASES = [
    {"компонент": "click 8.3.0 (pip install)",
     "в_сборке": True, "в_сканере": True,
     "почему": "есть метаданные dist-info"},
    {"компонент": "libssl3 (apt-get install)",
     "в_сборке": True, "в_сканере": True,
     "почему": "есть запись в /var/lib/dpkg/status"},
    {"компонент": "бинарник, скопированный COPY",
     "в_сборке": True, "в_сканере": False,
     "почему": "метаданных пакета нет — сканер о нём не знает"},
    {"компонент": "библиотека, собранная из исходников",
     "в_сборке": True, "в_сканере": False,
     "почему": "то же"},
    {"компонент": "распакованный архив с зависимостями",
     "в_сборке": True, "в_сканере": False,
     "почему": "то же"},
    {"компонент": "пакет, установленный и затем удалённый",
     "в_сборке": True, "в_сканере": False,
     "почему": "в готовом образе его нет; сборщик видел установку"},
]


def main() -> None:
    print(f"  {'компонент':<44} {'из сборки':<11} {'из сканера':<12} почему")
    print("  " + "─" * 108)
    only_build = 0
    for c in CASES:
        if c["в_сборке"] and not c["в_сканере"]:
            only_build += 1
        print(f"  {c['компонент']:<44} {'да' if c['в_сборке'] else 'нет':<11} "
              f"{'да' if c['в_сканере'] else 'НЕТ':<12} {c['почему']}")

    print()
    print(f"  видит только сборщик: {only_build} из {len(CASES)}")
    print()
    print("  Сборщик НАБЛЮДАЕТ установку; сканер ВОССТАНАВЛИВАЕТ список")
    print("  по метаданным пакетных менеджеров в готовом образе.")
    print()
    print("  Отсюда практика: генерировать SBOM при сборке")
    print("    docker/build-push-action: sbom: true, provenance: mode=max")
    print("  и хранить как attestation рядом с образом.")
    print()
    print("  Ограничение: attestations живут в registry и теряются")
    print("  при --load. Локально собранный образ их не имеет.")


if __name__ == "__main__":
    main()
PY

echo "═══ два источника SBOM ═══"
python3 sbom-sources.py

echo "═══ генерация SBOM в pipeline ═══"
cat <<'TXT'
  При сборке:
    - uses: docker/build-push-action@v6
      with:
        push: true
        sbom: true
        provenance: mode=max

  Проверка наличия attestation у опубликованного образа:
    docker buildx imagetools inspect ОБРАЗ --format '{{json .SBOM}}'
    docker buildx imagetools inspect ОБРАЗ --format '{{json .Provenance}}'

  Отдельным файлом-артефактом (когда нужен для аудита):
    syft ОБРАЗ -o spdx-json=sbom.spdx.json
    syft ОБРАЗ -o cyclonedx-json=sbom.cdx.json
TXT

echo "═══ проверка attestation у локального образа ═══"
if docker buildx imagetools inspect scan:demo --format '{{json .SBOM}}' > /dev/null 2>&1; then
    docker buildx imagetools inspect scan:demo --format '{{json .SBOM}}' 2>&1 \
        | head -c 200 | sed 's/^/  /'
    echo
else
    echo "  attestation отсутствует — образ собран локально с --load"
    echo "  (это ожидаемо: attestations живут в registry)"
fi

Ожидаемый вывод:

text
═══ два источника SBOM ═══
  компонент                                    из сборки   из сканера   почему
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
  click 8.3.0 (pip install)                    да          да           есть метаданные dist-info
  libssl3 (apt-get install)                    да          да           есть запись в /var/lib/dpkg/status
  бинарник, скопированный COPY                 да          НЕТ          метаданных пакета нет — сканер о нём не знает
  библиотека, собранная из исходников          да          НЕТ          то же
  распакованный архив с зависимостями          да          НЕТ          то же
  пакет, установленный и затем удалённый       да          НЕТ          в готовом образе его нет; сборщик видел установку

  видит только сборщик: 4 из 6
  ...
═══ проверка attestation у локального образа ═══
  attestation отсутствует — образ собран локально с --load
  (это ожидаемо: attestations живут в registry)

Четыре компонента из шести видит только сборщик. Это и есть довод за генерацию SBOM при сборке, а не восстановление по готовому образу.

Уязвимость в базовом образе

bash
cd /tmp/scan
cat > base-image.py <<'PY'
"""Выбор действия при уязвимости в системном пакете базового образа."""
from __future__ import annotations

OPTIONS = [
    {"действие": "Пересобрать на свежем базовом образе",
     "стоимость": "минуты", "надёжность": "высокая",
     "когда": "ОСНОВНОЙ вариант; проверяется одной пересборкой"},
    {"действие": "Обновить пакет в Dockerfile (apt-get install --only-upgrade)",
     "стоимость": "часы", "надёжность": "средняя",
     "когда": "исправление вышло, но базовый образ его ещё не включил"},
    {"действие": "Сменить базовый образ на меньший (slim, distroless)",
     "стоимость": "дни", "надёжность": "высокая",
     "когда": "находок много и большинство — в ненужных пакетах"},
    {"действие": "Ждать обновления базового образа",
     "стоимость": "нет", "надёжность": "низкая",
     "когда": "исправления пока нет ни у кого"},
    {"действие": "Исключить с датой пересмотра",
     "стоимость": "минуты", "надёжность": "низкая",
     "когда": "путь эксплуатации закрыт компенсирующей мерой"},
]

SIZE_EFFECT = [
    ("python:3.13", 1020, 68),
    ("python:3.13-slim", 124, 34),
    ("gcr.io/distroless/python3", 52, 6),
]


def main() -> None:
    print(f"  {'действие':<58} {'стоимость':<12} {'надёжность':<12} когда")
    print("  " + "─" * 120)
    for o in OPTIONS:
        print(f"  {o['действие']:<58} {o['стоимость']:<12} "
              f"{o['надёжность']:<12} {o['когда']}")

    print()
    print("  Влияние базового образа на число находок (типичные значения):")
    print(f"    {'базовый образ':<32} {'размер, МБ':>12} {'находок':>10}")
    print("    " + "─" * 56)
    for name, size, findings in SIZE_EFFECT:
        print(f"    {name:<32} {size:>12} {findings:>10}")

    biggest, smallest = SIZE_EFFECT[0], SIZE_EFFECT[-1]
    print()
    print(f"  Переход с {biggest[0]} на {smallest[0]}:")
    print(f"    размер   {biggest[1]} → {smallest[1]} МБ "
          f"({(1 - smallest[1] / biggest[1]) * 100:.0f} % меньше)")
    print(f"    находок  {biggest[2]} → {smallest[2]} "
          f"({(1 - smallest[2] / biggest[2]) * 100:.0f} % меньше)")
    print()
    print("  Значительная часть находок — в пакетах, которые приложению")
    print("  не нужны. Их отсутствие устраняет уязвимость надёжнее патча.")


if __name__ == "__main__":
    main()
PY

echo "═══ действия при уязвимости в базовом образе ═══"
python3 base-image.py

docker rmi -f scan:demo > /dev/null 2>&1
cd /tmp && rm -rf /tmp/scan

Ожидаемый вывод:

text
═══ действия при уязвимости в базовом образе ═══
  действие                                                   стоимость    надёжность   когда
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Пересобрать на свежем базовом образе                       минуты       высокая      ОСНОВНОЙ вариант; проверяется одной пересборкой
  Обновить пакет в Dockerfile (apt-get install --only-upgrade)  часы      средняя      исправление вышло, но базовый образ его ещё не включил
  Сменить базовый образ на меньший (slim, distroless)        дни          высокая      находок много и большинство — в ненужных пакетах
  Ждать обновления базового образа                           нет          низкая       исправления пока нет ни у кого
  Исключить с датой пересмотра                               минуты       низкая       путь эксплуатации закрыт компенсирующей мерой

  Влияние базового образа на число находок (типичные значения):
    базовый образ                      размер, МБ    находок
    ────────────────────────────────────────────────────────
    python:3.13                              1020         68
    python:3.13-slim                          124         34
    gcr.io/distroless/python3                  52          6

  Переход с python:3.13 на gcr.io/distroless/python3:
    размер   1020 → 52 МБ (95 % меньше)
    находок  68 → 6 (91 % меньше)

  Значительная часть находок — в пакетах, которые приложению
  не нужны. Их отсутствие устраняет уязвимость надёжнее патча.

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

Направление при этом устойчиво: меньше пакетов — меньше поверхности и меньше находок (урок 5.10).


Практическое упражнение

Задание. Встройте сканирование в pipeline так, чтобы оно не стало постоянно красным.

Требования:

  1. Реализовать политику блокировки по наличию действия, а не по уровню.
  2. Показать, сколько находок блокировало бы без --ignore-unfixed и сколько с ним.
  3. Показать, что результат сканирования одного образа меняется со временем.
  4. Разделить сканирование исходников и образа; обосновать, почему первое не заменяет второе.
  5. Написать проверку файла исключений: даты, обоснования, просроченные записи.
  6. Объяснить разницу двух источников SBOM и показать случаи, где они расходятся.
  7. Отметить, что именно не выполнялось, если сканер недоступен.

Подсказки

Подсказка 1

Критерий блокировки: находка в образе, уровень выше порога и поле fixed version не пусто.

Подсказка 2

Для пункта 3 достаточно модели: дата публикации CVE и дата выхода исправления отдельно.

Подсказка 3

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

Решение

Показать решение
bash
mkdir -p /tmp/scanlab && cd /tmp/scanlab

cat > scanpolicy.py <<'PY'
"""Политика реагирования на находки сканера и её проверка.

Критерий блокировки — наличие действия:
  находка в артефакте И уровень выше порога И исправление доступно.
"""
from __future__ import annotations

import json
import sys
from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class Finding:
    cve: str
    severity: str
    package: str
    fixed_version: str | None
    in_artifact: bool
    published: date
    fix_released: date | None


BLOCKING = {"CRITICAL", "HIGH"}

FINDINGS = [
    Finding("CVE-2026-1001", "CRITICAL", "libssl3", "3.0.15-1", True,
            date(2026, 6, 15), date(2026, 6, 20)),
    Finding("CVE-2026-1002", "CRITICAL", "libtiff", None, True,
            date(2026, 7, 18), None),
    Finding("CVE-2026-1003", "HIGH", "requests", "2.32.5", True,
            date(2026, 7, 10), date(2026, 7, 25)),
    Finding("CVE-2026-1004", "HIGH", "zlib", None, True,
            date(2026, 7, 5), None),
    Finding("CVE-2026-1005", "MEDIUM", "curl", "8.12.0-1", True,
            date(2026, 7, 1), date(2026, 7, 2)),
    Finding("CVE-2026-1006", "CRITICAL", "pytest", "9.1.2", False,
            date(2026, 7, 20), date(2026, 7, 21)),
    Finding("CVE-2026-1007", "LOW", "perl-base", None, True,
            date(2026, 5, 1), None),
    Finding("CVE-2026-1008", "HIGH", "libxml2", "2.13.5-1", True,
            date(2026, 7, 29), date(2026, 7, 30)),
]


def decide(f: Finding, ignore_unfixed: bool = True) -> tuple[bool, str]:
    if not f.in_artifact:
        return False, "пакета нет в артефакте"
    if f.severity not in BLOCKING:
        return False, "ниже порога"
    if f.fixed_version is None:
        if ignore_unfixed:
            return False, "исправления нет — действия не существует"
        return True, "исправления нет, но блокируем (без --ignore-unfixed)"
    return True, f"обновить до {f.fixed_version}"


def report(ignore_unfixed: bool) -> dict[str, object]:
    blocking = [f for f in FINDINGS if decide(f, ignore_unfixed)[0]]
    return {"блокируют": len(blocking), "cve": [f.cve for f in blocking]}


def scan_on(day: date) -> dict[str, int]:
    known = [f for f in FINDINGS if f.published <= day]
    block = [f for f in known
             if f.in_artifact and f.severity in BLOCKING
             and f.fix_released is not None and f.fix_released <= day]
    return {"известно": len(known), "блокируют": len(block)}


def main() -> int:
    print("  ── Политика по каждой находке ──\n")
    print(f"  {'CVE':<16} {'уровень':<10} {'пакет':<12} {'исправление':<12} "
          f"{'блокирует':<11} основание")
    print("  " + "─" * 106)
    for f in FINDINGS:
        block, why = decide(f)
        print(f"  {f.cve:<16} {f.severity:<10} {f.package:<12} "
              f"{str(f.fixed_version or '—'):<12} "
              f"{'ДА' if block else 'нет':<11} {why}")

    with_flag = report(True)
    without_flag = report(False)
    print()
    print(f"  с --ignore-unfixed:  блокируют {with_flag['блокируют']} "
          f"({', '.join(with_flag['cve'])})")
    print(f"  без --ignore-unfixed: блокируют {without_flag['блокируют']} "
          f"({', '.join(without_flag['cve'])})")
    print()
    print("  Разница — находки, по которым действия не существует.")
    print("  Блокировка по ним делает pipeline постоянно красным.")

    print("\n  ── Дрейф результата во времени ──\n")
    print(f"  {'дата':<14} {'известно':>10} {'блокируют':>11}")
    print("  " + "─" * 38)
    for day in (date(2026, 7, 1), date(2026, 7, 11), date(2026, 7, 21),
                date(2026, 7, 26), date(2026, 7, 31)):
        r = scan_on(day)
        print(f"  {day.isoformat():<14} {r['известно']:>10} {r['блокируют']:>11}")
    print()
    print("  Артефакт не менялся; менялась база уязвимостей.")

    print()
    print(json.dumps({
        "с_флагом": with_flag["блокируют"],
        "без_флага": without_flag["блокируют"],
        "всего": len(FINDINGS),
        "дрейф_с_1_по_31": scan_on(date(2026, 7, 31))["блокируют"]
                           - scan_on(date(2026, 7, 1))["блокируют"],
    }, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > check-ignores.py <<'PY'
"""Проверка файла исключений сканера."""
from __future__ import annotations

import json
import re
import sys
from datetime import date
from pathlib import Path

CVE_LINE = re.compile(r"^\s*(CVE-\d{4}-\d+)\s*(?:exp:(\d{4}-\d{2}-\d{2}))?\s*$")
TODAY = date(2026, 7, 31)


def parse(path: Path) -> list[dict[str, object]]:
    entries: list[dict[str, object]] = []
    comments: list[str] = []
    for raw in path.read_text().splitlines():
        line = raw.rstrip()
        if line.startswith("#"):
            comments.append(line.lstrip("# ").strip())
            continue
        if not line.strip():
            comments.clear()
            continue
        m = CVE_LINE.match(line)
        if not m:
            continue
        entries.append({"cve": m.group(1), "истекает": m.group(2),
                        "обоснование": " ".join(comments) or None})
        comments.clear()
    return entries


def audit(entries: list[dict[str, object]]) -> list[dict[str, object]]:
    result = []
    for e in entries:
        problems: list[str] = []
        if not e["истекает"]:
            problems.append("нет даты пересмотра")
        else:
            exp = date.fromisoformat(str(e["истекает"]))
            if exp < TODAY:
                problems.append(f"просрочено на {(TODAY - exp).days} дн.")
        if not e["обоснование"]:
            problems.append("нет обоснования")
        result.append({**e, "проблемы": problems})
    return result


def main(path: str) -> int:
    entries = audit(parse(Path(path)))
    print(f"  {'CVE':<18} {'истекает':<14} {'статус':<12} детали")
    print("  " + "─" * 92)
    bad = 0
    for e in entries:
        if e["проблемы"]:
            bad += 1
            print(f"  {e['cve']:<18} {str(e['истекает'] or '—'):<14} "
                  f"{'ТРЕБУЕТ':<12} {'; '.join(e['проблемы'])}")
        else:
            print(f"  {e['cve']:<18} {e['истекает']:<14} {'действует':<12} "
                  f"{str(e['обоснование'])[:44]}")
    print()
    print(f"  записей: {len(entries)}, требуют внимания: {bad}")
    print(json.dumps({"записей": len(entries), "проблемных": bad},
                     ensure_ascii=False))
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else ".trivyignore"))
PY

cat > .trivyignore <<'EOF'
# Формат: CVE [exp:ГГГГ-ММ-ДД]

# Пакет в образе есть, но в рантайме не используется.
# Пересмотреть: 2026-10-01, ответственный: команда платформы
CVE-2026-1002 exp:2026-10-01

# Исправления нет; путь эксплуатации закрыт: сервис не принимает файлы.
# Пересмотреть: 2026-09-01, ответственный: команда безопасности
CVE-2026-1004 exp:2026-09-01

# ПРОСРОЧЕНО
CVE-2026-0999 exp:2026-06-01

CVE-2026-0888
EOF

cat > ci-scan.sh <<'SH'
#!/usr/bin/env bash
# Шаг сканирования в pipeline.
#
# Блокирует только находки с доступным исправлением.
# Остальные попадают в отчёт, не останавливающий сборку.
set -uo pipefail

TARGET="${1:?укажите образ}"
REPORT_DIR="${2:-scan-reports}"
mkdir -p "$REPORT_DIR"

if ! command -v trivy > /dev/null 2>&1; then
    printf '\n  trivy НЕ УСТАНОВЛЕН — сканирование НЕ ВЫПОЛНЯЛОСЬ\n'
    printf '  шаг помечен невыполненным, а не пройденным\n\n'
    printf '  команды для справки:\n'
    printf '    trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 %s\n' "$TARGET"
    printf '    trivy image --severity HIGH,CRITICAL --format json -o %s/all.json %s\n\n' \
        "$REPORT_DIR" "$TARGET"
    exit 3    # отдельный код: не пройдено и не провалено
fi

printf '\n  ── блокирующее сканирование ──\n'
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
    --exit-code 1 --quiet "$TARGET"
blocking_rc=$?

printf '\n  ── полный отчёт (не блокирует) ──\n'
trivy image --severity HIGH,CRITICAL --format json \
    -o "$REPORT_DIR/all.json" --quiet "$TARGET" || true
if [ -f "$REPORT_DIR/all.json" ]; then
    python3 - "$REPORT_DIR/all.json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
total = unfixed = 0
for result in data.get("Results", []):
    for v in result.get("Vulnerabilities", []) or []:
        total += 1
        if not v.get("FixedVersion"):
            unfixed += 1
print(f"  находок HIGH/CRITICAL: {total}, из них без исправления: {unfixed}")
print(f"  блокируют: {total - unfixed}")
PY
fi

printf '\n  код блокирующего сканирования: %s\n\n' "$blocking_rc"
exit "$blocking_rc"
SH
chmod +x ci-scan.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требования 1-3: политика и дрейф ═══\n'
python3 scanpolicy.py > policy.log 2>&1
sed -n '1,/Артефакт не менялся/p' policy.log | sed 's/^/  /'
summary="$(tail -1 policy.log)"
with_flag="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['с_флагом'])")"
without_flag="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['без_флага'])")"
drift="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['дрейф_с_1_по_31'])")"
printf '\n  блокируют: с флагом %s, без флага %s\n' "$with_flag" "$without_flag"
printf '  прирост блокирующих за месяц без изменения образа: %s\n' "$drift"
[ "$with_flag" -lt "$without_flag" ] && [ "${drift:-0}" -gt 0 ] \
    && ok "флаг сокращает блокировки; результат дрейфует при неизменном образе" \
    || bad "с флагом=$with_flag без=$without_flag дрейф=$drift"

printf '\n═══ Требование 4: два вида сканирования ═══\n'
python3 - <<'PY'
SPLIT = {"системные пакеты базового образа": 34, "зависимости приложения": 6}
total = sum(SPLIT.values())
print(f"    {'что сканируется':<32} {'этап':<26} {'находит':<38}")
print("    " + "─" * 100)
print(f"    {'исходники, requirements.txt':<32} {'быстрые проверки':<26} "
      f"{'CVE зависимостей приложения':<38}")
print(f"    {'собранный образ':<32} {'после сборки':<26} "
      f"{'то же + системные пакеты базы':<38}")
print()
print("    Типичное распределение находок в образе:")
for source, n in SPLIT.items():
    print(f"      {source:<38} {n:>3} ({n / total * 100:.0f} %)")
print()
print(f"    {SPLIT['системные пакеты базового образа'] / total * 100:.0f} % находок —")
print("    в пакетах, которых в requirements.txt нет вовсе.")
print("    Поэтому сканирование исходников НЕ заменяет сканирование образа.")
PY
ok "разделение обосновано распределением находок"

printf '\n═══ Требование 5: проверка исключений ═══\n'
python3 check-ignores.py .trivyignore > ign.log 2>&1
ign_rc=$?
head -8 ign.log | sed 's/^/  /'
ign_json="$(tail -1 ign.log)"
n_bad="$(echo "$ign_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['проблемных'])")"
printf '  проблемных записей: %s, код возврата: %s\n' "$n_bad" "$ign_rc"

printf '  проверка на чистом файле:\n'
cat > clean.trivyignore <<'EOF'
# Обоснование: пакет не используется в рантайме.
# Пересмотреть: 2026-12-01
CVE-2026-1002 exp:2026-12-01
EOF
python3 check-ignores.py clean.trivyignore > clean.log 2>&1
clean_rc=$?
printf '  код возврата на чистом файле: %s\n' "$clean_rc"
[ "${n_bad:-0}" -ge 2 ] && [ "$ign_rc" -eq 1 ] && [ "$clean_rc" -eq 0 ] \
    && ok "найдены просроченная запись и запись без даты; на чистом файле — чисто" \
    || bad "проблемных=$n_bad коды=$ign_rc/$clean_rc"

printf '\n═══ Требование 6: два источника SBOM ═══\n'
python3 - <<'PY'
CASES = [
    ("click 8.3.0 (pip install)", True, True, "есть dist-info"),
    ("libssl3 (apt-get)", True, True, "есть запись в dpkg/status"),
    ("бинарник, скопированный COPY", True, False, "метаданных пакета нет"),
    ("библиотека из исходников", True, False, "то же"),
    ("пакет установлен и удалён", True, False, "в образе его нет"),
]
print(f"    {'компонент':<38} {'сборщик':<10} {'сканер':<10} почему")
print("    " + "─" * 86)
only_build = 0
for name, build, scan, why in CASES:
    if build and not scan:
        only_build += 1
    print(f"    {name:<38} {'да' if build else 'нет':<10} "
          f"{'да' if scan else 'НЕТ':<10} {why}")
print(f"\n    видит только сборщик: {only_build} из {len(CASES)}")
print("    Сборщик НАБЛЮДАЕТ установку; сканер ВОССТАНАВЛИВАЕТ список.")
print("    Отсюда: генерировать SBOM при сборке (sbom: true),")
print("    хранить как attestation рядом с образом.")
PY
ok "расхождение источников показано на пяти случаях"

printf '\n═══ Требование 7: что не выполнялось ═══\n'
mkdir -p src
cat > src/__init__.py <<'PY'
PY
cat > src/app.py <<'PY'
"""Минимальное приложение."""


def main() -> int:
    print("работает")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PY
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
ENTRYPOINT ["python", "-m", "src.app"]
EOF
docker build -q -t scanlab:demo . > /dev/null 2>&1

./ci-scan.sh scanlab:demo; scan_rc=$?
printf '  код возврата шага сканирования: %s\n' "$scan_rc"
case "$scan_rc" in
    3) ok "сканер недоступен — шаг помечен НЕВЫПОЛНЕННЫМ (код 3), а не пройденным" ;;
    0) ok "сканирование выполнено, блокирующих находок нет" ;;
    1) ok "сканирование выполнено, есть блокирующие находки" ;;
    *) bad "неожиданный код: $scan_rc" ;;
esac

printf '\n  Отдельный код 3 отличает три состояния:\n'
printf '    0 — проверено, чисто\n'
printf '    1 — проверено, есть блокирующие находки\n'
printf '    3 — НЕ ПРОВЕРЕНО: инструмент недоступен\n'
printf '  Без третьего кода недоступный сканер выглядел бы как чистый образ.\n'

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
[ "$scan_rc" -eq 3 ] && echo "  примечание: фактическое сканирование не выполнялось — trivy отсутствует"

docker rmi -f scanlab:demo > /dev/null 2>&1
cd /tmp && rm -rf /tmp/scanlab
exit "$fail"

Ожидаемый вывод:

text
═══ Требования 1-3: политика и дрейф ═══
  ── Политика по каждой находке ──

  CVE              уровень    пакет        исправление  блокирует   основание
  ──────────────────────────────────────────────────────────────────────────────────────────────────────────
  CVE-2026-1001    CRITICAL   libssl3      3.0.15-1     ДА          обновить до 3.0.15-1
  CVE-2026-1002    CRITICAL   libtiff      —            нет         исправления нет — действия не существует
  CVE-2026-1003    HIGH       requests     2.32.5       ДА          обновить до 2.32.5
  CVE-2026-1004    HIGH       zlib         —            нет         исправления нет — действия не существует
  CVE-2026-1005    MEDIUM     curl         8.12.0-1     нет         ниже порога
  CVE-2026-1006    CRITICAL   pytest       9.1.2        нет         пакета нет в артефакте
  CVE-2026-1007    LOW        perl-base    —            нет         ниже порога
  CVE-2026-1008    HIGH       libxml2      2.13.5-1     ДА          обновить до 2.13.5-1

  с --ignore-unfixed:  блокируют 3 (CVE-2026-1001, CVE-2026-1003, CVE-2026-1008)
  без --ignore-unfixed: блокируют 5 (CVE-2026-1001, CVE-2026-1002, CVE-2026-1003, CVE-2026-1004, CVE-2026-1008)

  Разница — находки, по которым действия не существует.
  Блокировка по ним делает pipeline постоянно красным.

  ── Дрейф результата во времени ──

  дата             известно   блокируют
  ──────────────────────────────────────
  2026-07-01              3           1
  2026-07-11              4           1
  2026-07-21              6           1
  2026-07-26              6           2
  2026-07-31              8           3

  Артефакт не менялся; менялась база уязвимостей.

  блокируют: с флагом 3, без флага 5
  прирост блокирующих за месяц без изменения образа: 2
  ✓ флаг сокращает блокировки; результат дрейфует при неизменном образе

═══ Требование 4: два вида сканирования ═══
    что сканируется                  этап                       находит                               
    ────────────────────────────────────────────────────────────────────────────────────────────────────
    исходники, requirements.txt      быстрые проверки           CVE зависимостей приложения           
    собранный образ                  после сборки               то же + системные пакеты базы         

    Типичное распределение находок в образе:
      системные пакеты базового образа           34 (85 %)
      зависимости приложения                      6 (15 %)

    85 % находок —
    в пакетах, которых в requirements.txt нет вовсе.
    Поэтому сканирование исходников НЕ заменяет сканирование образа.
  ✓ разделение обосновано распределением находок

═══ Требование 5: проверка исключений ═══
  CVE                истекает       статус       детали
  ────────────────────────────────────────────────────────────────────────────────────────────
  CVE-2026-1002      2026-10-01     действует    Пакет в образе есть, но в рантайме не использ
  CVE-2026-1004      2026-09-01     действует    Исправления нет; путь эксплуатации закрыт: се
  CVE-2026-0999      2026-06-01     ТРЕБУЕТ      просрочено на 60 дн.
  CVE-2026-0888      —              ТРЕБУЕТ      нет даты пересмотра; нет обоснования
  проблемных записей: 2, код возврата: 1
  проверка на чистом файле:
  код возврата на чистом файле: 0
  ✓ найдены просроченная запись и запись без даты; на чистом файле — чисто

═══ Требование 6: два источника SBOM ═══
    компонент                              сборщик    сканер     почему
    ──────────────────────────────────────────────────────────────────────────────────────
    click 8.3.0 (pip install)              да         да         есть dist-info
    libssl3 (apt-get)                      да         да         есть запись в dpkg/status
    бинарник, скопированный COPY           да         НЕТ        метаданных пакета нет
    библиотека из исходников               да         НЕТ        то же
    пакет установлен и удалён              да         НЕТ        в образе его нет

    видит только сборщик: 3 из 5
    Сборщик НАБЛЮДАЕТ установку; сканер ВОССТАНАВЛИВАЕТ список.
    Отсюда: генерировать SBOM при сборке (sbom: true),
    хранить как attestation рядом с образом.
  ✓ расхождение источников показано на пяти случаях

═══ Требование 7: что не выполнялось ═══

  trivy НЕ УСТАНОВЛЕН — сканирование НЕ ВЫПОЛНЯЛОСЬ
  шаг помечен невыполненным, а не пройденным

  команды для справки:
    trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 scanlab:demo
    trivy image --severity HIGH,CRITICAL --format json -o scan-reports/all.json scanlab:demo

  код возврата шага сканирования: 3
  ✓ сканер недоступен — шаг помечен НЕВЫПОЛНЕННЫМ (код 3), а не пройденным

  Отдельный код 3 отличает три состояния:
    0 — проверено, чисто
    1 — проверено, есть блокирующие находки
    3 — НЕ ПРОВЕРЕНО: инструмент недоступен
  Без третьего кода недоступный сканер выглядел бы как чистый образ.

═══ ИТОГ ═══
  все требования выполнены
  примечание: фактическое сканирование не выполнялось — trivy отсутствует

Все требования выполнены.

Требование 7 даёт решение, применимое к любому шагу pipeline: три кода возврата вместо двух. Ноль — проверено и чисто, единица — проверено и есть находки, тройка — не проверено. Без третьего состояния недоступный сканер неотличим от чистого образа, и pipeline зеленеет по причине отсутствия инструмента.

Три решения, определяющие качество.

Дрейф результата смоделирован двумя датами на каждую находку: публикации и выхода исправления. Одной даты было бы недостаточно — именно разрыв между ними порождает состояние «известна, но не блокирует», которое и объясняет, зачем нужно сканирование по расписанию. Строка 2026-07-21 в таблице показывает это состояние прямо.

Проверка исключений запускается на двух файлах. Файл с намеренными нарушениями даёт код 1 и находит просроченную запись и запись без даты; чистый файл даёт 0. Без второго запуска результат «две проблемы» не доказывал бы, что проверка умеет молчать.

Политика блокировки считает два варианта одновременно: с флагом и без. Числа 3 и 5 в соседних строках делают эффект --ignore-unfixed измеримым. Описание «флаг убирает находки без исправления» верно, но не показывает масштаб — а именно масштаб определяет, станет ли pipeline постоянно красным.

Чего решение не делает. Фактическое сканирование не выполнялось: trivy в этом окружении отсутствует, и шаг завершается кодом 3. Все находки — модель; их распределение по источникам взято как типичное, а не измерено. SBOM не генерировался: attestations требуют публикации в registry, а локальная сборка с --load их не создаёт (урок 11.6). Формат exp: в файле исключений поддерживается не всеми версиями сканера — проверка разбирает его самостоятельно, что делает её независимой, но не заменяет поведения инструмента.

Проверка результата

bash
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 ОБРАЗ; echo "код: $?"
trivy image --severity HIGH,CRITICAL --format json ОБРАЗ | python3 -c "
import json,sys
d=json.load(sys.stdin)
v=[x for r in d.get('Results',[]) for x in (r.get('Vulnerabilities') or [])]
print('всего:',len(v),'без исправления:',sum(1 for x in v if not x.get('FixedVersion')))"
docker buildx imagetools inspect ОБРАЗ --format '{{json .SBOM}}' | head -c 300

Типичные ошибки

ОшибкаПричинаИсправление
Блокировать по уровню без учёта исправления«Critical — значит стоп»Pipeline постоянно красный; --ignore-unfixed
Считать «зелёную» сборку зелёной навсегдаПроверка же прошлаБаза уязвимостей меняется; сканировать по расписанию
Заменять сканирование образа сканированием исходниковОно быстрееБольшинство находок — в системных пакетах
Исключение без даты пересмотраПрощеСтановится вечным и невидимым
Игнорировать находки, не фиксируя«Всё равно не блокирует»Отдельный отчёт и пересмотр по расписанию
Восстанавливать SBOM сканеромИнструмент под рукойНе увидит компоненты без метаданных
Ожидать attestation у локального образаФлаг же заданОни живут в registry, теряются при --load
Патчить пакет внутри DockerfileБыстрее пересборкиРасходится с базой; сначала пересобрать на свежей
Недоступный сканер даёт код 0Так прощеНеотличимо от чистого образа; нужен третий код
Сканировать только на mainЭкономияНаходка приходит поздно, при подготовке релиза

Контрольные вопросы

На понимание:

  1. Почему один и тот же образ вчера был чистым, а сегодня нет?
  2. Какой критерий блокировки работает и почему не уровень?
  3. Чем сканирование исходников отличается от сканирования образа?
  4. Почему исключение без даты пересмотра — не исключение?
  5. Чем SBOM из сборки отличается от восстановленного сканером?

На применение:

  1. Как настроить сканирование, чтобы pipeline не был постоянно красным?
  2. Что делать с уязвимостью в системном пакете базового образа?
  3. Как отличить «не проверено» от «проверено и чисто»?

На диагностику:

  1. Сканер не находит уязвимость в скопированном бинарнике. Почему?
  2. Pipeline зелёный, но образ уязвим. Гипотезы?

Краткое резюме

  1. Результат сканирования меняется без изменения образа: обновляется база уязвимостей.
  2. «Зелёная» сборка не остаётся зелёной; нужно сканирование по расписанию.
  3. Критерий блокировки — наличие действия, а не уровень.
  4. --ignore-unfixed превращает постоянно красный pipeline в осмысленный.
  5. Находки без исправления фиксируют отчётом, не блокирующим сборку.
  6. Сканирование исходников дёшево и идёт рано; сканирование образа незаменимо.
  7. Большинство находок в типовом образе — в системных пакетах базы.
  8. Исключение без даты пересмотра становится вечным; дата и есть напоминание.
  9. Сканер видит только пакеты с метаданными: скопированный бинарник ему невидим.
  10. SBOM из сборки — наблюдение; из сканера — восстановление, и оно неполнее.
  11. Attestations живут в registry и теряются при --load.
  12. Основное действие при уязвимости в базе — пересборка на свежем базовом образе.

Официальные источники

ИсточникСсылкаЧто подтверждает
Trivyhttps://trivy.dev/latest/docs/Сканирование, --ignore-unfixed
Trivy: .trivyignorehttps://trivy.dev/latest/docs/configuration/filtering/Формат исключений и exp:
Docker Scouthttps://docs.docker.com/scout/Альтернативный сканер
Docker: SBOM attestationshttps://docs.docker.com/build/metadata/attestations/sbom/Генерация при сборке
Docker: provenancehttps://docs.docker.com/build/metadata/attestations/slsa-provenance/mode=max
Syfthttps://github.com/anchore/syftФорматы SBOM
SPDXhttps://spdx.dev/Формат перечня компонентов
CycloneDXhttps://cyclonedx.org/Альтернативный формат

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → GitLab CI
Главное оглавление

Markdown на GitHub ↗