fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.
verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.
Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.
Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.
Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.
updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.
Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.
Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
достижима с неаутентифицированного запроса; набор алгоритмов подписи
зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
проверявшая меньше, чем middleware.
Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
This commit is contained in:
@@ -19,6 +19,8 @@ load_versions_contract
|
||||
. "$BUILD_DIR/lib/hysteria.sh"
|
||||
# shellcheck source=tools/build/lib/acceptance.sh
|
||||
. "$BUILD_DIR/lib/acceptance.sh"
|
||||
# shellcheck source=tools/build/lib/security.sh
|
||||
. "$BUILD_DIR/lib/security.sh"
|
||||
# shellcheck source=tools/build/lib/package.sh
|
||||
. "$BUILD_DIR/lib/package.sh"
|
||||
|
||||
@@ -58,6 +60,13 @@ main() {
|
||||
log_step "Checking HY2XS admin contracts"
|
||||
run_admin_tests
|
||||
|
||||
# Проверка уязвимостей идёт после bundle_ui по двум причинам: govulncheck
|
||||
# анализирует пакеты, а `go:embed all:dist` не собирается без ассетов; pnpm
|
||||
# audit требует установленных зависимостей frontend, которые кладёт
|
||||
# `pnpm install --frozen-lockfile` внутри bundle_ui.
|
||||
log_step "Checking dependency vulnerabilities"
|
||||
run_dependency_security_gate
|
||||
|
||||
log_step "Writing metadata"
|
||||
write_metadata "$PACKAGE_VERSION" "$BUILD_ID"
|
||||
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# code_without_comments отдаёт содержимое файла без строк-комментариев.
|
||||
#
|
||||
# Приёмка обязана проверять КОД, а не упоминания. Комментарий, объясняющий,
|
||||
# почему чего-то больше нет, обязан называть это по имени — иначе он бесполезен,
|
||||
# — и проверка, ищущая просто подстроку, начинает падать ровно на той
|
||||
# документации, которая описывает выполненную ею же работу.
|
||||
#
|
||||
# Урок в этом файле уже дважды оплачен: скан versions contract ловил сам себя на
|
||||
# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
|
||||
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы
|
||||
# третьего раза не было.
|
||||
code_without_comments() {
|
||||
grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
|
||||
code_mentions_in() {
|
||||
local needle="$1"
|
||||
shift
|
||||
|
||||
local file hits=""
|
||||
while IFS= read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
if code_without_comments "$file" | grep -qF -- "$needle"; then
|
||||
hits="${hits}${hits:+ }${file}"
|
||||
fi
|
||||
done <<EOF
|
||||
$(grep -rlF --exclude='*_test.go' -- "$needle" "$@" 2>/dev/null || true)
|
||||
EOF
|
||||
|
||||
printf '%s' "$hits"
|
||||
}
|
||||
|
||||
run_fix20_acceptance_subset() {
|
||||
local package_dir="$1"
|
||||
[ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir"
|
||||
@@ -371,6 +404,165 @@ run_clean_install_acceptance() {
|
||||
run_secret_channel_acceptance
|
||||
run_atomic_import_acceptance
|
||||
run_legacy_account_acceptance
|
||||
run_scheduler_lifecycle_acceptance
|
||||
run_dead_config_acceptance
|
||||
run_dependency_hygiene_acceptance "$package_dir"
|
||||
}
|
||||
|
||||
# Планировщик принадлежит процессу и не перезапускает HTTP-сервер.
|
||||
run_scheduler_lifecycle_acceptance() {
|
||||
log_step "Acceptance: cron scheduler is owned by the process, not by the HTTP server"
|
||||
|
||||
[ -f apps/service/cron_scheduler.go ] \
|
||||
|| fail "acceptance: модуль планировщика отсутствует"
|
||||
[ ! -f apps/middleware/cron.go ] \
|
||||
|| fail "acceptance: планировщик вернулся в middleware, где он создавался заново на каждый запуск сервера"
|
||||
|
||||
# Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер.
|
||||
# Именно этот путь плодил планировщики — StopServer возвращал управление в
|
||||
# `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку.
|
||||
! code_without_comments apps/controller/config.go | grep -q 'StopServer' \
|
||||
|| fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы"
|
||||
grep -q 'RescheduleResetTraffic' apps/controller/config.go \
|
||||
|| fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic"
|
||||
|
||||
# Точка входа не должна крутить сервер в цикле: цикл существовал только ради
|
||||
# внутрипроцессного перезапуска.
|
||||
! grep -qE '^[[:space:]]*for[[:space:]]*\{' apps/cmd/cmd.go \
|
||||
|| fail "acceptance: цикл перезапуска сервера вернулся в точку входа"
|
||||
|
||||
# Остановка планировщика обязана существовать и идти РАНЬШЕ закрытия базы.
|
||||
grep -q 'func StopCron' apps/service/cron_scheduler.go \
|
||||
|| fail "acceptance: у планировщика нет остановки, джобы переживут закрытие SQLite"
|
||||
grep -q 'defer service.StopCron()' apps/cmd/server.go \
|
||||
|| fail "acceptance: планировщик не останавливается при завершении сервиса"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/cmd/server.go", "utf8");
|
||||
const release = source.indexOf("defer releaseResource()");
|
||||
const stopCron = source.indexOf("defer service.StopCron()");
|
||||
if (release < 0 || stopCron < 0) throw new Error("не найдены defer releaseResource/StopCron");
|
||||
// defer выполняются в обратном порядке регистрации: releaseResource должен
|
||||
// быть зарегистрирован РАНЬШЕ, чтобы отработать ПОЗЖЕ StopCron.
|
||||
if (release > stopCron) throw new Error("SQLite закроется раньше остановки планировщика");
|
||||
' || fail "acceptance: порядок остановки должен глушить планировщик до закрытия базы"
|
||||
|
||||
# Сигналы: SIGTERM от systemd обязан приводить к штатному завершению.
|
||||
grep -q 'signal.NotifyContext' apps/cmd/server.go \
|
||||
|| fail "acceptance: сервис не обрабатывает сигнал завершения от systemd"
|
||||
grep -q 'syscall.SIGTERM' apps/cmd/server.go \
|
||||
|| fail "acceptance: SIGTERM не входит в набор сигналов завершения"
|
||||
|
||||
log_step "Acceptance: cron expression is validated by the runtime parser before it is stored"
|
||||
grep -q 'cron.ParseStandard' apps/service/cron_scheduler.go \
|
||||
|| fail "acceptance: выражение должно проверяться тем же парсером, которым его разбирает планировщик"
|
||||
grep -q 'ValidateConfigValue' apps/controller/config.go \
|
||||
|| fail "acceptance: значения настроек должны проверяться до записи в базу"
|
||||
|
||||
log_step "Acceptance: config batch is validated fully, then applied in one transaction"
|
||||
grep -q 'func WithConfigTx' apps/dao/config.go \
|
||||
|| fail "acceptance: у записи настроек нет транзакционной границы"
|
||||
grep -q 'sqliteDB.Transaction' apps/dao/config.go \
|
||||
|| fail "acceptance: WithConfigTx должен открывать настоящую транзакцию"
|
||||
grep -q 'service.UpdateConfigs(updates)' apps/controller/config.go \
|
||||
|| fail "acceptance: партия настроек должна применяться одним вызовом, а не по элементу"
|
||||
# Проверка обязана завершиться ДО первой записи.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/controller/config.go", "utf8");
|
||||
const validate = source.indexOf("service.ValidateConfigValue(");
|
||||
const apply = source.indexOf("service.UpdateConfigs(updates)");
|
||||
if (validate < 0 || apply < 0) throw new Error("не найдены проверка и применение партии");
|
||||
if (validate > apply) throw new Error("партия применяется раньше, чем проверяется");
|
||||
' || fail "acceptance: партия настроек обязана проверяться целиком до первой записи"
|
||||
}
|
||||
|
||||
# Мёртвые ключи таблицы `config` не должны вернуться.
|
||||
run_dead_config_acceptance() {
|
||||
log_step "Acceptance: dead config keys stay removed"
|
||||
|
||||
# Проверяется КОД: тесты перечисляют удалённые ключи, чтобы доказать их
|
||||
# отсутствие, а комментарии объясняют, почему ключей больше нет. И то и
|
||||
# другое обязано называть их по имени.
|
||||
local dead_key hits
|
||||
for dead_key in HYSTERIA2_ENABLE HYSTERIA2_CONFIG HYSTERIA2_TRAFFIC_TIME HYSTERIA2_CONFIG_REMARK; do
|
||||
hits="$(code_mentions_in "$dead_key" \
|
||||
apps/model apps/router apps/controller apps/service apps/util apps/frontend/src)"
|
||||
[ -z "$hits" ] \
|
||||
|| fail "acceptance: удалённый ключ $dead_key вернулся в: $hits"
|
||||
done
|
||||
|
||||
grep -q '006_drop_dead_config_keys' apps/dao/sqlite.go \
|
||||
|| fail "acceptance: миграция удаления мёртвых ключей пропала"
|
||||
|
||||
# Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite»
|
||||
# была вторым источником истины и читалась ПЕРВОЙ.
|
||||
! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \
|
||||
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
|
||||
! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \
|
||||
|| fail "acceptance: выгрузка серверного конфига снова обращается к таблице config"
|
||||
|
||||
log_step "Acceptance: share URI remark is derived, not stored as dead state"
|
||||
grep -q 'func shareRemark' apps/service/hysteria2_api.go \
|
||||
|| fail "acceptance: имя профиля в share URI должно выводиться детерминированно"
|
||||
}
|
||||
|
||||
# Гигиена зависимостей и bootstrap-учётных данных.
|
||||
run_dependency_hygiene_acceptance() {
|
||||
local package_dir="$1"
|
||||
|
||||
log_step "Acceptance: bootstrap credentials are never invented or logged"
|
||||
|
||||
# Ключевой канал утечки: сгенерированный пароль печатался в
|
||||
# /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки.
|
||||
! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \
|
||||
|| fail "acceptance: bootstrap-пароль снова пишется в журнал"
|
||||
grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \
|
||||
|| fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта"
|
||||
grep -q 'HY2XS_ADMIN_CON_PASS не задан' apps/dao/sqlite.go \
|
||||
|| fail "acceptance: отсутствие HY2XS_ADMIN_CON_PASS должно быть отказом создания bootstrap-пира"
|
||||
|
||||
log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one"
|
||||
grep -q 'func ExportAdminLog' apps/service/journal.go \
|
||||
|| fail "acceptance: журнал админки выгружается без санитайза"
|
||||
! code_without_comments apps/controller/log.go | grep -q 'c.File(' \
|
||||
|| fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза"
|
||||
|
||||
log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm"
|
||||
local jwt_v3_hits
|
||||
jwt_v3_hits="$(code_mentions_in '"github.com/golang-jwt/jwt"' \
|
||||
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util)"
|
||||
[ -z "$jwt_v3_hits" ] \
|
||||
|| fail "acceptance: вернулась ветка golang-jwt/jwt v3 (GO-2025-3553 без исправленной версии) в: $jwt_v3_hits"
|
||||
grep -q 'golang-jwt/jwt/v5' apps/go.mod \
|
||||
|| fail "acceptance: golang-jwt/jwt/v5 отсутствует в графе зависимостей"
|
||||
grep -q 'jwt.WithValidMethods' apps/service/jwt.go \
|
||||
|| fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи"
|
||||
|
||||
log_step "Acceptance: password storage has exactly one format"
|
||||
! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \
|
||||
|| fail "acceptance: вернулась проверка пароля по несолёному SHA-224"
|
||||
grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \
|
||||
|| fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt"
|
||||
|
||||
log_step "Acceptance: the release is built with a dependency vulnerability gate"
|
||||
[ -f tools/build/lib/security.sh ] \
|
||||
|| fail "acceptance: модуль проверки зависимостей отсутствует"
|
||||
grep -q 'run_dependency_security_gate' tools/build/build.sh \
|
||||
|| fail "acceptance: сборка не запускает проверку зависимостей"
|
||||
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|
||||
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
|
||||
|
||||
# По готовому tarball должно быть видно, проверялся он или собран с
|
||||
# пропущенной проверкой — ровно как hysteria_compat_gate.
|
||||
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|
||||
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
|
||||
|
||||
log_step "Acceptance: the Go toolchain is on a supported line"
|
||||
# Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь.
|
||||
# На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib.
|
||||
local go_minor
|
||||
go_minor="$(grep -E '^GO_VERSION=' versions.env | head -n1 | cut -d= -f2 | cut -d. -f2)"
|
||||
[ "$go_minor" -ge 26 ] \
|
||||
|| fail "acceptance: GO_VERSION 1.${go_minor} вне поддерживаемой линии Go; production-бинарь унаследует её stdlib"
|
||||
}
|
||||
|
||||
# PHASE 1 принадлежит оркестратору целиком.
|
||||
@@ -537,11 +729,23 @@ run_atomic_import_acceptance() {
|
||||
grep -q 'sqliteDB.Transaction' apps/dao/peer.go \
|
||||
|| fail "acceptance: WithPeerTx must open a real database transaction"
|
||||
# Применение обязано идти по tx, а не по глобальному соединению.
|
||||
#
|
||||
# Регрессия приёмки. Здесь стояло `source.slice(start)` — то есть «весь файл
|
||||
# от начала applyPeerImportEntry и ДО КОНЦА». В этот кусок попадали все
|
||||
# функции, объявленные ниже, а среди них ExistPeerName (dao.GetPeer) и
|
||||
# UpdatePeerLastConnectionAt (dao.UpdatePeer) — обычные операции вне импорта,
|
||||
# которым глобальное соединение положено. Проверка гарантированно падала на
|
||||
# корректном коде и не была замечена только потому, что сборка до неё не
|
||||
# доходила: раньше неё падал versions contract на собственном скане '/hui'.
|
||||
#
|
||||
# Границей тела функции служит следующее объявление верхнего уровня.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||||
const start = source.indexOf("func applyPeerImportEntry");
|
||||
if (start < 0) throw new Error("applyPeerImportEntry is missing");
|
||||
const body = source.slice(start);
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) {
|
||||
if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ write_metadata() {
|
||||
printf 'bun_compile_target=%s\n' "${BUN_COMPILE_TARGET:-unknown}"
|
||||
printf 'node_version=%s\n' "$($NODE_BIN --version)"
|
||||
printf 'pnpm_version=%s\n' "$($PNPM_BIN --version)"
|
||||
printf 'govulncheck_version=%s\n' "${GOVULNCHECK_VERSION:-unknown}"
|
||||
printf 'dependency_security_gate=%s\n' "${DEPENDENCY_SECURITY_GATE:-unknown}"
|
||||
printf 'hysteria_source=official-upstream\n'
|
||||
printf 'hysteria_target=linux-amd64\n'
|
||||
printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/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 — граф npm без анализа достижимости, поэтому ограничен
|
||||
# production-зависимостями и порогом PNPM_AUDIT_LEVEL:
|
||||
# devDependencies в артефакт не попадают.
|
||||
|
||||
# Аварийный выход существует по той же причине, что и ALLOW_DIRTY_BUILD: новое
|
||||
# advisory может выйти в неудачный момент, и решение «выпускаем, зная о нём»
|
||||
# должно приниматься человеком ЯВНО, а не пропуском шага по умолчанию.
|
||||
ALLOW_VULNERABLE_DEPENDENCIES="${ALLOW_VULNERABLE_DEPENDENCIES:-false}"
|
||||
|
||||
security_gate_failed() {
|
||||
local scanner="$1"
|
||||
local details="$2"
|
||||
|
||||
if [ "$ALLOW_VULNERABLE_DEPENDENCIES" = "true" ]; then
|
||||
log_info "WARNING: $scanner нашёл уязвимости, но сборка продолжена по ALLOW_VULNERABLE_DEPENDENCIES=true"
|
||||
log_info "$details"
|
||||
DEPENDENCY_SECURITY_GATE="accepted-risk"
|
||||
export DEPENDENCY_SECURITY_GATE
|
||||
return 0
|
||||
fi
|
||||
|
||||
fail "$scanner: найдены уязвимости в зависимостях. Обновите граф или, осознанно принимая риск, выставьте ALLOW_VULNERABLE_DEPENDENCIES=true.
|
||||
$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
|
||||
}
|
||||
|
||||
# Анализ production-зависимостей frontend.
|
||||
run_frontend_vulnerability_gate() {
|
||||
local ui_src="${UI_SRC:-apps}"
|
||||
local report status
|
||||
|
||||
log_step "Security: pnpm audit --prod (порог ${PNPM_AUDIT_LEVEL})"
|
||||
|
||||
set +e
|
||||
report="$(cd "$ui_src/frontend" && "$PNPM_BIN" audit --prod --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: по готовому tarball должно
|
||||
# быть видно, проверялся он или собран с пропущенной проверкой. Ровно так же
|
||||
# устроен hysteria_compat_gate.
|
||||
run_dependency_security_gate() {
|
||||
if [ "${SKIP_SECURITY_SCAN:-false}" = "true" ]; then
|
||||
log_info "WARNING: проверка зависимостей на уязвимости пропущена (SKIP_SECURITY_SCAN=true)"
|
||||
DEPENDENCY_SECURITY_GATE="skipped"
|
||||
export DEPENDENCY_SECURITY_GATE
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ -n "${GOVULNCHECK_VERSION:-}" ] \
|
||||
|| fail "security: GOVULNCHECK_VERSION не задан; load_versions_contract должен выполниться первым"
|
||||
[ -n "${PNPM_AUDIT_LEVEL:-}" ] \
|
||||
|| fail "security: PNPM_AUDIT_LEVEL не задан; load_versions_contract должен выполниться первым"
|
||||
|
||||
DEPENDENCY_SECURITY_GATE="true"
|
||||
export DEPENDENCY_SECURITY_GATE
|
||||
|
||||
run_go_vulnerability_gate
|
||||
run_frontend_vulnerability_gate
|
||||
}
|
||||
@@ -34,6 +34,8 @@ REQUIRED_CONTRACT_VARS=(
|
||||
NODE_LINUX_X64_SHA256
|
||||
PNPM_VERSION
|
||||
HYSTERIA_CHANNEL
|
||||
GOVULNCHECK_VERSION
|
||||
PNPM_AUDIT_LEVEL
|
||||
)
|
||||
|
||||
validate_sha256_value() {
|
||||
@@ -99,6 +101,7 @@ load_versions_contract() {
|
||||
export NODE_VERSION NODE_LINUX_X64_SHA256 PNPM_VERSION HYSTERIA_CHANNEL
|
||||
export GO_REQUIRED BUN_REQUIRED NODE_REQUIRED PNPM_REQUIRED
|
||||
export GO_ARCHIVE_SHA256 NODE_ARCHIVE_SHA256
|
||||
export GOVULNCHECK_VERSION PNPM_AUDIT_LEVEL
|
||||
}
|
||||
|
||||
# Ожидаемая контрольная сумма Bun зависит от выбранного артефакта: на CPU без
|
||||
@@ -217,17 +220,31 @@ verify_api_namespace_contract() {
|
||||
# Историческое имя допустимо только в docs/14-legacy-cleanup.md и в
|
||||
# legacy-маркерах clean-host: там это имя чужого артефакта, а не наше.
|
||||
#
|
||||
# Список каталогов покрывает КАЖДЫЙ слой, который может собрать URL: раньше
|
||||
# в нём не было apps/middleware, apps/cmd, apps/dao и apps/util, то есть
|
||||
# ровно тех мест, где живут HTTP-логгер, точка входа сервера и helpers.
|
||||
# Сканируются ТОЛЬКО runtime production sources и то, что уезжает в пакет.
|
||||
# Файлы, задача которых — доказать отсутствие legacy-пространства, из скана
|
||||
# исключены: они обязаны называть его по имени, иначе проверять нечего.
|
||||
#
|
||||
# Регрессия приёмки. Раньше исключений не было, а в список каталогов входили
|
||||
# tools/build и orchestrator/test. Контракт ловил сам себя: grep возвращал
|
||||
# apps/router/router_test.go (регрессионный тест на отсутствие маршрута) и
|
||||
# этот самый файл, где строка '/hui' стоит в тексте проверки. Сборка падала
|
||||
# на шаге versions contract — то есть добавление теста, закрепляющего
|
||||
# очистку, гарантированно ломало сборку.
|
||||
#
|
||||
# Гарантия при этом не ослабла, а переехала на слой, где она сильнее: скан
|
||||
# исходников доказывает отсутствие СТРОКИ, а TestRouterHasNoLegacyNamespace
|
||||
# проверяет ТАБЛИЦУ МАРШРУТОВ собранного роутера. Поэтому существование
|
||||
# этого теста тоже входит в контракт и проверяется ниже.
|
||||
local legacy_hits
|
||||
legacy_hits="$(grep -rlF '/hui' \
|
||||
legacy_hits="$(grep -rlF --exclude='*_test.go' '/hui' \
|
||||
apps/model apps/router apps/controller apps/service apps/middleware \
|
||||
apps/cmd apps/dao apps/util apps/frontend/src \
|
||||
orchestrator/src orchestrator/test package/templates \
|
||||
tools/test tools/build 2>/dev/null || true)"
|
||||
orchestrator/src package/templates tools/test 2>/dev/null || true)"
|
||||
[ -z "$legacy_hits" ] \
|
||||
|| fail "versions contract: legacy /hui namespace came back in: $legacy_hits"
|
||||
|
||||
grep -q 'func TestRouterHasNoLegacyNamespace' apps/router/router_test.go \
|
||||
|| fail "versions contract: the legacy-namespace route test disappeared from apps/router/router_test.go"
|
||||
}
|
||||
|
||||
verify_go_toolchain_contract() {
|
||||
|
||||
Reference in New Issue
Block a user