Files
HY2XS_flamy/tools/build/lib/security.sh
T
founder 594525dd73 build: закрыть обходы релизного гейта тестов и проверять весь граф npm
Два гейта сборки проверяли не то, что обещали.

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, а утверждение о прогоне обязано следовать за прогоном.
2026-08-30 18:18:13 +05:00

161 lines
9.4 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
}