Безопасность приложений Безопасность цепочки поставок: зависимости, SBOM, подпись артефактов
0%

Безопасность цепочки поставок: зависимости, SBOM, подпись артефактов

Безопасность цепочки поставок: зависимости, SBOM, подпись артефактов

Соберите сегодня типовой веб-сервис — и в продакшн уедет несколько сотен тысяч строк кода, которые не писал никто из вашей команды. Часть этого кода вы выбрали сознательно: веб-фреймворк, драйвер базы, клиент HTTP. Остальное — транзитивные зависимости: то, что притащили ваши зависимости, и то, что притащили их зависимости. Каждый такой пакет выполняется с теми же правами, что и ваш код: читает те же переменные окружения, ходит в ту же базу, видит те же секреты. Языковой рантайм не различает «наш модуль» и «чужой модуль» — он их просто загружает.

Отсюда и весь класс угроз. Атака на цепочку поставок — это компрометация не вашей системы, а чего-то, чему ваша система доверяет по построению: пакета, реестра, сборочного конвейера, базового образа, плагина CI. Экономика для нападающей стороны исключительно выгодная: одно проникновение в популярный пакет масштабируется на десятки тысяч продуктов, а жертвы сами устанавливают вредонос командой install и сами доставляют его в прод.

Статья написана с позиции защищающейся стороны. Разбирая механику атаки, мы разбираем её ровно настолько, чтобы понять, какой контроль её ломает: цель — узнать проблему в собственном репозитории и починить. Готовых работающих сценариев против чужих систем здесь нет и быть не должно. Любая практическая проверка описанных контролей проводится только на системах, которыми вы владеете, либо при письменном разрешении владельца с зафиксированным объёмом работ, окном проведения и контактом для эскалации. Публикация «доказательства концепции» в чужой публичный реестр — не исследование, а инцидент.

Опорные классификаторы: OWASP Top 10A06:2021 Vulnerable and Outdated Components и A08:2021 Software and Data Integrity Failures; CWE — 829 (подключение функциональности из недоверенной области), 494 (загрузка кода без проверки целостности), 1104 (использование неподдерживаемых компонентов), 1357 (опора на недостаточно доверенный компонент), 506 (встроенный вредоносный код); NIST — SP 800-218 SSDF и SP 800-161r1 C-SCRM.

Почему это отдельный класс угроз

Все предыдущие статьи трека — от моделирования угроз до управления секретами — говорили о коде, который вы контролируете. Цепочка поставок отличается тремя свойствами.

Доверие транзитивно, а решение о доверии — нет. Вы согласились доверять пятидесяти прямым зависимостям. Вместе с ними вы автоматически доверились семистам транзитивным и, что важнее, нескольким сотням людей — мейнтейнеров, которых вы никогда не проверяли и чьи учётные записи защищены неизвестно как. Никакого явного акта согласия при этом не было: строка в package.json не выглядит как выдача прав на выполнение произвольного кода на машине разработчика и на сборочном раннере, хотя является именно ей.

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

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

Два инцидента из этого списка стоит держать в голове как опорные. SolarWinds (декабрь 2020, разбор Mandiant): исходники были чистыми, тесты проходили, релиз был подписан настоящим ключом вендора — вредонос жил в сборочной среде и внедрялся в момент компиляции. Никакая проверка исходников и никакая проверка подписи вендора этого не ловят; ловит только доказуемая связь «этот бинарь собран из этого коммита в этой герметичной среде». xz-utils (март 2024, CVE-2024-3094, исходное письмо Андреса Фройнда): человек два года добросовестно помогал проекту, получил права мейнтейнера и спрятал бэкдор не в исходниках, а в тестовых бинарных файлах и скриптах сборочной обвязки, попадавших только в релизный tarball. Это отдельный урок: релизный архив и содержимое git-репозитория — разные артефакты, и сравнивать надо именно то, что вы собираете.

Модель угроз: где именно вставляется вредонос

Полезно перестать думать «зависимости небезопасны» и начать думать точками внедрения. Канонический разбор — SLSA v1.0 Threat Model, который расставляет угрозы вокруг стадий конвейера.

Конвейер цепочки поставок с точками внедрения и мерами, закрывающими каждую точку

Точка Что происходит Чем закрывается
A. Учётка мейнтейнера фишинг, угон, «усталый мейнтейнер отдал права» MFA с аппаратным ключом у всех с правом публикации, подписанные коммиты, CODEOWNERS
B. Обход процесса прямой push в защищённую ветку, перенос релизного тега на другой коммит защита веток, запрет force-push, неизменяемые релизные теги, релиз только из тега
C. Сборочная среда скомпрометированный раннер, отравленный кэш, вредоносный шаг CI эфемерные раннеры, герметичная сборка, минимальные права токена сборки
D. Зависимость опечатка в имени, dependency confusion, вредоносный postinstall lock-файл с хешами, приватное зеркало, карантин версий, запрет lifecycle-скриптов
E. После сборки публикуется не то, что собрал конвейер подпись и провенанс выпускает сама сборка, у человека нет прав на публикацию
F. Реестр и зеркало подмена содержимого под тем же именем и версией журнал прозрачности, immutable-теги, узкие права на push
G. Доставка скачали и запустили без проверки подписи проверка подписи с явной ожидаемой личностью, а не «подпись есть»
H. Рантайм ссылка по тегу, latest, обновление на месте ссылка по digest, admission-контроль в кластере

