From 594525dd738e634ac398d5ddcd491ac1bb74d505 Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 30 Aug 2026 18:18:13 +0500 Subject: [PATCH] =?UTF-8?q?build:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D1=8B=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B3=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=D0=B0=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=8C=20=D0=B3=D1=80=D0=B0=D1=84=20npm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два гейта сборки проверяли не то, что обещали. 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, а утверждение о прогоне обязано следовать за прогоном. --- CHANGELOG.md | 83 ++++++++++++++++++++++++++++++ README.md | 17 +++++- docs/02-build-layer-and-package.md | 44 +++++++++++++++- docs/11-testing-and-acceptance.md | 28 +++++++++- tools/build/README.md | 5 +- tools/build/lib/acceptance.sh | 55 ++++++++++++++++++++ tools/build/lib/package.sh | 51 ++++++++++++++---- tools/build/lib/security.sh | 27 +++++++--- 8 files changed, 290 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb894e4..e342209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,89 @@ Hardening-проход перед релизом `1.0.0`. Основная те неполной; диагностика, обрывающая соединения; аварийный выход сборки, которым невозможно воспользоваться. +Четвёртый проход — failure path и релизные гейты: восстановление после +неудачной установки, которое умело отменить само себя, и два гейта сборки, +проверявшие не то, что обещали. + +### Исправлено — восстановление после неудачной операции + +- **Запись состояния отказа отменяла откат.** Обработчик ошибки в `install` и + `reconfigure` первым делом писал в `install-state.json` фазу отказа обычным + `await` и только потом откатывался. Эта запись — `mkdir`, `write` и `chown` в + `/var/lib/hy2xs`, то есть она падает ровно там, где откат нужнее всего: + заполненный диск, read-only ФС, ошибка ввода-вывода. Бросок уносил управление + наружу, и обязательное восстановление не выполнялось вовсе — применённый + firewall и развёрнутые сервисы оставались на сервере. + + Необязательная телеметрия состояния стояла перед обязательным + восстановлением. Для сбора диагностики это уже было закрыто прошлым проходом, + для записи состояния — нет. Теперь запись обёрнута так же: неудача попадает в + журнал строкой `failed to persist failure state, continuing with the mandatory + rollback`, а откат продолжается. + +- **Откат отменял сам себя.** Он был написан цепочкой `await`, а каждая его + стадия — `systemctl`, `cp`, `rm -rf` или `nft`, то есть умеет упасть сама. + Отказ первой стадии отменял все последующие. В `reconfigure` это означало + сервер одновременно с применённым сломанным firewall **и** без + восстановленных из `/etc/hy2xs/backups` конфигов — худший сценарий отказа + лишался обеих половин восстановления сразу. + + Внутри `rollbackCurrentState` болезнь была та же: единственная команда без + `|| true` (`systemctl daemon-reload`) отменяла перезапуск сервисов строкой + ниже, и восстановленные unit-файлы так и не применялись. + + Стадии стали независимыми: выполняются все и в объявленном порядке, + отказавшие перечисляются в журнале, наружу уходит исходная ошибка операции. + +- **У маркера установки было два писателя с разными гарантиями.** `install` + перезаписывал файл на месте, `reconfigure` подставлял атомарно; слабейшая + гарантия досталась команде, которая этот файл создаёт. Перезапись на месте + укорачивает файл до нуля и только потом наполняет — отказ между этими + моментами оставляет половину JSON, которая не разбирается: `reconfigure` + видит такой маркер как отсутствующий, clean-host — как присутствующий, а хост + к этому моменту уже изменён. + + Атомарности при этом было бы мало: `rename()` без `fsync` даёт атомарность + видимости без долговечности, и после потери питания ext4 штатно отдаёт по + этому пути нулевой файл. Порядок теперь: права и владелец → `fsync` файла → + `rename` → `fsync` каталога. + +- **Ownership-флаг маркера отвечал не на тот вопрос.** Он назывался + `stateWritten` и взводился ПОСЛЕ успешной записи, хотя запись — это три + операции. Отказ на `chown` оставлял файл на диске при невзведённом флаге, то + есть давал классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — + при уже существующем `/var/lib/hy2xs/install-state.json`, который ломал + следующую чистую установку. Флаг переименован в `stateTouched` и взводится до + первой операции записи, как все остальные. + +### Изменено — релизные гейты сборки + +- **`pnpm audit` проверяет весь lock-граф, а не production-подграф.** Гейт + запускался с `--prod` под обоснованием «devDependencies в артефакт не + попадают». Для frontend build tooling это неверно по существу: `vite` и + `rollup` не копируются на сервер, но исполняются на build-машине и порождают + тот самый production-бандл. Ровно такой случай и был найден в этом же + релизном цикле — DOM clobbering в Rollup затрагивал генерируемый бандл, а + `--prod` его не показывал; по всему графу тот же прогон дал 33 предупреждения + против нуля. Критерий приёмки №47 в `docs/11` формулировал это правильно ещё + до того, как стало правдой в коде. + +- **Удалён `SKIP_TESTS`.** Переменная была описана как «аварийное отключение + тестов; для release-сборок недопустимо». Недопустимость держалась + исключительно на этой фразе: ни metadata, ни финальная приёмка архива не + проверяли, что тесты запускались, поэтому `SKIP_TESTS=true ./tools/build/build.sh` + доходила до конца и выдавала обычный tarball с `build_profile=production` и + `dependency_security_gate=true` — артефакт, по которому невозможно отличить + проверенную сборку от непроверенной. Глушила она при этом не только тесты, но + и `tsc --noEmit` с `go vet`. + + Выбран тот же строгий вариант, что и для проверки зависимостей: обхода нет, + а готовый пакет объявляет `tests_gate=true` в `metadata/package.env`. Поле + опирается на фактический прогон — `write_metadata` отказывается писать + метаданные, если хотя бы один из двух прогонов не подтверждён. Для локальной + работы обходить нечего: `bun test`, `tsc --noEmit`, `go vet` и `go test` + запускаются напрямую и tarball не создают. + ### Исправлено — операции, не выполняющие обещанного - **Удаление `bootstrap-admin-peer` не было отзывом доступа.** Признаком diff --git a/README.md b/README.md index af2c15f..1584fa7 100644 --- a/README.md +++ b/README.md @@ -957,11 +957,19 @@ export GITHUB_TOKEN= 4. проходит compatibility gate: реальный бинарник Hysteria должен принять канонический конфиг HY2XS для Gecko и для Salamander; 5. собирает orchestrator, frontend и backend, проставляя версию админки из контракта; 6. прогоняет `go vet` и `go test` для HY2XS admin; -7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit --prod`); +7. проверяет граф зависимостей на известные уязвимости (`govulncheck ./...` и `pnpm audit` по всему lock‑графу); 8. формирует архив и прогоняет acceptance‑проверки. Любой сбой на шагах 1–7 останавливает сборку до создания пакета. +Тесты и типы (шаги 2 и 6) — такой же обязательный гейт, как проверка +зависимостей: переменной, которая их отключает, не существует. Готовый пакет +объявляет об этом полем `tests_gate=true` в `metadata/package.env`, и это +утверждение опирается на фактический прогон, а не на намерение. + +Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`, +`go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают. + Переменные, управляющие выбором версии Hysteria: | Переменная | По умолчанию | Назначение | @@ -983,6 +991,13 @@ export GITHUB_TOKEN= цикл и падала на последнем шаге. Документированная операция, которую продукт сам же запрещает, — хуже отсутствующей. +`pnpm audit` при этом проверяет **весь** lock‑граф frontend, а не только +production‑подграф. Причина в том, что build tooling исполняется на build‑машине +и порождает production‑бандл: уязвимость в `vite`/`rollup` уезжает в артефакт, +хотя сами они на сервер не копируются. Ровно такой случай и был найден — DOM +clobbering в Rollup затрагивал генерируемый бандл, а проверка по одному +production‑подграфу его не показывала. + Если advisory вышло в неудачный момент, чинится это обновлением графа (`apps/go.sum`, `apps/frontend/pnpm-lock.yaml`) или версии toolchain в `versions.env`. Для локальной работы обходить нечего: `go test ./...`, diff --git a/docs/02-build-layer-and-package.md b/docs/02-build-layer-and-package.md index e92fea5..b7940a7 100644 --- a/docs/02-build-layer-and-package.md +++ b/docs/02-build-layer-and-package.md @@ -152,7 +152,20 @@ bundle из кода, который не проходит проверку ти | Проверка | Что покрывает | Порог | | --- | --- | --- | | `govulncheck ./...` | Go-граф **и stdlib**, с анализом достижимости: уязвимость считается только при наличии пути вызова из нашего кода | любая вызываемая | -| `pnpm audit --prod` | production-зависимости frontend, без анализа достижимости | `PNPM_AUDIT_LEVEL` | +| `pnpm audit` | **весь** lock-граф frontend, включая build tooling, без анализа достижимости | `PNPM_AUDIT_LEVEL` | + +Про «весь граф» отдельно, потому что здесь стояло `--prod` с обоснованием +«devDependencies в артефакт не попадают». + +Для frontend build tooling это обоснование неверно по существу. `vite` и +`rollup` действительно не копируются на production-сервер как `node_modules`. +Но они **исполняются на build-машине, читают наши исходники и порождают тот +самый production-бандл**, который уезжает в артефакт. Уязвимость в них — это +уязвимость в том, что мы выпускаем. + +Это не гипотеза: DOM clobbering в Rollup затрагивал именно генерируемый бандл, а +проверка по одному production-подграфу его не показывала. По всему графу тот же +прогон дал 33 предупреждения против нуля. Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире. @@ -180,6 +193,35 @@ bundle из кода, который не проходит проверку ти локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и `pnpm audit` запускаются напрямую и tarball не создают. +### Тесты и типы + +Та же политика и по той же причине. Аварийного выхода у этого шага **нет**: +переменной, отключающей тесты, не существует. + +Проверяется на трёх участках: + +| Шаг сборки | Что запускается | +| --- | --- | +| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` | +| `bundle_ui` | `pnpm run typecheck` (`vue-tsc --noEmit`) до сборки bundle | +| `run_admin_tests` | `go vet ./...`, `go test ./...` | + +Готовый пакет объявляет об этом полем `tests_gate=true` в +`metadata/package.env` — так же, как `dependency_security_gate` и +`hysteria_compat_gate`. Значение у поля ровно одно, потому что не бывает +пакета, собранного с пропущенными тестами: обе функции прогона выставляют свой +флаг **после** успешного завершения, а `write_metadata` отказывается писать +метаданные, если хотя бы один из них не выставлен. То есть поле остаётся +утверждением о результате, а не переключателем. + +Здесь существовала переменная, описанная как «аварийное отключение тестов; для +release-сборок недопустимо». Недопустимость держалась исключительно на этой +фразе: ни metadata, ни финальная приёмка архива не проверяли, что тесты +запускались, поэтому сборка с ней доходила до конца и выдавала внешне +неотличимый production-tarball. Глушила она при этом не только тесты, но и +`tsc --noEmit` с `go vet` — то есть проверку типов и статический анализ того +самого кода, который уезжает в production. История — в `CHANGELOG.md`. + ### Проверка, а не генерация `profile.ts`, `package/config/hy2xs.env` и `packageManager` в двух `package.json` diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 99f89b8..da1e600 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -534,6 +534,31 @@ metadata. Подробности в [docs/02](02-build-layer-and-package.md); з трактовать как «уязвимостей нет». По той же причине недоступность реестра npm для `pnpm audit` — это отказ проверки, а не её отрицательный результат. +`pnpm audit` проверяет **весь** lock-граф frontend, а не production-подграф: +build tooling исполняется на build-машине и порождает production-бандл, поэтому +уязвимость в нём уезжает в артефакт. Приёмка сборки следит, чтобы `--prod` не +вернулся в гейт. + +## A11a. Обязательные тесты (build) + +Гейт тестов устроен так же, как гейт зависимостей: аварийного выхода нет, +результат виден по готовому артефакту. + +| Шаг сборки | Что запускается | +| --- | --- | +| `run_orchestrator_tests` | `bun x tsc --noEmit`, `bun test` | +| `bundle_ui` | `pnpm run typecheck` до сборки bundle | +| `run_admin_tests` | `go vet ./...`, `go test ./...` | + +Приёмка проверяет: + +- отключающей тесты переменной нет ни в одном модуле сборки, ни в README/docs + (место для истории — `CHANGELOG.md`); +- `metadata/package.env` содержит `tests_gate=true`; +- утверждение о прогоне выставляется **после** самого прогона, а не до него; +- `write_metadata` отказывается писать метаданные, если хотя бы один из двух + прогонов не подтверждён. + ## A12. Приёмка проверяет код, а не упоминания Два контракта приёмки на снимке до этого патча **гарантированно роняли сборку на @@ -953,7 +978,7 @@ hy2xs-orchestrator doctor 37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска 38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен 39. админка не генерирует `HYSTERIA2_TRAFFIC_STATS_SECRET` сама: пустой env при пустой базе — отказ старта -40. проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации +40. проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации, и покрывает весь lock-граф frontend 41. `apps/go.mod` объявляет `toolchain`, совпадающий с `GO_VERSION` из `versions.env` 42. `tools/dev/doctor.sh` / `doctor.ps1` показывают расхождение среды разработки с `versions.env` 43. маршруты-алиасы `/:id/client-url` и `/:id/qr` удалены и не входят в публичный API v1 @@ -967,3 +992,4 @@ hy2xs-orchestrator doctor 51. невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи 52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный 53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле +54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон diff --git a/tools/build/README.md b/tools/build/README.md index 4b0c47d..2097ac5 100644 --- a/tools/build/README.md +++ b/tools/build/README.md @@ -170,7 +170,10 @@ BUILD FAILED: unsupported Hysteria stable v2.13.0 | `HYSTERIA_GATE_PORT` | `34443` | UDP-порт для временного запуска Hysteria в gate | | `HYSTERIA_GATE_STATS_PORT` | `34712` | TCP-порт trafficStats в gate | | `GITHUB_TOKEN` | пусто | Опционально: снимает anonymous rate limit GitHub API | -| `SKIP_TESTS` | `false` | Аварийное отключение тестов; для release-сборок недопустимо | + +Тесты, проверка типов и проверка зависимостей переменными не управляются: у них +нет аварийного выхода. Готовый пакет объявляет об этом полями `tests_gate=true` +и `dependency_security_gate=true` в `metadata/package.env`. Обновить lock-файл под текущий upstream: diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 60bfd5a..f033fa6 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -653,6 +653,61 @@ run_dependency_hygiene_acceptance() { grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \ || fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей" + log_step "Acceptance: the npm gate covers the whole lock graph, not only production deps" + # `--prod` не показывал уязвимости в vite/rollup, хотя они исполняются на + # build-машине и порождают production-бандл. Ровно такой класс и был найден + # (DOM clobbering в Rollup затрагивал генерируемый бандл). + ! code_without_comments tools/build/lib/security.sh | grep -q 'audit --prod' \ + || fail "acceptance: гейт зависимостей снова проверяет только production-подграф npm" + code_without_comments tools/build/lib/security.sh \ + | grep -q 'audit --audit-level "\$PNPM_AUDIT_LEVEL"' \ + || fail "acceptance: гейт зависимостей обязан проверять весь lock-граф frontend" + + log_step "Acceptance: the release is built with a mandatory test gate" + # SKIP_TESTS=true доходил до конца сборки и выдавал внешне неотличимый + # production-артефакт: ни metadata, ни финальная приёмка не проверяли, что + # тесты запускались. Глушил он при этом и `tsc --noEmit`, и `go vet`. + local test_bypass_file + for test_bypass_file in \ + tools/build/build.sh \ + tools/build/lib/package.sh \ + tools/build/lib/security.sh \ + tools/build/lib/versions.sh \ + tools/build/lib/deps.sh \ + tools/build/lib/verify.sh + do + [ -z "$(code_without_comments "$test_bypass_file" | grep -F -- 'SKIP_TESTS' || true)" ] \ + || fail "acceptance: обход тестов SKIP_TESTS вернулся в $test_bypass_file" + done + ! grep -rq 'SKIP_TESTS' README.md docs tools/build/README.md \ + || fail "acceptance: README/docs снова описывают обход тестов, которого нет в сборке; место для истории — CHANGELOG.md" + grep -q '^tests_gate=true$' "$package_dir/metadata/package.env" \ + || fail "acceptance: релизный пакет обязан собираться с пройденными тестами" + # Утверждение обязано опираться на результат, а не на намерение. + grep -q 'ORCHESTRATOR_TESTS_PASSED' tools/build/lib/package.sh \ + || fail "acceptance: прогон тестов оркестратора не фиксируется результатом" + grep -q 'ADMIN_TESTS_PASSED' tools/build/lib/package.sh \ + || fail "acceptance: прогон тестов админки не фиксируется результатом" + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8"); + for (const [fn, flag] of [ + ["run_orchestrator_tests()", "ORCHESTRATOR_TESTS_PASSED=\"true\""], + ["run_admin_tests()", "ADMIN_TESTS_PASSED=\"true\""] + ]) { + const start = source.indexOf(fn); + if (start < 0) throw new Error("не найдена функция " + fn); + const rest = source.slice(start); + // Границей тела служит закрывающая скобка в первой позиции строки. + // Поиск регуляркой, а не подстрокой: файл может быть выгружен с CRLF. + const end = rest.search(/\n\}[\r\n]/); + const body = end < 0 ? rest : rest.slice(0, end); + const set = body.indexOf(flag); + const run = body.lastIndexOf("|| fail"); + if (set < 0) throw new Error(fn + ": результат прогона не фиксируется"); + if (set < run) throw new Error(fn + ": результат объявляется раньше, чем получен"); + } + ' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном" + # У шага не должно быть обходов — ни объявленных, ни забытых. # # Раньше их было два: ALLOW_VULNERABLE_DEPENDENCIES=true писал в metadata diff --git a/tools/build/lib/package.sh b/tools/build/lib/package.sh index 322d530..4d687b8 100644 --- a/tools/build/lib/package.sh +++ b/tools/build/lib/package.sh @@ -28,26 +28,42 @@ prepare_stage() { printf '%s\n' "$HY2XS_CONFIG_SCHEMA_VERSION" >"$STAGE_DIR/metadata/package.config_schema_version" } +# Тесты — обязательный шаг релиза, аварийного выхода у него НЕТ. +# +# Это то же решение и по той же причине, что и у проверки зависимостей. +# +# Раньше существовал SKIP_TESTS=true, и README описывал его как «аварийное +# отключение тестов; для release-сборок недопустимо». Недопустимость держалась +# исключительно на этой фразе: ни metadata, ни финальная приёмка архива не +# проверяли, что тесты вообще запускались. То есть +# +# SKIP_TESTS=true ./tools/build/build.sh +# +# доходила до конца и выдавала обычный tarball с build_profile=production и +# dependency_security_gate=true — артефакт, по которому невозможно отличить +# проверенную сборку от непроверенной. +# +# Глушил он при этом не только тесты: под тем же флагом пропускались +# `tsc --noEmit` для оркестратора и `go vet` для админки, то есть проверка типов +# и статический анализ того самого кода, который уезжает в production. +# +# Для локальной работы обходить нечего: `bun test`, `bun x tsc --noEmit`, +# `go vet ./...` и `go test ./...` запускаются напрямую и tarball не создают. run_orchestrator_tests() { - if [ "${SKIP_TESTS:-false}" = "true" ]; then - log_info "WARNING: orchestrator tests skipped (SKIP_TESTS=true)" - return 0 - fi - ( cd orchestrator "$BUN_BIN" install --frozen-lockfile "$BUN_BIN" x tsc --noEmit "$BUN_BIN" test ) || fail "orchestrator contract tests failed" + + # Утверждение о результате выставляется ПОСЛЕ получения результата, ровно как + # DEPENDENCY_SECURITY_GATE. + ORCHESTRATOR_TESTS_PASSED="true" + export ORCHESTRATOR_TESTS_PASSED } run_admin_tests() { - if [ "${SKIP_TESTS:-false}" = "true" ]; then - log_info "WARNING: HY2XS admin tests skipped (SKIP_TESTS=true)" - return 0 - fi - # `go:embed all:dist` требует собранных frontend-ассетов, поэтому эта # функция должна вызываться только после bundle_ui. [ -d "${UI_SRC:-apps}/frontend/dist" ] \ @@ -58,6 +74,9 @@ run_admin_tests() { GOTOOLCHAIN=local "$GO_BIN" vet ./... GOTOOLCHAIN=local "$GO_BIN" test ./... ) || fail "HY2XS admin contract tests failed" + + ADMIN_TESTS_PASSED="true" + export ADMIN_TESTS_PASSED } build_orchestrator() { @@ -148,6 +167,17 @@ write_metadata() { validate_hysteria_version_format "$HYSTERIA_VERSION" validate_hysteria_sha256_format "$HYSTERIA_ARTIFACT_SHA256" + # tests_gate — утверждение о готовом артефакте, а не переключатель. + # + # Значение у него ровно одно, потому что не бывает пакета, собранного с + # пропущенными тестами: обе функции выставляют свой флаг только после + # успешного прогона. Поле нужно, чтобы это было ВИДНО по tarball — раньше + # SKIP_TESTS=true выдавала внешне неотличимый production-артефакт. + [ "${ORCHESTRATOR_TESTS_PASSED:-false}" = "true" ] \ + || fail "write_metadata: контракты оркестратора не проверялись; тесты обязательны для релизного пакета" + [ "${ADMIN_TESTS_PASSED:-false}" = "true" ] \ + || fail "write_metadata: контракты админки не проверялись; тесты обязательны для релизного пакета" + { printf 'name=HY2XS\n' printf 'license=AGPL-3.0-only\n' @@ -172,6 +202,7 @@ write_metadata() { 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 'tests_gate=true\n' printf 'hysteria_source=official-upstream\n' printf 'hysteria_target=linux-amd64\n' printf 'hysteria_version=%s\n' "$HYSTERIA_VERSION" diff --git a/tools/build/lib/security.sh b/tools/build/lib/security.sh index 8d2204f..81ef365 100644 --- a/tools/build/lib/security.sh +++ b/tools/build/lib/security.sh @@ -23,9 +23,24 @@ set -euo pipefail # govulncheck — анализ достижимости: уязвимость считается только если до неё # есть путь вызова из нашего кода. Покрывает и stdlib, то есть # ровно то, что версия Go привносит в production-бинарь; -# pnpm audit — граф npm без анализа достижимости, поэтому ограничен -# production-зависимостями и порогом PNPM_AUDIT_LEVEL: -# devDependencies в артефакт не попадают. +# 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 по всему графу») +# ещё до того, как это стало правдой в коде. # Аварийного выхода у этого шага НЕТ, и это осознанное решение. # @@ -95,15 +110,15 @@ $report" esac } -# Анализ production-зависимостей frontend. +# Анализ всего lock-графа frontend, включая build tooling. run_frontend_vulnerability_gate() { local ui_src="${UI_SRC:-apps}" local report status - log_step "Security: pnpm audit --prod (порог ${PNPM_AUDIT_LEVEL})" + log_step "Security: pnpm audit по всему графу (порог ${PNPM_AUDIT_LEVEL})" set +e - report="$(cd "$ui_src/frontend" && "$PNPM_BIN" audit --prod --audit-level "$PNPM_AUDIT_LEVEL" 2>&1)" + report="$(cd "$ui_src/frontend" && "$PNPM_BIN" audit --audit-level "$PNPM_AUDIT_LEVEL" 2>&1)" status=$? set -e