16.4. Сканирование и SBOM
Цели
После этого материала вы сможете:
- объяснить, почему один и тот же образ вчера был «чистым», а сегодня нет;
- настроить политику блокировки, не превращающую pipeline в постоянно красный;
- разделить сканирование исходников и образа и поставить их на разные этапы;
- сделать исключения из сканирования срочными, а не вечными;
- объяснить, чем SBOM из сборки отличается от SBOM, восстановленного сканером;
- выбрать действие при уязвимости в базовом образе.
Предварительные знания
- 16.1. Проектирование pipeline — блокирующие и информационные шаги;
- 11.6. Supply chain — SBOM, provenance, приоритизация;
- 12.7. Supply chain security — доверие к артефактам.
Ключевые термины
| Термин | Объяснение |
|---|---|
CVE | Идентификатор известной уязвимости |
fixed version | Версия пакета, где уязвимость устранена |
unfixed | Уязвимость без доступного исправления |
SBOM | Перечень компонентов артефакта |
attestation | Заверенное утверждение, привязанное к образу |
.trivyignore | Файл исключений сканера |
Теория
Результат сканирования меняется без изменения образа
понедельник: образ sha256:8a3f... → 0 находок critical
четверг: образ sha256:8a3f... → 3 находки critical
Образ тот же, digest тот же. Изменилась база данных уязвимостей: за три дня опубликованы новые CVE, затрагивающие уже установленные пакеты.
Два следствия:
«Зелёная» сборка не остаётся зелёной. Проверка при сборке говорит о состоянии на момент сборки, и только.
Нужно сканирование по расписанию. Опубликованные образы проверяют регулярно — иначе уязвимость обнаружится, когда кто-то соберёт новую версию, то есть случайно (урок 16.1).
Что блокирует, а что нет
Единственный работающий критерий — наличие действия.
| Находка | Блокирует | Действие |
|---|---|---|
critical, есть исправление | Да | Обновить пакет или базовый образ |
high, есть исправление | Да | То же |
critical, исправления нет | Нет | Действия не существует |
medium и ниже | Нет | Плановое обновление |
| Уязвимость в dev-зависимости, не попавшей в образ | Нет | Её нет в артефакте |
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 ОБРАЗ
Флаг --ignore-unfixed — центральный в этой команде. Без него pipeline становится постоянно красным при первой же уязвимости без патча, и дальше происходит известное: красный перестаёт быть сигналом (урок 15.1).
Это не отказ от находок без исправления. Их фиксируют отдельным отчётом, не блокирующим сборку, и пересматривают по расписанию — исправление может появиться.
Два разных сканирования
| Что сканируется | Когда | Что находит | Скорость |
|---|---|---|---|
| Исходники и файлы зависимостей | В быстрых проверках | CVE в зависимостях приложения | Секунды |
| Собранный образ | После сборки | То же плюс системные пакеты базового образа | Десятки секунд |
Первое стоит поставить рано: оно дёшево и отсеивает часть проблем до сборки (урок 16.1).
Второе незаменимо: большинство находок в типовом образе приходится на системные пакеты, которых в requirements.txt нет вовсе.
Исключения должны истекать
# .trivyignore
CVE-2026-12345
Такая запись живёт вечно. Через полгода никто не помнит, почему она добавлена, и исправление, вышедшее месяц назад, не применяется.
Формат с датой:
# .trivyignore
# Ложное срабатывание: пакет не используется в рантайме.
# Пересмотреть: 2026-10-01, ответственный: команда платформы
CVE-2026-12345 exp:2026-10-01
После даты исключение перестаёт действовать, и находка возвращается — это и есть напоминание.
Если формат сканера дат не поддерживает, дату держат в комментарии и проверяют отдельным шагом.
Правило: исключение без даты пересмотра — не исключение, а забывание.
SBOM: из сборки или из сканера
| Источник | Что содержит | Точность |
|---|---|---|
Сборщик (sbom: true) | Что было установлено во время сборки | Высокая: наблюдение |
| Сканер по готовому образу | Что удалось распознать в файловой системе | Ниже: восстановление |
Сборщик видит команды установки и их результат. Сканер видит только итоговые файлы и восстанавливает список по метаданным пакетных менеджеров.
Разница проявляется на пакетах, установленных нестандартно: скопированный бинарник, распакованный архив, собранное из исходников. Сборщик их зафиксирует по контексту, сканер — может не распознать.
Практика: генерировать SBOM при сборке и хранить как attestation рядом с образом.
- 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 в отчёте пусто ровно в этом случае. Никакое действие с вашей стороны не изменит ситуацию: обновляться не на что.
Возможные ответы: смена пакета, отказ от него, компенсирующая мера (ограничение доступа, сетевая изоляция) — но не блокировка сборки.
Команды и примеры
Политика блокировки
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
Ожидаемый вывод:
═══ политика реагирования ═══
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). К артефакту она отношения не имеет.
Результат меняется со временем
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
Ожидаемый вывод:
═══ дрейф результата ═══
образ: 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 — та, ради которой сделана модель: уязвимость известна три дня, но исправления ещё нет, и она не блокирует. Пятого числа исправление выходит, и та же находка становится блокирующей.
Это и есть аргумент за сканирование по расписанию: момент появления исправления не связан с вашими сборками.
Сканирование исходников и образа
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
Ожидаемый вывод:
═══ два вида сканирования ═══
исходники и 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
...
Сканер не установлен, и результат не имитируется. Распределение находок приведено как типичное значение, а не как измерение этого образа.
Исключения с датой пересмотра
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
Ожидаемый вывод:
═══ проверка исключений ═══
CVE истекает статус обоснование / проблема
────────────────────────────────────────────────────────────────────────────────────────────────────
CVE-2026-1002 2026-10-01 действует Пакет присутствует в образе, но не используется в ра
CVE-2026-1004 2026-09-01 действует Исправление не выпущено. Компенсирующая мера: сервис
CVE-2026-0999 2026-06-01 ТРЕБУЕТ просрочено 60 дн. назад
записей: 3, требуют внимания: 1
Просроченное исключение перестаёт действовать — находка
возвращается в отчёт. Это и есть напоминание пересмотреть.
Третья запись просрочена на 60 дней и найдена проверкой. Без даты она осталась бы вечной и невидимой.
SBOM: сборка против восстановления
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
Ожидаемый вывод:
═══ два источника 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 при сборке, а не восстановление по готовому образу.
Уязвимость в базовом образе
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
Ожидаемый вывод:
═══ действия при уязвимости в базовом образе ═══
действие стоимость надёжность когда
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Пересобрать на свежем базовом образе минуты высокая ОСНОВНОЙ вариант; проверяется одной пересборкой
Обновить пакет в 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 так, чтобы оно не стало постоянно красным.
Требования:
- Реализовать политику блокировки по наличию действия, а не по уровню.
- Показать, сколько находок блокировало бы без
--ignore-unfixedи сколько с ним. - Показать, что результат сканирования одного образа меняется со временем.
- Разделить сканирование исходников и образа; обосновать, почему первое не заменяет второе.
- Написать проверку файла исключений: даты, обоснования, просроченные записи.
- Объяснить разницу двух источников SBOM и показать случаи, где они расходятся.
- Отметить, что именно не выполнялось, если сканер недоступен.
Подсказки
Подсказка 1
Критерий блокировки: находка в образе, уровень выше порога и поле fixed version не пусто.
Подсказка 2
Для пункта 3 достаточно модели: дата публикации CVE и дата выхода исправления отдельно.
Подсказка 3
Проверка исключений должна находить запись без даты, запись без обоснования и просроченную запись.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требования 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: в файле исключений поддерживается не всеми версиями сканера — проверка разбирает его самостоятельно, что делает её независимой, но не заменяет поведения инструмента.
Проверка результата
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 | Экономия | Находка приходит поздно, при подготовке релиза |
Контрольные вопросы
На понимание:
- Почему один и тот же образ вчера был чистым, а сегодня нет?
- Какой критерий блокировки работает и почему не уровень?
- Чем сканирование исходников отличается от сканирования образа?
- Почему исключение без даты пересмотра — не исключение?
- Чем SBOM из сборки отличается от восстановленного сканером?
На применение:
- Как настроить сканирование, чтобы pipeline не был постоянно красным?
- Что делать с уязвимостью в системном пакете базового образа?
- Как отличить «не проверено» от «проверено и чисто»?
На диагностику:
- Сканер не находит уязвимость в скопированном бинарнике. Почему?
- Pipeline зелёный, но образ уязвим. Гипотезы?
Краткое резюме
- Результат сканирования меняется без изменения образа: обновляется база уязвимостей.
- «Зелёная» сборка не остаётся зелёной; нужно сканирование по расписанию.
- Критерий блокировки — наличие действия, а не уровень.
--ignore-unfixedпревращает постоянно красный pipeline в осмысленный.- Находки без исправления фиксируют отчётом, не блокирующим сборку.
- Сканирование исходников дёшево и идёт рано; сканирование образа незаменимо.
- Большинство находок в типовом образе — в системных пакетах базы.
- Исключение без даты пересмотра становится вечным; дата и есть напоминание.
- Сканер видит только пакеты с метаданными: скопированный бинарник ему невидим.
- SBOM из сборки — наблюдение; из сканера — восстановление, и оно неполнее.
- Attestations живут в registry и теряются при
--load. - Основное действие при уязвимости в базе — пересборка на свежем базовом образе.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Trivy | https://trivy.dev/latest/docs/ | Сканирование, --ignore-unfixed |
Trivy: .trivyignore | https://trivy.dev/latest/docs/configuration/filtering/ | Формат исключений и exp: |
| Docker Scout | https://docs.docker.com/scout/ | Альтернативный сканер |
| Docker: SBOM attestations | https://docs.docker.com/build/metadata/attestations/sbom/ | Генерация при сборке |
| Docker: provenance | https://docs.docker.com/build/metadata/attestations/slsa-provenance/ | mode=max |
| Syft | https://github.com/anchore/syft | Форматы SBOM |
| SPDX | https://spdx.dev/ | Формат перечня компонентов |
| CycloneDX | https://cyclonedx.org/ | Альтернативный формат |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → GitLab CI
Главное оглавление