Ключевое наблюдение: точки A–F лежат вне вашего периметра целиком или частично. Вы не можете проверить каждого мейнтейнера каждой транзитивной зависимости — их тысячи. Значит, стратегия не «проверить всех», а «принимать на вход только то, что доказуемо пришло из ожидаемой сборки ожидаемого исходника». Точки G и H — единственные полностью ваши, и именно на них строится вся практическая защита.

Транзитивные зависимости: измеряем радиус доверия

Прежде чем что-то чинить, полезно измерить масштаб. Три числа, которые стоит знать про свой проект:

  1. Прямые зависимости — сколько строк в манифесте. Обычно десятки.
  2. Транзитивное замыкание — сколько пакетов реально попадёт в сборку. В экосистемах с мелкой гранулярностью пакетов (npm прежде всего) счёт идёт на сотни и тысячи.
  3. Радиус доверия — сколько различных публикующих сторон (аккаунтов, организаций) может выпустить версию, которая автоматически попадёт к вам. Это и есть настоящее число «людей, которым вы дали доступ к своему CI».

Транзитивное замыкание считается обычным обходом графа. Псевдокод:

ВХОД: граф G, где рёбра ведут от пакета к его прямым зависимостям; корни R — прямые зависимости проекта
ВЫХОД: множество достижимых пакетов и минимальная глубина каждого

очередь ← R, глубина[r] ← 1 для всех r из R
пока очередь не пуста:
    u ← извлечь из очереди
    для каждого v из зависимостей(u):
        если v не посещён:
            глубина[v] ← глубина[u] + 1
            добавить v в очередь
вернуть посещённые, глубина

Реализация на Python — и сразу же обратная задача, которая нужна в момент инцидента: «какие наши сервисы затронуты уязвимым пакетом и через какую цепочку он к нам пришёл».

from collections import deque
from typing import Iterable

Graph = dict[str, list[str]]  # "имя@версия" -> список прямых зависимостей


def closure(graph: Graph, roots: Iterable[str]) -> dict[str, int]:
    """Транзитивное замыкание: что реально попадёт в сборку и на какой глубине."""
    depth: dict[str, int] = {r: 1 for r in roots}
    queue = deque(depth)
    while queue:
        node = queue.popleft()
        for dep in graph.get(node, ()):
            if dep not in depth:              # первый заход = минимальная глубина
                depth[dep] = depth[node] + 1
                queue.append(dep)
    return depth


def blast_radius(graph: Graph, roots: Iterable[str], target: str) -> list[list[str]]:
    """Все кратчайшие пути от прямых зависимостей до уязвимого пакета.

    Ответ на вопрос «почему это вообще у нас есть»: без пути невозможно
    решить, чинить обновлением прямой зависимости или переопределением версии.
    """
    parents: dict[str, str | None] = {r: None for r in roots}
    queue = deque(parents)
    while queue:
        node = queue.popleft()
        for dep in graph.get(node, ()):
            if dep not in parents:
                parents[dep] = node
                queue.append(dep)
    if target not in parents:
        return []
    path, cur = [], target
    while cur is not None:
        path.append(cur)
        cur = parents[cur]
    return [list(reversed(path))]


def trust_radius(depth: dict[str, int], publisher: dict[str, str]) -> set[str]:
    """Сколько различных публикующих сторон способны дотянуться до вашей сборки."""
    return {publisher[pkg] for pkg in depth if pkg in publisher}

Сложность. Обход — O(V + E) по времени, где V — число уникальных пар «пакет + версия», E — число рёбер зависимости; по памяти O(V) на словари посещения и родителей. Для реального проекта на npm это V порядка тысяч и E порядка десятков тысяч — миллисекунды. Тяжело становится, только если считать это по всему парку сервисов на каждый коммит; тогда граф хранят в базе и обновляют инкрементально при изменении lock-файла.

Практический смысл blast_radius виден в инциденте. Когда выходит критическая уязвимость в pkg-x, вопрос «уязвимы ли мы» бесполезен без ответа «через что он к нам пришёл»: если путь один и через прямую зависимость — чинится обновлением; если путей семь и все через давно замороженный фреймворк — чинится переопределением версии (overrides в npm, resolutions в Yarn, constraints в pip-tools, dependencyManagement в Maven) и заводится долг на обновление фреймворка.

Как чужой пакет становится вашим кодом

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

Классы атак и защита от каждого

Дальше — разбор по единой схеме: как выглядит уязвимая конфигурация, почему это работает, как чинить, как проверить, что починено.

Опечатки в именах (typosquatting)

Как выглядит уязвимое место. Установка пакетов вручную по имени, продиктованному в чате или скопированному из блога, без сверки с манифестом:

# опасная привычка: имя не сверяется ни с чем
pip install python-dateutils   # настоящий пакет называется python-dateutil
npm i crossenv                 # настоящий пакет называется cross-env

Почему это работает. Имя в публичном реестре — это просто строка, выданная первому попросившему. Регистрация имён, отличающихся на один символ, перестановку слов или дефис, ничего не стоит. Установка пакета в npm и в старых сборочных схемах Python исполняет код (postinstall, setup.py), то есть ошибка в одном символе означает исполнение чужого кода прямо в момент установки. Классификатор — CWE-829.

