594525dd73
Два гейта сборки проверяли не то, что обещали.
1. pnpm audit проверял production-подграф вместо всего lock-графа.
Гейт запускался с --prod под обоснованием «devDependencies в артефакт не
попадают». Для frontend build tooling это неверно по существу: vite и
rollup действительно не копируются на production-сервер как node_modules,
но они ИСПОЛНЯЮТСЯ на build-машине, читают наши исходники и порождают тот
самый production-бандл, который уезжает в артефакт.
Это не гипотеза: DOM clobbering в Rollup затрагивал именно генерируемый
бандл, и `pnpm audit --prod` его не показывал — по всему графу тот же
прогон дал 33 предупреждения против нуля. Критерий приёмки №47 в docs/11
формулировал «по всему графу» правильно ещё до того, как это стало правдой
в коде.
На текущем lock-файле полный граф на пороге high чист.
2. SKIP_TESTS позволял собрать production-артефакт без тестов.
Переменная была описана как «аварийное отключение тестов; для
release-сборок недопустимо». Недопустимость держалась исключительно на этой
фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты
запускались. То есть
SKIP_TESTS=true ./tools/build/build.sh
доходила до конца и выдавала обычный tarball с build_profile=production и
dependency_security_gate=true — артефакт, по которому невозможно отличить
проверенную сборку от непроверенной.
Глушила она при этом не только тесты: под тем же флагом пропускались
`tsc --noEmit` для оркестратора и `go vet` для админки, то есть проверка
типов и статический анализ того самого кода, который уезжает в production.
Выбран тот же строгий вариант, что уже принят для проверки зависимостей:
обхода нет. Готовый пакет объявляет tests_gate=true в metadata, и это
утверждение опирается на результат — обе функции прогона выставляют свой
флаг только после успешного завершения, а write_metadata отказывается
писать метаданные, если хотя бы один не подтверждён.
Приёмка закрепляет оба инварианта: --prod не может вернуться в гейт, SKIP_TESTS
не может вернуться ни в один модуль сборки и ни в README/docs, tests_gate=true
обязателен в metadata, а утверждение о прогоне обязано следовать за прогоном.
161 lines
9.4 KiB
Bash
161 lines
9.4 KiB
Bash
#!/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 printf '%s' "$report" | grep -qiE 'ERR_PNPM_AUDIT_ENDPOINT|ENOTFOUND|ECONNREFUSED|network|getaddrinfo'; 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
|
||
}
|