#!/usr/bin/env bash set -euo pipefail # Проверка графа зависимостей на известные уязвимости. # # Зачем отдельный шаг сборки, а не разовая проверка «когда вспомним». # # Версии toolchain и библиотек фиксируются в versions.env, go.sum и lock-файлах # — то есть намеренно НЕ движутся. Это правильно для воспроизводимости и ровно # поэтому опасно для безопасности: зафиксированный граф не стареет только на # бумаге, а advisory по нему выходят и после фиксации. Единственный момент, # когда расхождение между «что мы собираем» и «что про это известно» можно # поймать дёшево, — сама сборка релиза. # # История, ради которой шаг появился: на Go 1.21.13 и графе поколения 0.x # govulncheck находил 21 ВЫЗЫВАЕМУЮ уязвимость, включая GO-2025-3553 в # github.com/golang-jwt/jwt v3 — с пометкой `Fixed in: N/A` и путём вызова из # разбора токена, то есть достижимую с неаутентифицированного запроса. Ни один # из существовавших шагов сборки этого показать не мог. # # Проверяются РАЗНЫЕ вещи в двух экосистемах: # # govulncheck — анализ достижимости: уязвимость считается только если до неё # есть путь вызова из нашего кода. Покрывает и stdlib, то есть # ровно то, что версия Go привносит в production-бинарь; # pnpm audit — ВЕСЬ lock-граф frontend без анализа достижимости, с порогом # PNPM_AUDIT_LEVEL. # # Про «весь граф» отдельно, потому что здесь стояло `--prod` с обоснованием # «devDependencies в артефакт не попадают». # # Для frontend build tooling это обоснование неверно по существу. Да, vite и # rollup не копируются на production-сервер как node_modules. Но они # ИСПОЛНЯЮТСЯ на build-машине, читают наши исходники и ПОРОЖДАЮТ тот самый # production-бандл, который уезжает в артефакт. Уязвимость в них — это # уязвимость в том, что мы выпускаем. # # Это не гипотеза. GHSA по DOM clobbering в Rollup затрагивал именно # генерируемый бандл, то есть уезжал в production, — и `pnpm audit --prod` его # не показывал. По всему графу тот же прогон дал 33 предупреждения против нуля. # # docs/11 формулировал критерий приёмки правильно («pnpm audit по всему графу») # ещё до того, как это стало правдой в коде. # Аварийного выхода у этого шага НЕТ, и это осознанное решение. # # Раньше существовали два: ALLOW_VULNERABLE_DEPENDENCIES=true записывал в # metadata `dependency_security_gate=accepted-risk`, SKIP_SECURITY_SCAN=true — # `skipped`. Оба были описаны в README и docs/02 как способ выпустить релиз, # зная об уязвимости. # # Способом они не были. Финальная приёмка архива требует буквально # # grep -q '^dependency_security_gate=true$' metadata/package.env # # то есть сборка с любым из этих значений доходила до самого конца — компиляция, # бандл, тесты, метаданные, tar — и падала на последнем шаге. Продукт # документировал операцию, которую сам же запрещал, а обнаруживалось это через # полный цикл сборки. # # Из двух непротиворечивых вариантов выбран строгий: гейт обязателен, значение # в metadata ровно одно. Контракт при этом читается однозначно: # # релизный артефакт HY2XS невозможно собрать с непройденной проверкой # зависимостей. # # Для локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и # `pnpm audit` запускаются напрямую и к созданию tarball отношения не имеют. security_gate_failed() { local scanner="$1" local details="$2" fail "$scanner: найдены уязвимости в зависимостях. Обойти этот шаг нельзя: релизный пакет HY2XS собирается только с пройденной проверкой. Обновите граф зависимостей (apps/go.sum, apps/frontend/pnpm-lock.yaml) или версию toolchain в versions.env. $details" } # Анализ Go-графа, включая stdlib выбранной версии Go. run_go_vulnerability_gate() { local ui_src="${UI_SRC:-apps}" local report status log_step "Security: govulncheck ${GOVULNCHECK_VERSION} (Go ${GO_VERSION})" # GOTOOLCHAIN=local обязателен: без него go может молча скачать другую # версию toolchain, и проверялась бы не та stdlib, которая попадёт в бинарь. set +e report="$(cd "$ui_src" && GOTOOLCHAIN=local "$GO_BIN" run \ "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" ./... 2>&1)" status=$? set -e # 0 — чисто; 3 — найдены вызываемые уязвимости; остальное — отказ самого # инструмента, и его нельзя трактовать как «уязвимостей нет». case "$status" in 0) log_info "govulncheck: вызываемых уязвимостей не найдено" ;; 3) security_gate_failed "govulncheck" "$report" ;; *) fail "govulncheck завершился с кодом $status (это отказ инструмента, а не результат проверки): $report" ;; esac } # Анализ всего lock-графа frontend, включая build tooling. run_frontend_vulnerability_gate() { local ui_src="${UI_SRC:-apps}" local report status log_step "Security: pnpm audit по всему графу (порог ${PNPM_AUDIT_LEVEL})" set +e report="$(cd "$ui_src/frontend" && "$PNPM_BIN" audit --audit-level "$PNPM_AUDIT_LEVEL" 2>&1)" status=$? set -e if [ "$status" -eq 0 ]; then log_info "pnpm audit: уязвимостей уровня ${PNPM_AUDIT_LEVEL} и выше не найдено" return 0 fi # pnpm audit ходит в реестр npm. Недоступность реестра — это отказ проверки, # а не её отрицательный результат, и молча пропускать его нельзя. if grep -qiE 'ERR_PNPM_AUDIT_ENDPOINT|ENOTFOUND|ECONNREFUSED|network|getaddrinfo' <<<"$report"; then fail "pnpm audit не смог обратиться к реестру npm — проверка не выполнена: $report" fi security_gate_failed "pnpm audit" "$report" } # Результат шага уезжает в metadata/package.env — так же, как hysteria_compat_gate. # # Значение у поля теперь ровно одно: `true`. Пропущенного состояния не бывает, # потому что не бывает пакета, собранного с пропущенной проверкой; поле остаётся # в metadata как утверждение о готовом артефакте, а не как переключатель. run_dependency_security_gate() { [ -n "${GOVULNCHECK_VERSION:-}" ] \ || fail "security: GOVULNCHECK_VERSION не задан; load_versions_contract должен выполниться первым" [ -n "${PNPM_AUDIT_LEVEL:-}" ] \ || fail "security: PNPM_AUDIT_LEVEL не задан; load_versions_contract должен выполниться первым" run_go_vulnerability_gate run_frontend_vulnerability_gate # Флаг выставляется ПОСЛЕ обеих проверок, а не до них. Разницы в поведении # сейчас нет — обе ветки отказа завершают сборку, — но «утверждение о # результате», записанное перед получением результата, рано или поздно # переживает свою причину. DEPENDENCY_SECURITY_GATE="true" export DEPENDENCY_SECURITY_GATE }