Как чинить. Убрать человека из цикла подбора имён: любые зависимости добавляются правкой манифеста и кодом-ревью, а не командой в терминале. На уровне организации — принудительный прокси-реестр с allowlist, чтобы неизвестное имя не резолвилось вовсе. На уровне машины — запрет исполнения скриптов установки по умолчанию:

# npm: глобально запретить lifecycle-скрипты
npm config set ignore-scripts true
npm ci --ignore-scripts

# pnpm 10+ запрещает их по умолчанию; разрешение — явным списком в package.json:
#   "pnpm": { "onlyBuiltDependencies": ["esbuild", "sharp"] }

# Python: ставить только колёса, без сборки из исходников
pip install --only-binary=:all: -r requirements.txt

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

Как проверить, что починено. В CI: сборка на чистой машине с ignore-scripts проходит; попытка установить пакет, отсутствующий в allowlist прокси, завершается ошибкой; в логах установки нет строк вида «running postinstall» для пакетов вне списка.

Dependency confusion

Как выглядит уязвимое место. Приватный индекс добавлен «дополнительным», а не единственным:

# УЯЗВИМО: pip опрашивает оба индекса и выбирает версию повыше,
# независимо от того, из какого индекса она пришла
pip install --extra-index-url https://nexus.acme.internal/simple/ acme-billing-client
# УЯЗВИМО: .npmrc, где приватный реестр задан для одного пакета,
# а всё остальное, включая внутренние имена без скоупа, идёт в публичный npm
registry=https://registry.npmjs.org/

Почему это работает. Внутренние имена пакетов — не секрет: они утекают в package.json внутри собранных бандлов, в Docker-образы, в трейсы ошибок, в публичные репозитории. Если внутреннее имя не занято в публичном реестре, кто угодно может опубликовать под ним пакет с очень высокой версией. Резолвер, видящий оба источника, выберет большую версию. Механику публично описал Алекс Бирсан в 2021 году; тогда это сработало против десятков крупных компаний.

Как чинить. Правило одно: имя должно однозначно определять источник.

# npm: скоуп организации жёстко привязан к приватному реестру
@acme:registry=https://nexus.acme.internal/repository/npm-private/
//nexus.acme.internal/repository/npm-private/:_authToken=${NPM_TOKEN}
registry=https://nexus.acme.internal/repository/npm-proxy/
<!-- NuGet: packageSourceMapping — самый строгий из массовых механизмов -->
<configuration>
  <packageSources>
    <add key="acme" value="https://nexus.acme.internal/nuget/v3/index.json" />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="acme">
      <package pattern="Acme.*" />
    </packageSource>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

Для Python корректная схема — один --index-url, указывающий на прокси-репозиторий, который сам решает, откуда брать пакет, плюс исключающие правила на стороне прокси («имена acme-* никогда не проксируются наружу»). Стандартизованный ответ экосистемы — PEP 708, вводящий метаданные отслеживания источника для индексов. Дополнительно: занять внутренние имена в публичных реестрах пустыми пакетами-заглушками — дёшево и закрывает целый вектор.

Как проверить, что починено. Тест в CI: временно добавить в манифест внутреннее имя с заведомо несуществующей версией и убедиться, что резолвер ходит только во внутренний индекс (по логам прокси) и падает, а не уходит наружу. И регулярный отчёт прокси: сколько запросов к внешнему реестру шло за именами из внутреннего пространства имён — в норме ноль.

Захват пакета: угон учётки и передача проекта

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

{ "dependencies": { "some-util": "^3.1.0" } }

Почему это работает. ^3.1.0 означает «любая версия до 4.0.0» — то есть согласие принять код, который ещё не написан, от человека, который может смениться. Так сработал event-stream в 2018 году: мейнтейнер устал и передал права постороннему, тот выпустил версию с новой зависимостью, содержавшей целевой вредонос. Так же выглядит и сценарий xz: доверие зарабатывается легитимной работой в течение месяцев.

Как чинить. Диапазоны в манифесте допустимы, но устанавливать всегда из lock-файла (npm ci, pip install --require-hashes, go mod download с проверкой go.sum, mvn -o с verification metadata). Между «версия вышла» и «версия разрешена у нас» ставится карантин: в Renovate это minimumReleaseAge, у прокси-реестров — политика задержки.

{
  "extends": ["config:recommended"],
  "minimumReleaseAge": "7 days",
  "packageRules": [
    { "matchUpdateTypes": ["patch"], "matchCurrentVersion": "!/^0/", "automerge": true },
    { "matchUpdateTypes": ["major"], "dependencyDashboardApproval": true },
    { "matchDepTypes": ["devDependencies"], "minimumReleaseAge": "3 days" }
  ],
  "vulnerabilityAlerts": { "minimumReleaseAge": null }
}

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

Как проверить. Сборка из чистого клона без сети, кроме внутреннего прокси, даёт бит-в-бит тот же набор версий, что и вчера. Любое расхождение — это либо неучтённый диапазон, либо отсутствие lock-файла.

Мутабельные ссылки: теги, ветки, latest

Как выглядит уязвимое место.

# УЯЗВИМО: три мутабельные ссылки в четырёх строках
FROM node:20
steps:
  - uses: actions/checkout@v4
  - uses: some-org/some-action@main

Почему это работает. Тег образа и тег git — подвижные указатели. Владелец репозитория может перенести v4 на другой коммит, а владелец реестра или тот, кто получил права на push, — переставить node:20 на другой образ. Ваш конвейер при этом не изменится ни на символ, а собирать начнёт другое. Это CWE-494 в чистом виде: код загружается по имени без проверки целостности.

Как чинить. Ссылки только по неизменяемому идентификатору содержимого:

# базовый образ по digest, а не по тегу
FROM node:20.14.0-bookworm-slim@sha256:<полный-digest-образа>

steps:
  # действие по полному 40-символьному SHA коммита; тег остаётся комментарием для читаемости
  - uses: actions/checkout@<полный-40-символьный-sha>  # v4.2.2

Получить SHA для пиннинга можно так, чтобы не копировать из ненадёжного места:

gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq '.object.sha'
docker buildx imagetools inspect node:20.14.0-bookworm-slim --format '{{.Manifest.Digest}}'

Обновление таких пинов автоматизируется тем же Renovate — он умеет обновлять и SHA, и digest, оставляя человеку ревью диффа. В реестре включается запрет перезаписи тегов (immutable tags поддерживают все распространённые реестры), чтобы 1.8.3 навсегда означал один и тот же образ.

Как проверить. Линтер в CI, отклоняющий любую ссылку uses: без 40-символьного SHA и любой FROM без @sha256:. Проверка на всех репозиториях сразу — обычный grep по конвейерам с отчётом в дашборд.

Фиксация: lock-файлы и хеши содержимого

Lock-файл решает две разные задачи, и их путают. Первая — воспроизводимость: одинаковый набор версий сегодня и через год. Вторая — целостность: гарантия, что по этой версии придёт ровно то содержимое, которое видел автор lock-файла. Вторую задачу решает только хеш, и он есть не во всех экосистемах по умолчанию.

Экосистема Файл фиксации Хеш содержимого Как включить строгий режим
npm / pnpm / yarn package-lock.json, pnpm-lock.yaml да, поле integrity npm ci вместо npm install
Python requirements.txt, uv.lock, poetry.lock только если сгенерирован с хешами pip-compile --generate-hashes, затем pip install --require-hashes
Go go.mod + go.sum да, плюс глобальный журнал контрольных сумм go mod verify, GOFLAGS=-mod=readonly
Rust Cargo.lock да cargo build --locked
Maven нет из коробки нет maven-artifact-plugin, checksum-политика, --strict-checksums
Gradle gradle.lockfile да, отдельным механизмом dependency-verification с verification-metadata.xml
.NET packages.lock.json да RestorePackagesWithLockFile, --locked-mode

Практический вид фиксации по хешу в Python:

# requirements.txt, сгенерированный pip-compile --generate-hashes
requests==2.32.3 \
    --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 \
    --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760
# любая неучтённая транзитивная зависимость или несовпадение хеша = падение установки
pip install --require-hashes --no-deps -r requirements.txt

--require-hashes включает важный побочный эффект: все зависимости, включая транзитивные, обязаны быть перечислены явно с хешами. Это ровно то состояние, в котором вы знаете свой граф целиком.

Отдельного упоминания заслуживает подход Go. Помимо локального go.sum, есть глобальный журнал контрольных сумм — append-only-структура на базе дерева Меркла, аналогичная Certificate Transparency (RFC 6962, RFC 9162). Когда go впервые видит модуль, он сверяет его хеш с журналом; чтобы подменить содержимое версии для конкретной жертвы, атакующей стороне пришлось бы подменить публичный, наблюдаемый многими журнал. Устройство описано в design-документе 25530. Приватные модули выводятся из-под этой проверки переменной GOPRIVATE, которая задаёт умолчания для соответствующих GONO*-переменных, — и именно здесь надо не переусердствовать: широкий шаблон вроде *.com отключает проверку для всего.

Обобщение этой идеи — The Update Framework (TUF), спецификация распределения ролей и ключей в системе обновлений, устойчивая к компрометации отдельных ключей и к атакам на свежесть (подсовывание старой уязвимой версии как «актуальной»). PyPI внедряет TUF по PEP 458, а аттестации публикаций описаны в PEP 740.

SCA: находить известные уязвимости и не утонуть в них

Software Composition Analysis — это сопоставление вашего списка компонентов с базами известных уязвимостей. Источники стоит различать:

  • CVE — идентификатор уязвимости, ведёт MITRE/CVE Program. Это имя, а не оценка.
  • NVDобогащение CVE метриками CVSS и диапазонами версий; исторически бывали периоды отставания в обработке, поэтому единственным источником его делать не стоит.
  • OSVоткрытая база с машиночитаемой схемой, заточенная под пакеты: версии указаны в терминах экосистемы, а не человеческим текстом. Агрегирует GHSA, RustSec, PyPA и другие.
  • CVSSоценка серьёзности в вакууме. Базовый вектор не знает ни вашего контекста, ни того, есть ли эксплуатация в реальности.
  • EPSSвероятностная оценка того, что уязвимость будут эксплуатировать в ближайшие 30 дней.
  • CISA KEVкаталог уязвимостей, эксплуатация которых наблюдается в реальности. Самый прикладной сигнал из всех.

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

Правый нижний и правый верхний квадранты — это работа. Левый нижний — это не «игнорировать», а явно задокументировать решение (см. VEX ниже), иначе через месяц та же находка снова съест час триажа.

Инструмент, который отличает достижимость от простого совпадения версий, стоит десяти, которые этого не делают. Эталонный пример — govulncheck, анализирующий фактические вызовы функций:

# Go: сообщает только те уязвимости, чей уязвимый символ реально достижим из вашего кода
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

# кросс-экосистемный скан по lock-файлам и по SBOM
osv-scanner scan source --recursive .
osv-scanner scan --sbom=sbom.cdx.json

# образы: и уязвимости, и SBOM одной командой
trivy image --scanners vuln,secret --exit-code 1 --severity HIGH,CRITICAL reg.acme/billing@sha256:9f3c...

Подробности встраивания сканеров в конвейер и разговор про SAST/DAST — в следующей статье трека и в материале devops-трека про безопасность в пайплайне. Тестовая перспектива — тестирование безопасности.

SBOM: инвентарь, который отвечает на вопрос за минуты

SBOM (Software Bill of Materials) — машиночитаемый список компонентов, из которых состоит поставляемый артефакт, с версиями, идентификаторами и связями. Смысл его не в бюрократии. Смысл в одном вопросе, который задают в день выхода очередного Log4Shell: «где у нас эта библиотека и какой версии?» Без инвентаря ответ занимает недели опросов команд. С инвентарём — один запрос.

Два живых формата:

  • SPDXspdx.dev, версия 2.2.1 стандартизована как ISO/IEC 5962:2021, актуальная линия — SPDX 3.0. Исторически силён в лицензионном комплаенсе.
  • CycloneDXcyclonedx.org, от OWASP, стандартизован как ECMA-424. Исторически силён в безопасности: встроенные VEX, сервисы, зависимости, аттестации.

Минимальный набор полей задан в NTIA Minimum Elements: поставщик, имя компонента, версия, прочие идентификаторы, отношения зависимости, автор записи, временная метка. Ключевой идентификатор на практике — purl (Package URL), строка вида pkg:npm/%40acme/billing@1.8.3, однозначно указывающая экосистему, имя и версию; именно по нему сопоставляются уязвимости.

Фрагмент CycloneDX:

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
  "version": 1,
  "metadata": {
    "timestamp": "2026-07-16T07:00:00Z",
    "component": {
      "type": "container",
      "name": "billing-api",
      "version": "1.8.3",
      "purl": "pkg:oci/billing-api@sha256%3A9f3c...c1a7"
    }
  },
  "components": [
    {
      "type": "library",
      "name": "requests",
      "version": "2.32.3",
      "purl": "pkg:pypi/requests@2.32.3",
      "licenses": [{ "license": { "id": "Apache-2.0" } }],
      "hashes": [{ "alg": "SHA-256", "content": "70761cfe...6dcc6" }]
    }
  ],
  "dependencies": [
    { "ref": "pkg:oci/billing-api@sha256%3A9f3c...c1a7", "dependsOn": ["pkg:pypi/requests@2.32.3"] }
  ]
}

Генерация — в момент сборки, а не постфактум сканом. Разница принципиальная: сборка знает, какие версии реально были разрешены; внешний скан образа это лишь угадывает по метаданным пакетного менеджера.

# из образа или каталога, формат CycloneDX
syft scan reg.acme/billing@sha256:9f3c... -o cyclonedx-json > sbom.cdx.json
# из исходников, с учётом lock-файлов
cdxgen -t python -o sbom.cdx.json .
# сканирование готового SBOM, а не пересборка знания заново
grype sbom:./sbom.cdx.json --fail-on high

Четыре заблуждения про SBOM, которые стоит проговорить.

  1. SBOM — не мера безопасности сам по себе. Это инвентарь. Он ничего не защищает; он сокращает время ответа.
  2. SBOM без хранилища бесполезен. Файл, положенный в артефакты сборки и забытый, не отвечает на вопрос «где у нас эта библиотека» — на него отвечает база, куда SBOM всех сборок складываются и по которой можно искать. Открытый вариант такой базы — OWASP Dependency-Track.
  3. SBOM без привязки к digest ничего не доказывает. Он должен ссылаться на конкретный неизменяемый артефакт и, в идеале, поставляться подписанным как аттестация.
  4. SBOM не равен «списку уязвимостей». Он равен списку компонентов; уязвимости к нему присоединяются в момент запроса и меняются каждый день. Поэтому пересканировать надо не только новые сборки, но и всё, что уже работает в проде.

Инвентарь как модель данных выглядит примерно так:

Смысл связи ARTIFACT → DEPLOYMENT в том, что вопрос инцидента звучит не «есть ли у нас уязвимый компонент», а «что прямо сейчас работает в проде с уязвимым компонентом». Без этой связи инвентарь остаётся архивом.

VEX: почему «уязвимость есть, а проблемы нет» надо записывать

Большая часть находок SCA — ложная тревога в вашем контексте: уязвим код, который вы не вызываете; уязвим CLI-инструмент, лежащий в образе, но не запускающийся; уязвимость требует конфигурации, которой у вас нет. VEX (Vulnerability Exploitability eXchange) — способ записать это решение машиночитаемо, один раз, с обоснованием.

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://acme.example/vex/2026-0031",
  "author": "Acme AppSec <appsec@acme.example>",
  "timestamp": "2026-07-16T09:00:00+03:00",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2026-11111" },
      "products": [{ "@id": "pkg:oci/billing-api@sha256%3A9f3c...c1a7" }],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "Уязвим только XML-парсер библиотеки; сервис принимает исключительно JSON, XML-ветка отключена сборочным флагом."
    }
  ]
}

Допустимые статусы — not_affected, affected, fixed, under_investigation; обоснования для not_affected фиксированы спецификацией (OpenVEX, альтернативы — CSAF VEX и встроенный VEX в CycloneDX). Правило гигиены: у not_affected обязательно есть срок пересмотра. Отключение находки навсегда — это то, как уязвимости доживают до инцидента.

Подпись артефактов: от «подпись есть» к «подписал именно тот»

Хеш отвечает на вопрос «то же ли это содержимое, что я видел раньше». Он не отвечает на вопрос «кто это выпустил» — если атакующая сторона подменила артефакт в реестре, она подменила и опубликованный рядом хеш. Нужна подпись: доказательство, что содержимое заверено владельцем известного ключа или известной личности.

Классический путь — долгоживущие ключи (GPG, ключи вендора). Он работает и провалился по одной причине: управление ключами. Ключ надо где-то хранить, кому-то доверять, отзывать при увольнении, ротировать, и в реальности он оказывается в CI как переменная окружения — то есть ровно там, где живут остальные проблемы из статьи про секреты.

Sigstore переворачивает подход: ключа нет вовсе. Сборка на короткое время генерирует пару ключей, доказывает свою личность OIDC-токеном рабочего процесса, получает от удостоверяющего центра Fulcio сертификат со сроком жизни около десяти минут, подписывает артефакт, публикует запись в журнале прозрачности Rekor и выбрасывает приватный ключ. Красть нечего.

Обратите внимание на шаг «сверить SAN с ожидаемым». Без него вся конструкция бессмысленна: сертификат Fulcio получит любой, у кого есть аккаунт у любого поддерживаемого OIDC-провайдера. Ровно как TLS без проверки имени хоста (см. транспортную безопасность), подпись без проверки личности — криптография, которая отработала, но доверия не установила.

Анатомия проверяемого артефакта: три равенства, которые обязана проверить сторона-потребитель

# Подпись в сборке (keyless — режим по умолчанию в cosign v2)
cosign sign --yes reg.acme/billing@sha256:9f3c...c1a7

# НЕДОСТАТОЧНО: проверяет, что подпись валидна, но не проверяет, чья
cosign verify reg.acme/billing:1.8.3

# ПРАВИЛЬНО: неизменяемая ссылка + ожидаемая личность + ожидаемый издатель
cosign verify reg.acme/billing@sha256:9f3c...c1a7 \
  --certificate-identity-regexp '^https://github\.com/acme/billing/\.github/workflows/release\.yml@refs/tags/v.*$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Про криптографическую механику подписи, выбор алгоритмов и свойства хеш-функций — в статье про прикладную криптографию.

Провенанс и SLSA: «из чего и чем это собрано»

Подпись говорит «я заверяю этот байтовый набор». Она ничего не говорит о происхождении. Именно поэтому подпись не спасла бы от SolarWinds: вредоносный бинарь был подписан подлинным ключом вендора.

Провенанс — заверенное утверждение о процессе сборки: из какого коммита какого репозитория, каким сборщиком, по какому рецепту, с какими разрешёнными зависимостями получился артефакт с таким digest. Формат — in-toto attestation: конверт с подписью, внутри Statement с полями subject (digest артефакта) и predicate (собственно утверждение, для провенанса — SLSA Provenance).

SLSA v1.0 описывает уровни зрелости сборочного трека:

Уровень Что требуется Против чего работает
Build L1 провенанс существует и доступен потребителю ошибки и непрозрачность, простейшие подмены
Build L2 провенанс подписан сборочной платформой, сборка выполняется размещённым сервисом подделка провенанса разработчиком
Build L3 сборочные прогоны изолированы, секреты подписи недоступны шагам сборки компрометация одного прогона, влияющая на другой

Практическое внедрение на распространённой платформе занимает десяток строк:

name: release
on:
  push:
    tags: ["v*"]

permissions:
  contents: read          # минимум по умолчанию

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write      # публикация образа
      id-token: write      # OIDC для keyless-подписи
      attestations: write  # публикация провенанса
    steps:
      - uses: actions/checkout@<полный-40-символьный-sha>   # v4.2.2
      - id: build
        run: |
          # герметичность: без сети, кроме внутреннего прокси, всё из lock-файлов
          docker build --network=none -t "$IMAGE" .
          echo "digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE")" >> "$GITHUB_OUTPUT"
      - uses: actions/attest-build-provenance@<полный-40-символьный-sha>  # v2
        with:
          subject-name: reg.acme/billing
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

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

# провенанс: собрано именно этим репозиторием
gh attestation verify oci://reg.acme/billing@sha256:9f3c...c1a7 --repo acme/billing

# или через cosign, с явной проверкой предиката
cosign verify-attestation --type slsaprovenance1 \
  --certificate-identity-regexp '^https://github\.com/acme/billing/\.github/workflows/release\.yml@.*$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  reg.acme/billing@sha256:9f3c...c1a7

# для релизов, собранных генераторами SLSA
slsa-verifier verify-artifact billing_1.8.3_linux_amd64 \
  --provenance-path billing.intoto.jsonl \
  --source-uri github.com/acme/billing --source-tag v1.8.3

Проверка обязана быть автоматической и стоять на входе в среду выполнения. В Kubernetes это admission-контроль:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-keyless-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences: ["reg.acme/*"]
          mutateDigest: true      # тег переписывается в digest на приёме
          verifyDigest: true
          required: true
          attestors:
            - count: 1
              entries:
                - keyless:
                    subject: "https://github.com/acme/*/.github/workflows/release.yml@refs/tags/*"
                    issuer: "https://token.actions.githubusercontent.com"

Альтернативы — sigstore policy-controller и Gatekeeper с внешним провайдером данных. Контекст по кластерам и выкладке — в devops-треке: Kubernetes и контейнеры и реестры.

Герметичность и воспроизводимость сборки

Провенанс полезен ровно настолько, насколько сборка детерминирована. Герметичная сборка — та, что не ходит в сеть за неучтёнными входами: все зависимости разрешены заранее и зафиксированы, никакие curl | sh в процессе не выполняются. Воспроизводимая сборка — та, что из одного входа даёт бит-в-бит одинаковый выход.

Зачем это защите: воспроизводимость даёт независимую проверку. Если два разных исполнителя из одного коммита получают одинаковый бинарь, то компрометация одного сборочного окружения обнаруживается сравнением. Это единственный известный практический ответ на угрозу класса SolarWinds. Основной ресурс — reproducible-builds.org.

Главные источники недетерминизма и лечение:

# 1. Временные метки: фиксируем время из коммита
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"   # см. reproducible-builds.org/docs/source-date-epoch/

# 2. Порядок файлов в архивах: сортировка и нормализация метаданных
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf out.tar dist/

# 3. Пути сборки, попадающие в отладочную информацию
go build -trimpath -ldflags="-buildid=" ./cmd/billing

# 4. Проверка: две независимые сборки должны совпасть побайтово
diffoscope out-a.tar out-b.tar

Полной воспроизводимости добиться тяжело, и это нормально: даже частичная (совпадают все артефакты, кроме подписи и метки времени) резко сужает пространство незаметной подмены.

Жизненный цикл зависимости в организации

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

Критерии допуска новой зависимости стоит записать явно и проверять на ревью — они дешевле любого сканера:

  • Нужна ли она вообще. Пакет на двенадцать строк, тянущий шесть транзитивных, — плохая сделка.
  • Живой ли проект. Дата последнего релиза, скорость закрытия issues, число мейнтейнеров. Проект с одним мейнтейнером — не приговор, но повод знать об этом.
  • Как публикуются релизы. Есть ли провенанс, подписаны ли артефакты, включена ли 2FA у публикующих.
  • Что тянет за собой. Размер транзитивного замыкания и наличие в нём известных проблемных мест.
  • Лицензия. Инженерный вопрос ровно в той части, где лицензия влияет на возможность поставки; юридическую оценку делают юристы.
  • Есть ли план ухода. Если проект завтра умрёт, что вы сделаете.

Часть этих сигналов автоматизируется через OpenSSF Scorecard — набор эвристик от «есть ли защита ветки» до «пиннятся ли зависимости в CI». Использовать его как ворота с жёстким порогом — плохая идея: низкий балл у крошечной, но идеально работающей библиотеки нормален. Использовать как повод посмотреть внимательнее — хорошая.

scorecard --repo=github.com/acme/some-dependency --format=json | jq '.checks[] | {name, score}'

Конвейер как часть цепочки поставок

Компрометировать вашу сборку часто проще, чем ваш продакшн: раннеры имеют доступ к исходникам, к реестру и к секретам публикации. Минимальный набор мер:

  • Права токена сборки — на чтение по умолчанию, расширение только там, где нужно, и только в конкретной job. Публикация — отдельная job с отдельным набором прав.
  • Ветка не равна доверию. Отдельная осторожность с триггерами, которые выполняют конвейер с правами репозитория для кода из внешнего pull request. Такие сценарии либо не используют вовсе, либо не дают им доступ к секретам.
  • Секреты выдаются коротко и по OIDC, а не лежат долгоживущими токенами. Разбор — в статье про секреты и ключи.
  • Раннеры эфемерны. Самохостящийся раннер, переживающий сборку, — это способ для одной сборки оставить наследство следующей: в кэше, в ~/.docker/config.json, в /tmp.
  • Кэш — недоверенный вход. Отравление кэша сборки — реальный вектор: ключ кэша, который может задать чужая ветка, превращает кэш в канал доставки. Ключи кэша делают неподделываемыми (хеш lock-файла плюс идентификатор защищённой ветки), а содержимое кэша никогда не считают проверенным.
  • Все действия и образы — по неизменяемому идентификатору (см. выше).

Более подробный разбор конвейера — в devops-треке: безопасность в пайплайне и основы CI.

Как проверить, что починено

Проверяемый чек-лист, который имеет смысл выполнить на своём репозитории (и только на своём) и повесить в CI:

#!/usr/bin/env bash
# supply-chain-gate.sh — ворота цепочки поставок; любой провал = красная сборка
set -euo pipefail

IMAGE="$1"   # обязательно ссылка по digest, не по тегу
[[ "$IMAGE" == *"@sha256:"* ]] || { echo "нужна ссылка по digest, а не по тегу"; exit 1; }

# 1. Установка строго из lock-файла: расхождение манифеста и lock = падение
npm ci --ignore-scripts

# 2. Никаких мутабельных ссылок в конвейере
! grep -rnE 'uses: [^@]+@(v[0-9]|main|master)\b' .github/workflows/ \
  || { echo "найдены действия без пиннинга по SHA"; exit 1; }

# 3. Известные уязвимости с учётом достижимости
osv-scanner scan source --recursive . --format table

# 4. SBOM генерируется из собранного артефакта и не пуст
syft scan "$IMAGE" -o cyclonedx-json > sbom.cdx.json
[[ "$(jq '.components | length' sbom.cdx.json)" -gt 0 ]]

# 5. Подпись сделана ожидаемым workflow ожидаемого репозитория
cosign verify "$IMAGE" \
  --certificate-identity-regexp "$EXPECTED_IDENTITY_REGEX" \
  --certificate-oidc-issuer "$EXPECTED_OIDC_ISSUER" >/dev/null

# 6. Провенанс существует, подписан и указывает на этот же digest
gh attestation verify "oci://$IMAGE" --repo "$EXPECTED_REPO"

echo "ворота пройдены"

Отдельно — проверки, которые невозможно автоматизировать целиком, но которые стоит проводить как учение раз в квартал:

  1. Учение «Log4Shell»: назвать случайный компонент и засечь, за сколько минут команда назовёт все продовые артефакты, где он есть, и все кластеры, где они запущены. Больше часа — инвентарь не работает.
  2. Учение «отравленный пакет»: во внутреннем прокси-реестре пометить безобидный внутренний пакет как заблокированный и убедиться, что сборка действительно падает, а не идёт в обход.
  3. Проверка обхода: попробовать выложить в тестовый кластер образ без подписи и убедиться, что admission его отверг. Это единственный способ узнать, что политика не в режиме Audit уже полгода.

Что делать, когда пакет оказался вредоносным

Порядок действий, который стоит записать заранее, — потому что во время инцидента его не пишут:

  1. Зафиксировать факт и версии. Какие именно версии пакета скомпрометированы, когда опубликованы, когда сняты.
  2. Определить экспозицию по инвентарю. Какие сборки содержали эти версии (запрос к базе SBOM), какие из них уехали в прод, когда именно и на каких узлах работали.
  3. Считать скомпрометированным всё, к чему у кода был доступ. Это ключевой пункт: вредонос в зависимости выполнялся с правами процесса сборки или процесса приложения. Значит, под подозрением все секреты, доступные этому окружению, — их ротируют, а не «проверяют, не утекли ли».
  4. Откатиться на заведомо чистую версию по digest, а не на «предыдущий тег».
  5. Заблокировать версии во внутреннем прокси, чтобы никто не подтянул их снова.
  6. Искать следы исполнения: исходящие соединения сборочных раннеров и подов в период экспозиции, обращения к метаданным облака, аномальные обращения к хранилищам секретов.
  7. Разобрать причину по конвейеру: как именно версия прошла — не было карантина, не было lock-файла, был обход прокси. Чинится процесс, а не конкретный пакет.

Пункт 3 — тот, на котором чаще всего экономят, и именно он превращает локальный инцидент в затяжной.

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

  • Считать SBOM целью. Сгенерировали, положили в артефакты сборки, отчитались. Инвентарь без базы и без связи с продом не отвечает ни на один вопрос.
  • cosign verify без указания ожидаемой личности. Проверка проходит всегда и не значит ничего.
  • Ссылки по тегам. Один переставленный тег обнуляет и подпись, и провенанс, и SBOM.
  • Игнорировать транзитивные зависимости в отчётах на том основании, что «это не наш код». Именно они и есть основная поверхность.
  • Автослияние обновлений без карантина. Ускоряет доставку исправлений и ровно так же ускоряет доставку вредоносов.
  • Вечные исключения в сканере. # nosec, .trivyignore без срока и без обоснования — способ гарантированно пропустить настоящую находку.
  • Отключить проверку контрольных сумм ради «оно не собирается». Широкий GOPRIVATE, отключённая верификация Gradle, --trusted-host в pip — быстрые решения, снимающие защиту со всего разом.
  • Не иметь плана на смерть зависимости. Уязвимость в живом проекте — задача на обновление; уязвимость в мёртвом — проект по замене, который надо начинать заранее.
  • Путать лицензионный комплаенс с безопасностью. Оба живут в SBOM, но это разные процессы с разными владельцами.
  • Забыть про сборочные зависимости. Плагины сборки, линтеры, генераторы кода выполняются с полными правами и почти никогда не попадают в отчёты SCA.

Мини-итог

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

  1. Знать состав. Lock-файлы с хешами, SBOM из сборки, база инвентаря со связью «артефакт → прод». Без этого любой ответ на вопрос «затронуты ли мы» — гадание.
  2. Ограничить вход. Один источник истины для имён пакетов, приватный прокси, карантин новых версий, запрет lifecycle-скриптов, критерии допуска новой зависимости на ревью.
  3. Сделать связь доказуемой. Подпись и провенанс, выпущенные самой сборкой, эфемерные ключи вместо долгоживущих, журнал прозрачности, герметичная и по возможности воспроизводимая сборка.
  4. Проверять на входе. Ссылки по digest, проверка подписи с явной ожидаемой личностью, проверка провенанса, admission-контроль — автоматикой, а не глазами.

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

Источники

Что дальше

Безопасная разработка: практики, ревью, SAST/DAST, security-чемпионы — как встроить всё перечисленное в повседневную работу команды: где в процессе стоят автоматические проверки, что ищет ревьюер, чем отличается статический анализ от динамического и почему одна роль в команде решает больше, чем десять инструментов.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов