From 8a35d9249f2585c467a503ea54c95878243206b8 Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 06:45:46 +0300 Subject: [PATCH 01/13] =?UTF-8?q?=D0=97=D0=B0=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20productio?= =?UTF-8?q?n-hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 не меняет поведение: документ фиксирует базовый commit, чистую сборку, состав и результат полного набора тестов (33/33 CTest + 84/84 strategy fidelity), текущую версию upstream (1.10.1) и точную природу дефекта семантики версий RC/stable. Co-Authored-By: Claude Opus 5 --- docs/production/BASELINE_BEFORE_HARDENING.md | 159 +++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/production/BASELINE_BEFORE_HARDENING.md diff --git a/docs/production/BASELINE_BEFORE_HARDENING.md b/docs/production/BASELINE_BEFORE_HARDENING.md new file mode 100644 index 0000000..3752084 --- /dev/null +++ b/docs/production/BASELINE_BEFORE_HARDENING.md @@ -0,0 +1,159 @@ +# CHEBURNET — базовое состояние перед production-hardening + +Документ фиксирует воспроизводимое состояние репозитория **до** каких-либо +изменений в рамках подготовки стабильного выпуска `v1.0.0`. + +## Git + +| Параметр | Значение | +|---|---| +| Базовая ветка | `main` | +| Базовый commit (HEAD до изменений) | `78cc980a6d0d68f669cf98eff3799c1cf5fa02bd` | +| Тег на базовом commit | `v1.0.0-rc.3` | +| Рабочее дерево | чистое (`git status --porcelain` — пусто) | +| Рабочая ветка изменений | `claude/production-hardening-v1` | +| Существующие теги | `v1.0.0-rc.1`, `v1.0.0-rc.2`, `v1.0.0-rc.3` | + +## Окружение сборки + +| Параметр | Значение | +|---|---| +| ОС | Windows 11 Pro 10.0.26200 | +| Тулчейн | MSVC (Visual Studio 18 BuildTools), x64 | +| Генератор | Ninja | +| Конфигурация | Release | +| Стандарт | C++20, `/W4 /WX /permissive- /utf-8`, `/guard:cf`, статический CRT (`/MT`) | + +## Результат чистой сборки + +``` +scripts\build-release.ps1 -BuildDir build-p0-baseline +``` + +- Результат: **PASS** +- Артефакт: `build-p0-baseline\CHEBURNET.exe`, 3.91 MB +- Предупреждения компилятора: **нет** (сборка идёт с `/WX`, любое предупреждение было бы ошибкой) +- Встроенный тестовый пакет обновления: `UPDATE_PACKAGE: PASS version=1.10.1 files=27 bytes=3399786 sha256=41004f8523af4c176d859590ee8b617834df048e892006dab8cc77cfbd6b7319` + +## Результат полного набора тестов + +``` +scripts\run-tests.ps1 -BuildDir build-p0-baseline +``` + +- CTest: **33/33 PASS**, 0 падений, общее время 7.04 s +- Независимая кросс-проверка стратегий: `STRATEGY_FIDELITY: 84/84 PASS (21 strategies x 4 modes)` +- Итог: `ALL TESTS PASSED` + +Состав CTest (33 теста): + +| Категория | Тесты | +|---|---| +| Аргументы/строки | `args`, `quoting` | +| Конфигурация | `config`, `configmigration` | +| Криптография/целостность | `sha256`, `integrity` | +| Манифест ресурсов | `manifest` | +| Процессы | `process`, `preflight`, `operationstate` | +| Файловая система | `securefs`, `loghandle` | +| UI | `animation`, `progress`, `mascot`, `layout`, `checkpoints`, `menu`, `effects`, `theme`, `framebuffer` | +| JSON | `jsonstrict` | +| Обновления | `updatemanifest`, `updateversion`, `updatesignature`, `updatepackage`, `updaterollback`, `updatestate`, `updatehttp` | +| Скриптовые шлюзы | `package_safety`, `security_regression`, `license_validation`, `release_metadata` | + +## Текущая версия upstream + +`resources/upstream/provenance.json`: + +| Поле | Значение | +|---|---| +| provider | `Flowseal/zapret-discord-youtube` | +| version / tag | `1.10.1` | +| release_id | `367512178` | +| archive_sha256 | `f748d61fec75e4edc992cb5b09d554e914197c68c690384aceb61f143d8f76c9` | +| upstream_commit | `47da17f80ad36a8424cdd25658153fdebd7eb938` | +| immutable | `true` | + +Встроенных стратегий: **21** (`general` + `general (ALT)` … `general (ALT12)` + `general (EXP)` и варианты), проверено в 4 режимах GameFilter. + +Доступная стабильная версия upstream на момент фиксации baseline: **1.10.2** +(`zapret-discord-youtube-1.10.2.zip`, `sha256:5eaac9fb2e4b1abd693487452a3ff3f4dfe9578a45f9ddddfa4bc1f5a6bb62d5`, +`immutable=true`, `prerelease=false`). + +## Текущее поведение семантической версии лончера (дефект) + +Источник версии — единственный: `project(CHEBURNET VERSION 1.0.0)` в `CMakeLists.txt`. + +`cmake/GeneratedVersion.h.in` раскрывается в: + +```c +#define CHEBURNET_VERSION_QUAD 1,0,0,0 +#define CHEBURNET_VERSION_STR "1.0.0" +#define CHEBURNET_VERSION_RC_STR "1.0.0.0" // значение PE FileVersion/ProductVersion +#define CHEBURNET_VERSION_WSTR L"1.0.0" +``` + +Следствия на baseline: + +1. Бинарник, собранный для тега `v1.0.0-rc.3`, во время выполнения + идентифицирует себя как `1.0.0` (`CHEBURNET_VERSION_STR`). +2. `UpdateManager::CheckNow` сравнивает именно `CHEBURNET_VERSION_STR` + с версией из подписанного манифеста + (`src/update/UpdateManager.cpp:138`, `src/update/UpdateManager.cpp:150`). +3. `scripts/prepare-release.ps1` передаёт в генератор манифеста + `-LauncherVersion $sourceVersion`, то есть `1.0.0`, **и для RC-тегов тоже**. +4. Поэтому будущий стабильный `1.0.0` оценивается установленным + `v1.0.0-rc.3` как `Eligibility::Current`, а не `Upgrade`. + +При этом сам компаратор версий корректно упорядочивает prerelease: +`CompareVersions("1.0.0-rc.3", "1.0.0") < 0`. Дефект — исключительно в том, +что prerelease-метка вообще не попадает в версию времени выполнения и в +метаданные выпуска. Это и есть предмет Phase 1; компаратор ослаблять не требуется. + +Дополнительно зафиксированы связанные ограничения baseline: + +- `scripts/generate-update-manifest.ps1` требует строгого равенства + `FileVersion == ".0"`, то есть семантическая версия и + числовая PE-версия жёстко склеены и не могут различаться. +- `scripts/prepare-release.ps1` допускает RC-тег при стабильной исходной + версии, но не умеет выразить RC в метаданных выпуска. + +## Текущий release workflow (ключевые наблюдения) + +`.github/workflows/release.yml`: + +- шаг подписи называется «Необязательная подпись и проверка Authenticode» и + вызывает `scripts/authenticode-sign.ps1`, который при отсутствии + `AUTHENTICODE_PFX_B64` печатает предупреждение и завершается с кодом `0`; +- то есть стабильный тег `vX.Y.Z` на baseline **может быть опубликован без подписи**; +- порядок «подпись → упаковка → хеш» сам по себе корректен: подпись ставится + на `build-release\CHEBURNET.exe`, а `package.ps1` копирует этот файл в `dist` + и только затем считает SHA-256 — то есть хеш уже считается с подписанного файла; +- `permissions: contents: write` — минимальные права для публикации релиза; +- сторонние actions закреплены по неизменяемым commit SHA. + +`.github/workflows/upstream-check.yml` выполняет проверку новых выпусков +upstream, но не создаёт устойчивую задачу (PR/issue) — предмет Phase 4. + +## Текущие метаданные RC-выпуска + +Для тега `v1.0.0-rc.3` публикуются: + +- `CHEBURNET.exe` + `CHEBURNET.exe.sha256` +- `cheburnet-payload-1.10.1.cbpkg` + `.sha256` +- `update-manifest.json` + `update-manifest.json.sig` +- `THIRD_PARTY_NOTICES.md`, `DEPENDENCIES.md`, `LICENSES/*` + +`prerelease` в GitHub Release выставляется по наличию `-rc.` в имени тега. +Внутри `update-manifest.json` канал всегда `stable`, а `launcher.version` на +baseline равен `1.0.0` даже для RC — см. дефект выше. + +## Известные предупреждения / шум + +- Предупреждений компилятора нет. +- В корне рабочей копии присутствует множество локальных каталогов сборки + (`build*`, `dist`, `artifacts`) — все они в `.gitignore` и на состояние + репозитория не влияют. + +## Изменения поведения в этой фазе + +Отсутствуют. Phase 0 — только фиксация состояния. From 34c0f596b5d8e198d9009c62a23ea1b8fbb0147e Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 10:07:56 +0300 Subject: [PATCH 02/13] =?UTF-8?q?=D0=92=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82=D0=BD?= =?UTF-8?q?=D1=83=D1=8E=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20RC/stable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дефект: исходная версия была единственным числом 1.0.0, поэтому сборка по тегу v1.0.0-rc.3 сообщала о себе «1.0.0», и будущий стабильный 1.0.0 оценивался установленным RC как Current, а не Upgrade. Модель версии (cmake/Version.cmake) теперь единственный источник истины и хранит четыре поля: major/minor/patch и prerelease. Из них выводятся семантическая версия (1.0.0-rc.3), числовая PE-версия (1.0.0.3), признак предварительного выпуска и канал обновлений. Решения об обновлении принимаются только по семантической версии; PE-версия информационная. Сравнение версий не ослаблено, а уточнено: prerelease сравнивается по правилам precedence SemVer 2.0.0 (rc.2 < rc.10), ведущие нули и пустые идентификаторы отклоняются как псевдонимы, ревизия upstream (1.10.1a) теперь корректно считается новее базового выпуска, а не понижением. Канал перестал быть константой true: стабильная сборка по-прежнему отклоняет любые предварительные версии, RC-сборка может перейти на новый RC или на стабильный выпуск. Пороги совместимости в манифесте вынесены в параметр со значением 1.0.0-rc.1, иначе установленный 1.0.0-rc.N получал бы LauncherTooOld при переходе на стабильный 1.0.0. Шлюз выпуска: тег обязан точно совпадать с исходной версией и каналом, поэтому стабильный тег не может опубликовать RC-семантику и наоборот. Тесты: новый набор version_model (34-й тест CTest) и расширенные updateversion/updatemanifest/release_metadata. Порядок версий задан одной канонической таблицей tests/version-order-cases.json, которую независимо проверяют реализация на C++ и реализация на PowerShell. Проверено: чистая сборка без предупреждений, CTest 34/34, strategy fidelity 84/84. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 12 +- cmake/GeneratedVersion.h.in | 31 +++- cmake/Version.cmake | 59 +++++++ resources/app.manifest.in | 2 +- resources/cheburnet.rc | 4 +- scripts/generate-update-manifest.ps1 | 35 ++-- scripts/prepare-release.ps1 | 26 ++- scripts/version.ps1 | 242 +++++++++++++++++++++++++++ src/update/UpdateManager.cpp | 8 +- src/update/Version.cpp | 128 +++++++++++--- src/update/Version.h | 25 ++- src/util/Version.h | 10 +- tests/CMakeLists.txt | 10 +- tests/release_metadata.ps1 | 115 ++++++++++--- tests/security_regression.ps1 | 11 +- tests/test_main.cpp | 168 ++++++++++++++++++- tests/version-order-cases.json | 49 ++++++ tests/version_model.ps1 | 140 ++++++++++++++++ 18 files changed, 974 insertions(+), 101 deletions(-) create mode 100644 cmake/Version.cmake create mode 100644 scripts/version.ps1 create mode 100644 tests/version-order-cases.json create mode 100644 tests/version_model.ps1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 24918db..ce29543 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,14 @@ cmake_minimum_required(VERSION 3.25) -project(CHEBURNET VERSION 1.0.0 LANGUAGES CXX) +# Единственный авторитетный источник версии (семантическая + числовая PE + +# prerelease + канал). Подключается до project(), поэтому использует +# CMAKE_CURRENT_LIST_DIR. +include("${CMAKE_CURRENT_LIST_DIR}/cmake/Version.cmake") + +project(CHEBURNET VERSION "${CHEBURNET_VERSION_PE}" LANGUAGES CXX) + +message(STATUS "CHEBURNET: версия ${CHEBURNET_VERSION_SEMANTIC} " + "(PE ${CHEBURNET_VERSION_PE}, канал ${CHEBURNET_RELEASE_CHANNEL})") set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -30,7 +38,7 @@ set(STRATEGY_SRC "${CMAKE_SOURCE_DIR}/resources/strategies_src") file(MAKE_DIRECTORY "${GEN_DIR}") # Launcher version is a single source of truth. C++, PE VERSIONINFO and the -# embedded application manifest are all configured from project(VERSION). +# embedded application manifest are all configured from cmake/Version.cmake. configure_file( "${CMAKE_SOURCE_DIR}/cmake/GeneratedVersion.h.in" "${GEN_DIR}/GeneratedVersion.h" @ONLY) diff --git a/cmake/GeneratedVersion.h.in b/cmake/GeneratedVersion.h.in index 9cf69f2..40762e6 100644 --- a/cmake/GeneratedVersion.h.in +++ b/cmake/GeneratedVersion.h.in @@ -1,11 +1,26 @@ -// СОЗДАНО АВТОМАТИЧЕСКИ CMake из версии project() — НЕ РЕДАКТИРОВАТЬ. +// СОЗДАНО АВТОМАТИЧЕСКИ CMake из cmake/Version.cmake — НЕ РЕДАКТИРОВАТЬ. #pragma once -#define CHEBURNET_VERSION_MAJOR @PROJECT_VERSION_MAJOR@ -#define CHEBURNET_VERSION_MINOR @PROJECT_VERSION_MINOR@ -#define CHEBURNET_VERSION_PATCH @PROJECT_VERSION_PATCH@ +// Числовые компоненты. TWEAK несёт номер RC (0 для стабильного выпуска) и +// существует только ради числового формата PE-версии. +#define CHEBURNET_VERSION_MAJOR @CHEBURNET_VERSION_MAJOR@ +#define CHEBURNET_VERSION_MINOR @CHEBURNET_VERSION_MINOR@ +#define CHEBURNET_VERSION_PATCH @CHEBURNET_VERSION_PATCH@ +#define CHEBURNET_VERSION_TWEAK @CHEBURNET_VERSION_TWEAK@ -#define CHEBURNET_VERSION_QUAD @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0 -#define CHEBURNET_VERSION_STR "@PROJECT_VERSION@" -#define CHEBURNET_VERSION_RC_STR "@PROJECT_VERSION@.0" -#define CHEBURNET_VERSION_WSTR L"@PROJECT_VERSION@" +// PE VERSIONINFO: только числовая форма. +#define CHEBURNET_VERSION_QUAD @CHEBURNET_VERSION_MAJOR@,@CHEBURNET_VERSION_MINOR@,@CHEBURNET_VERSION_PATCH@,@CHEBURNET_VERSION_TWEAK@ +#define CHEBURNET_VERSION_PE_STR "@CHEBURNET_VERSION_PE@" + +// Семантическая версия — единственное значение, по которому принимаются +// решения об обновлении. Для RC она содержит prerelease-метку. +#define CHEBURNET_VERSION_STR "@CHEBURNET_VERSION_SEMANTIC@" +#define CHEBURNET_VERSION_WSTR L"@CHEBURNET_VERSION_SEMANTIC@" +#define CHEBURNET_VERSION_CORE_STR "@CHEBURNET_VERSION_CORE@" +#define CHEBURNET_VERSION_PRERELEASE_STR "@CHEBURNET_VERSION_PRERELEASE@" + +// Канал выпуска этой сборки. Стабильная сборка никогда не принимает +// предварительные версии; RC-сборка может перейти на новый RC или на стабильный. +#define CHEBURNET_VERSION_IS_PRERELEASE @CHEBURNET_VERSION_IS_PRERELEASE@ +#define CHEBURNET_STABLE_CHANNEL @CHEBURNET_STABLE_CHANNEL@ +#define CHEBURNET_RELEASE_CHANNEL "@CHEBURNET_RELEASE_CHANNEL@" diff --git a/cmake/Version.cmake b/cmake/Version.cmake new file mode 100644 index 0000000..c58ca3e --- /dev/null +++ b/cmake/Version.cmake @@ -0,0 +1,59 @@ +# --------------------------------------------------------------------------- +# CHEBURNET — единственный авторитетный источник версии. +# +# Здесь задаются только четыре поля. Всё остальное (семантическая версия, +# числовая PE-версия, признак предварительного выпуска и канал обновлений) +# выводится ниже, чтобы версия нигде не дублировалась вручную. +# +# Правила: +# * CHEBURNET_VERSION_PRERELEASE == "" -> стабильный выпуск vX.Y.Z +# * CHEBURNET_VERSION_PRERELEASE == "rc.N" -> предварительный выпуск vX.Y.Z-rc.N +# +# Числовая PE-версия намеренно отличается от семантической: PE-формат не умеет +# выражать prerelease, поэтому номер RC переносится в четвёртый компонент +# (1.0.0-rc.3 -> 1.0.0.3, стабильная 1.0.0 -> 1.0.0.0). PE-версия — +# информационная; решения об обновлении принимаются ИСКЛЮЧИТЕЛЬНО по +# семантической версии (CHEBURNET_VERSION_STR), см. docs/UPDATE_SECURITY.md. +# --------------------------------------------------------------------------- + +set(CHEBURNET_VERSION_MAJOR 1) +set(CHEBURNET_VERSION_MINOR 0) +set(CHEBURNET_VERSION_PATCH 0) +set(CHEBURNET_VERSION_PRERELEASE "rc.3") + +# --- производные значения: не редактировать --------------------------------- + +foreach(_component MAJOR MINOR PATCH) + if(NOT "${CHEBURNET_VERSION_${_component}}" MATCHES "^(0|[1-9][0-9]*)$") + message(FATAL_ERROR + "CHEBURNET_VERSION_${_component} должен быть числом без ведущих нулей") + endif() +endforeach() + +set(CHEBURNET_VERSION_CORE + "${CHEBURNET_VERSION_MAJOR}.${CHEBURNET_VERSION_MINOR}.${CHEBURNET_VERSION_PATCH}") + +if(CHEBURNET_VERSION_PRERELEASE STREQUAL "") + set(CHEBURNET_VERSION_SEMANTIC "${CHEBURNET_VERSION_CORE}") + set(CHEBURNET_VERSION_TWEAK 0) + set(CHEBURNET_VERSION_IS_PRERELEASE 0) + set(CHEBURNET_STABLE_CHANNEL 1) + set(CHEBURNET_RELEASE_CHANNEL "stable") +elseif(CHEBURNET_VERSION_PRERELEASE MATCHES "^rc\.([1-9][0-9]*)$") + set(CHEBURNET_VERSION_SEMANTIC + "${CHEBURNET_VERSION_CORE}-${CHEBURNET_VERSION_PRERELEASE}") + set(CHEBURNET_VERSION_TWEAK "${CMAKE_MATCH_1}") + set(CHEBURNET_VERSION_IS_PRERELEASE 1) + set(CHEBURNET_STABLE_CHANNEL 0) + set(CHEBURNET_RELEASE_CHANNEL "prerelease") +else() + message(FATAL_ERROR + "CHEBURNET_VERSION_PRERELEASE должен быть пустым или иметь вид rc.N (N >= 1), " + "получено: '${CHEBURNET_VERSION_PRERELEASE}'") +endif() + +if(CHEBURNET_VERSION_TWEAK GREATER 65534) + message(FATAL_ERROR "номер RC не укладывается в компонент PE-версии") +endif() + +set(CHEBURNET_VERSION_PE "${CHEBURNET_VERSION_CORE}.${CHEBURNET_VERSION_TWEAK}") diff --git a/resources/app.manifest.in b/resources/app.manifest.in index 495b962..e1b9ca1 100644 --- a/resources/app.manifest.in +++ b/resources/app.manifest.in @@ -3,7 +3,7 @@ " + title="Upstream Flowseal ${UPSTREAM_VERSION} доступен для импорта" + body_file="$(mktemp)" + { + printf '%s\n\n' "$marker" + cat upstream/report.md + if [ -s upstream/payload-diff.txt ]; then + printf '\n### git diff --stat\n\n```\n' + cat upstream/payload-diff.txt + printf '```\n' + fi + } > "$body_file" + # Дедупликация по скрытому маркеру, а не по заголовку: заголовок + # может быть отредактирован человеком, маркер — нет. + existing="$(gh issue list --state all --limit 100 --json number,body \ + --jq "[.[] | select(.body != null and (.body | contains(\"${marker}\"))) | .number] | first // empty")" + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file "$body_file" + echo "updated existing issue #${existing}" + else + gh issue create --title "$title" --body-file "$body_file" + fi diff --git a/docs/production/UPSTREAM_AUTOMATION.md b/docs/production/UPSTREAM_AUTOMATION.md new file mode 100644 index 0000000..df7fc86 --- /dev/null +++ b/docs/production/UPSTREAM_AUTOMATION.md @@ -0,0 +1,124 @@ +# Автоматизация импорта исходных выпусков + +Документ описывает, что происходит при появлении нового стабильного выпуска +Flowseal и почему автоматика устроена именно так. + +## Что делает запланированный процесс + +`.github/workflows/upstream-check.yml` запускается по расписанию (еженедельно) +и вручную. Он состоит из трёх заданий с раздельными правами. + +### 1. `detect` — `contents: read` + +`scripts/upstream-report.ps1` запрашивает последний выпуск Flowseal и сравнивает +его с `resources/upstream/provenance.json`. Выпуск принимается к рассмотрению +только если он не черновик, не предварительный и помечен как неизменяемый, а +релизный ZIP-ассет имеет SHA-256 digest. + +Результат — машиночитаемый `report.json` и готовый `report.md`. Статус: + +| Статус | Значение | +|---|---| +| `current` | встроенная нагрузка уже соответствует последнему стабильному выпуску | +| `available` | существует более новый неизменяемый стабильный выпуск | +| `error` | состояние upstream определить не удалось (задание падает) | + +### 2. `validate` — `contents: read` + +Выполняется только при статусе `available`. + +Запускается штатный защищённый импортёр `scripts/sync-upstream.ps1`, который сам +проверяет кандидата в изолированном дереве: сверка SHA-256 архива с digest +релизного ассета, валидация каждой записи ZIP до материализации, генерация +стратегий и ресурсов, чистая сборка, полный набор тестов и побайтовая сверка +установленной нагрузки с архивом. Затем итоговое дерево собирается и тестируется +ещё раз, и проверяется достоверность upstream. + +Импорт происходит в одноразовой рабочей копии раннера и **никуда не публикуется**. +В артефакты запуска выгружаются: `report.json`, `report.md`, +`payload-diff.txt` (`git diff --stat`) и `payload-status.txt`. + +Дельта нагрузки вычисляется из фактического вывода `git status --porcelain` +(`scripts/upstream-delta.ps1`), а не угадывается: учитываются добавления, +удаления, изменения, переименования и закавыченные пути с пробелами. + +### 3. `task` — `issues: write` + +Создаёт **одну** задачу на версию upstream либо обновляет существующую. +Дедупликация выполняется по скрытому маркеру в теле задачи: + +``` + +``` + +Маркер, а не заголовок: заголовок человек может отредактировать, маркер — нет. +Поиск идёт по задачам в любом состоянии, поэтому повторный запуск не создаёт +дубликат и не открывает заново уже закрытую задачу. + +## Состав задачи + +| Поле | Источник | +|---|---| +| Старая версия | `provenance.version` | +| Новая версия | тег релиза upstream | +| Ссылка на выпуск | `release.html_url` | +| SHA-256 архива | digest релизного ассета | +| Размер архива | `asset.size` | +| Commit upstream | разрешение тега до неизменяемого commit | +| Число стратегий до и после | дельта из `git status` | +| Добавленные / удалённые / изменённые стратегии и файлы нагрузки | дельта из `git status` | +| Результат проверки | `passed` / `failed` с пояснением | +| Ссылка на запуск | `actions/runs/` | +| Команды для локального импорта | шаблон в `report.md` | + +## Почему задача, а не черновой pull request + +Предпочтительным вариантом в задании был черновой pull request. Он требует +`contents: write` у запланированного процесса, то есть постоянного права записи +в репозиторий у автоматики, которая обрабатывает содержимое стороннего выпуска. +Это расширение поверхности атаки цепочки поставок ради удобства просмотра +различий. + +Выбран вариант с задачей, явно разрешённый заданием как приемлемая альтернатива, +потому что он даёт ту же полезность без этого права: + +- импортёр детерминирован и сверяет архив с неизменяемым digest релизного + ассета, поэтому локальный запуск `scripts\sync-upstream.ps1 -Version <версия>` + воспроизводит ровно то, что проверил CI; +- разбор различий доступен в артефактах запуска (`payload-diff.txt`) и в теле + задачи; +- решение о внесении изменений остаётся за человеком, как и при черновом PR. + +Переход на черновой PR потребует: `contents: write` и `pull-requests: write` у +этого процесса, отдельной ветки вида `upstream/<версия>`, принудительного +`draft: true` и запрета автослияния. Ничто в текущей оснастке этому не мешает; +это сознательно отложено как расширение прав, не дающее новой гарантии. + +## Чего автоматика не делает + +- не объединяет ничего автоматически (проверяется регрессией: + в процессе нет `gh pr merge`, `--auto`, `--admin`, `--merge`); +- не получает права записи в содержимое репозитория + (`contents: write` отсутствует, проверяется регрессией); +- не запрашивает `pull-requests: write`, которым не пользуется; +- не публикует импортированную нагрузку в ветку; +- не принимает черновики, предварительные и изменяемые выпуски upstream. + +Все сторонние actions закреплены по неизменяемым commit SHA; это проверяется +тестом `upstream_automation` для каждого `uses:`. + +## Автоматические проверки + +| Проверка | Где | +|---|---| +| Разбор дельты: добавление, удаление, изменение, переименование, закавыченные пути | тест CTest `upstream_automation` | +| Пути вне нагрузки игнорируются | `upstream_automation` | +| Счётчики стратегий «до» и «после» выводятся из дельты | `upstream_automation` | +| Отчёт содержит все обязательные поля | `upstream_automation` | +| Отчёт отклоняет черновик, предварительный и изменяемый выпуск | `upstream_automation` | +| Процесс не получает `contents: write` | `upstream_automation` | +| Процесс запрашивает `issues: write` и не запрашивает `pull-requests: write` | `upstream_automation` | +| Процесс запускает защищённый импортёр | `upstream_automation` | +| Дедупликация по маркеру присутствует | `upstream_automation` | +| Автослияние отсутствует | `upstream_automation` | +| Все сторонние actions закреплены по commit SHA | `upstream_automation` | diff --git a/scripts/upstream-delta.ps1 b/scripts/upstream-delta.ps1 new file mode 100644 index 0000000..630ecbe --- /dev/null +++ b/scripts/upstream-delta.ps1 @@ -0,0 +1,74 @@ +<# +Payload delta derived from `git status --porcelain` after an upstream import. + +Kept separate from scripts/upstream-report.ps1 so the parsing can be tested +offline against fixtures: this is the part that silently goes wrong when git +quotes a path or reports a rename. + +ASCII-only on purpose: PowerShell 5.1 reads .ps1 as ANSI without a BOM. + +Dot-source it: . "$PSScriptRoot\upstream-delta.ps1" +#> +Set-StrictMode -Version Latest + +function Get-CheburnetPayloadDelta { + <# + $PorcelainLines is the raw output of + git status --porcelain -- resources/strategies_src resources/payload + Returns the six name lists plus the resulting strategy counts. + #> + param( + # git emits blank lines in some shells; they are data to skip, not a + # parameter-binding error. + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()][AllowEmptyString()][string[]]$PorcelainLines, + [int]$StrategyCountAfter = 0 + ) + $result = [ordered]@{ + StrategiesAdded = New-Object Collections.Generic.List[string] + StrategiesRemoved = New-Object Collections.Generic.List[string] + StrategiesModified = New-Object Collections.Generic.List[string] + PayloadAdded = New-Object Collections.Generic.List[string] + PayloadRemoved = New-Object Collections.Generic.List[string] + PayloadModified = New-Object Collections.Generic.List[string] + } + foreach ($line in $PorcelainLines) { + if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) { continue } + $code = $line.Substring(0, 2) + $rest = $line.Substring(3) + # A rename is reported as "R old -> new"; the new path is what matters. + $arrow = $rest.IndexOf(' -> ', [StringComparison]::Ordinal) + if ($arrow -ge 0) { $rest = $rest.Substring($arrow + 4) } + # git quotes paths containing spaces or non-ASCII bytes. + $path = $rest.Trim() + if ($path.StartsWith('"') -and $path.EndsWith('"') -and $path.Length -ge 2) { + $path = $path.Substring(1, $path.Length - 2) + } + $path = $path.Replace('\\', '/') + if ([string]::IsNullOrWhiteSpace($path)) { continue } + $name = $path.Substring($path.LastIndexOf('/') + 1) + + $isStrategy = $path.StartsWith('resources/strategies_src/', [StringComparison]::OrdinalIgnoreCase) + $isPayload = $path.StartsWith('resources/payload/', [StringComparison]::OrdinalIgnoreCase) + if (-not $isStrategy -and -not $isPayload) { continue } + + $trimmed = $code.Trim() + $bucket = if ($trimmed -ceq 'D') { 'Removed' } + elseif ($trimmed -ceq '??' -or $trimmed -ceq 'A' -or $trimmed -ceq 'R') { 'Added' } + else { 'Modified' } + $key = if ($isStrategy) { "Strategies$bucket" } else { "Payload$bucket" } + if (-not $result[$key].Contains($name)) { $result[$key].Add($name) } + } + $after = $StrategyCountAfter + $before = $after - $result['StrategiesAdded'].Count + $result['StrategiesRemoved'].Count + return [pscustomobject]@{ + StrategiesAdded = @($result['StrategiesAdded']) + StrategiesRemoved = @($result['StrategiesRemoved']) + StrategiesModified = @($result['StrategiesModified']) + PayloadAdded = @($result['PayloadAdded']) + PayloadRemoved = @($result['PayloadRemoved']) + PayloadModified = @($result['PayloadModified']) + StrategyCountAfter = $after + StrategyCountBefore = $before + } +} diff --git a/scripts/upstream-report.ps1 b/scripts/upstream-report.ps1 new file mode 100644 index 0000000..71729a3 --- /dev/null +++ b/scripts/upstream-report.ps1 @@ -0,0 +1,232 @@ +<# +Machine-readable report about the embedded upstream payload versus the latest +immutable stable Flowseal release. + +Read-only with respect to the repository: it queries the GitHub API and reads +resources/upstream/provenance.json. Importing is a separate, explicit step +(scripts/sync-upstream.ps1). + +Emits JSON for automation and Markdown for the durable task body. Exit code is +0 whether or not an update exists; the caller branches on report.status: + + current the embedded payload already is the latest stable release + available a newer immutable stable release exists + error the upstream state could not be determined + +-ImportedRoot points at a checkout where scripts/sync-upstream.ps1 has already +run, so the report can also carry the resulting strategy/payload delta and the +validation outcome. + +ASCII-only on purpose: PowerShell 5.1 reads .ps1 as ANSI without a BOM. +#> +[CmdletBinding()] +param( + [string]$Root, + [string]$Version, + [string]$JsonOut, + [string]$MarkdownOut, + [string]$ImportedRoot, + [ValidateSet('unknown', 'passed', 'failed', 'skipped')][string]$Validation = 'unknown', + [string]$ValidationDetail = '', + [string]$RunUrl = '' +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. "$PSScriptRoot\upstream-delta.ps1" + +if ([string]::IsNullOrWhiteSpace($Root)) { $Root = Split-Path -Parent $PSScriptRoot } +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') + +$headers = @{ + 'User-Agent' = 'CHEBURNET-upstream-report/1.0' + 'Accept' = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' +} +$githubToken = [Environment]::GetEnvironmentVariable('GITHUB_TOKEN') +if (-not [string]::IsNullOrWhiteSpace($githubToken)) { + $headers['Authorization'] = "Bearer $githubToken" +} + +$provenance = Get-Content -LiteralPath (Join-Path $root 'resources\upstream\provenance.json') ` + -Raw -Encoding UTF8 | ConvertFrom-Json +$currentVersion = [string]$provenance.version + +$report = [ordered]@{ + schema = 1 + generated_at = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + provider = 'Flowseal/zapret-discord-youtube' + status = 'error' + current_version = $currentVersion + current_archive_sha256 = [string]$provenance.archive_sha256 + current_commit = [string]$provenance.upstream_commit + latest_version = '' + latest_release_url = '' + latest_archive_url = '' + latest_archive_sha256 = '' + latest_archive_size = 0 + latest_commit = '' + strategies_added = @() + strategies_removed = @() + strategies_modified = @() + payload_added = @() + payload_removed = @() + payload_modified = @() + strategy_count_before = 0 + strategy_count_after = 0 + validation = $Validation + validation_detail = $ValidationDetail + run_url = $RunUrl + error = '' +} + +try { + $api = if ([string]::IsNullOrWhiteSpace($Version)) { + 'https://api.github.com/repos/Flowseal/zapret-discord-youtube/releases/latest' + } else { + 'https://api.github.com/repos/Flowseal/zapret-discord-youtube/releases/tags/' + + [Uri]::EscapeDataString($Version) + } + $release = Invoke-RestMethod -Uri $api -Headers $headers + if ($release.draft -or $release.prerelease) { + throw 'latest release is a draft or prerelease and is not an import candidate' + } + if ($release.immutable -ne $true) { throw 'latest release is not immutable' } + $latestVersion = [string]$release.tag_name + if ($latestVersion -cnotmatch '^[0-9]+\.[0-9]+\.[0-9]+[a-z]?$') { + throw "unsupported upstream version syntax: $latestVersion" + } + $asset = @($release.assets | Where-Object name -eq "zapret-discord-youtube-$latestVersion.zip") + if ($asset.Count -ne 1) { throw 'exact upstream ZIP release asset not found' } + if ([string]$asset[0].digest -cnotmatch '^sha256:([a-f0-9]{64})$') { + throw 'release asset has no SHA-256 digest' + } + $report.latest_version = $latestVersion + $report.latest_release_url = [string]$release.html_url + $report.latest_archive_url = [string]$asset[0].browser_download_url + $report.latest_archive_sha256 = $Matches[1] + $report.latest_archive_size = [long]$asset[0].size + + $tagRef = Invoke-RestMethod -Uri ( + 'https://api.github.com/repos/Flowseal/zapret-discord-youtube/git/ref/tags/' + + [Uri]::EscapeDataString($latestVersion)) -Headers $headers + $tagObject = $tagRef.object + if ($tagObject.type -eq 'tag') { + $tagObject = (Invoke-RestMethod -Uri $tagObject.url -Headers $headers).object + } + if ($tagObject.type -ceq 'commit' -and [string]$tagObject.sha -cmatch '^[a-f0-9]{40}$') { + $report.latest_commit = [string]$tagObject.sha + } + + $report.status = if ($latestVersion -ceq $currentVersion -and + $report.latest_archive_sha256 -ceq [string]$provenance.archive_sha256) { + 'current' + } else { + 'available' + } +} catch { + $report.status = 'error' + $report.error = [string]$_.Exception.Message +} + +# Delta after an actual import, taken from git rather than guessed. +if (-not [string]::IsNullOrWhiteSpace($ImportedRoot)) { + $importedRootFull = [IO.Path]::GetFullPath($ImportedRoot).TrimEnd('\\') + Push-Location $importedRootFull + try { + $porcelain = @(& git status --porcelain -- 'resources/strategies_src' 'resources/payload') + $after = @(Get-ChildItem -LiteralPath ` + (Join-Path $importedRootFull 'resources\strategies_src') -File -Filter '*.bat').Count + $delta = Get-CheburnetPayloadDelta -PorcelainLines $porcelain -StrategyCountAfter $after + $report.strategies_added = $delta.StrategiesAdded + $report.strategies_removed = $delta.StrategiesRemoved + $report.strategies_modified = $delta.StrategiesModified + $report.payload_added = $delta.PayloadAdded + $report.payload_removed = $delta.PayloadRemoved + $report.payload_modified = $delta.PayloadModified + $report.strategy_count_after = $delta.StrategyCountAfter + $report.strategy_count_before = $delta.StrategyCountBefore + } finally { + Pop-Location + } +} + +if (-not [string]::IsNullOrWhiteSpace($JsonOut)) { + $parent = Split-Path -Parent ([IO.Path]::GetFullPath($JsonOut)) + if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + [IO.File]::WriteAllText([IO.Path]::GetFullPath($JsonOut), + ($report | ConvertTo-Json -Depth 6) + "`n", [Text.UTF8Encoding]::new($false)) +} + +function Format-NameList($Values) { + $items = @($Values) + if ($items.Count -eq 0) { return '-' } + return ($items | ForEach-Object { '`' + $_ + '`' }) -join ', ' +} + +$lines = New-Object Collections.Generic.List[string] +if ($report.status -eq 'available') { + $lines.Add("## Upstream Flowseal $($report.latest_version) is available") +} elseif ($report.status -eq 'current') { + $lines.Add('## Upstream payload is current') +} else { + $lines.Add('## Upstream check failed') +} +$lines.Add('') +$lines.Add('| Field | Value |') +$lines.Add('| --- | --- |') +$lines.Add("| Provider | ``$($report.provider)`` |") +$lines.Add("| Embedded version | ``$($report.current_version)`` |") +$lines.Add("| Latest stable version | ``$($report.latest_version)`` |") +$lines.Add("| Release | $($report.latest_release_url) |") +$lines.Add("| Archive SHA-256 | ``$($report.latest_archive_sha256)`` |") +$lines.Add("| Archive size | $($report.latest_archive_size) bytes |") +$lines.Add("| Upstream commit | ``$($report.latest_commit)`` |") +$lines.Add("| Strategies | $($report.strategy_count_before) -> $($report.strategy_count_after) |") +$lines.Add("| Validation | **$($report.validation)** |") +if (-not [string]::IsNullOrWhiteSpace($report.validation_detail)) { + $lines.Add("| Validation detail | $($report.validation_detail) |") +} +if (-not [string]::IsNullOrWhiteSpace($report.run_url)) { + $lines.Add("| Workflow run | $($report.run_url) |") +} +if (-not [string]::IsNullOrWhiteSpace($report.error)) { + $lines.Add("| Error | $($report.error) |") +} +$lines.Add('') +if ($report.status -eq 'available') { + $lines.Add('### Payload delta') + $lines.Add('') + $lines.Add('| Change | Strategies | Payload files |') + $lines.Add('| --- | --- | --- |') + $lines.Add("| Added | $(Format-NameList $report.strategies_added) | $(Format-NameList $report.payload_added) |") + $lines.Add("| Removed | $(Format-NameList $report.strategies_removed) | $(Format-NameList $report.payload_removed) |") + $lines.Add("| Modified | $(Format-NameList $report.strategies_modified) | $(Format-NameList $report.payload_modified) |") + $lines.Add('') + $lines.Add('### How to import') + $lines.Add('') + $lines.Add('The importer is deterministic and verifies the archive digest against the') + $lines.Add('immutable release asset, so a local run reproduces exactly what CI validated:') + $lines.Add('') + $lines.Add('```powershell') + $lines.Add("scripts\sync-upstream.ps1 -Version $($report.latest_version)") + $lines.Add('scripts\build-release.ps1 -BuildDir build-upstream') + $lines.Add('scripts\run-tests.ps1 -BuildDir build-upstream') + $lines.Add('```') + $lines.Add('') + $lines.Add('Nothing is merged automatically. Review the payload delta before committing.') +} +$markdown = ($lines -join "`n") + "`n" + +if (-not [string]::IsNullOrWhiteSpace($MarkdownOut)) { + $parent = Split-Path -Parent ([IO.Path]::GetFullPath($MarkdownOut)) + if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + [IO.File]::WriteAllText([IO.Path]::GetFullPath($MarkdownOut), $markdown, + [Text.UTF8Encoding]::new($false)) +} + +Write-Output ("UPSTREAM_REPORT: status=$($report.status) current=$($report.current_version) " + + "latest=$($report.latest_version) validation=$($report.validation)") diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9ddaa11..54e9626 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,6 +55,13 @@ add_test(NAME release_signing -File "${CMAKE_SOURCE_DIR}/tests/release_signing.ps1" -RepositoryRoot "${CMAKE_SOURCE_DIR}") +# Новый стабильный выпуск upstream обязан превращаться в устойчивую задачу, +# без автоматического слияния и без лишних прав. +add_test(NAME upstream_automation + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_SOURCE_DIR}/tests/upstream_automation.ps1" + -RepositoryRoot "${CMAKE_SOURCE_DIR}") + # Изолированное дерево проверки импортёра обязано удовлетворять тем же # релизным шлюзам, что и сам репозиторий. add_test(NAME importer_validation_tree diff --git a/tests/upstream_automation.ps1 b/tests/upstream_automation.ps1 new file mode 100644 index 0000000..c6e91a3 --- /dev/null +++ b/tests/upstream_automation.ps1 @@ -0,0 +1,117 @@ +<# +Upstream automation regression test. + +Covers the parts that can be verified without touching the network: + * the payload-delta parser (quoted paths, renames, deletions, additions); + * the workflow contract that turns a new upstream release into one durable, + deduplicated task and never merges anything automatically; + * least-privilege permissions on the scheduled workflow. + +ASCII-only on purpose: PowerShell 5.1 reads .ps1 as ANSI without a BOM. +#> +[CmdletBinding()] +param([Parameter(Mandatory = $true)][string]$RepositoryRoot) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = [IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\') +. (Join-Path $root 'scripts\upstream-delta.ps1') + +$failures = New-Object Collections.Generic.List[string] +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { $script:failures.Add($Message) } +} +function Assert-Set($Actual, [string[]]$Expected, [string]$Message) { + $left = @($Actual) | Sort-Object + $right = @($Expected) | Sort-Object + if (($left -join '|') -cne ($right -join '|')) { + $script:failures.Add("$Message (got: $($left -join ', '))") + } +} + +# ---- delta parser ---------------------------------------------------------- +$porcelain = @( + ' D resources/payload/bin/quic_initial_dbankcloud_ru.bin', + '?? resources/payload/bin/tls_clienthello_sochi_park.bin', + ' M resources/payload/lists/list-general.txt', + ' M resources/payload/lists/list-exclude.txt', + '?? "resources/strategies_src/general (ALT13).bat"', + ' M "resources/strategies_src/general (EXP).bat"', + 'R resources/payload/bin/old_name.bin -> resources/payload/bin/new_name.bin', + ' M docs/ARCHITECTURE.md', + '', + 'xx' +) +$delta = Get-CheburnetPayloadDelta -PorcelainLines $porcelain -StrategyCountAfter 22 +Assert-Set $delta.StrategiesAdded @('general (ALT13).bat') 'ALT13 must be reported as an added strategy' +Assert-Set $delta.StrategiesModified @('general (EXP).bat') 'EXP must be reported as a modified strategy' +Assert-Set $delta.StrategiesRemoved @() 'no strategy was removed in the fixture' +Assert-Set $delta.PayloadRemoved @('quic_initial_dbankcloud_ru.bin') 'removed payload file must be reported' +Assert-Set $delta.PayloadAdded @('tls_clienthello_sochi_park.bin', 'new_name.bin') ` + 'added and renamed payload files must be reported as added' +Assert-Set $delta.PayloadModified @('list-general.txt', 'list-exclude.txt') ` + 'modified list files must be reported' +Assert-True ($delta.StrategyCountAfter -eq 22) 'strategy count after must be preserved' +Assert-True ($delta.StrategyCountBefore -eq 21) 'strategy count before must be derived from the delta' + +# Paths outside the payload must be ignored entirely. +Assert-True (@($delta.PayloadModified) -notcontains 'ARCHITECTURE.md') ` + 'unrelated repository files must not appear in the payload delta' + +$empty = Get-CheburnetPayloadDelta -PorcelainLines @() -StrategyCountAfter 22 +Assert-True (@($empty.StrategiesAdded).Count -eq 0 -and $empty.StrategyCountBefore -eq 22) ` + 'an empty delta must report no change' + +# A removal must raise the "before" count above the "after" count. +$removal = Get-CheburnetPayloadDelta -PorcelainLines @( + ' D "resources/strategies_src/general (ALT12).bat"') -StrategyCountAfter 21 +Assert-True ($removal.StrategyCountBefore -eq 22) 'a removed strategy must raise the before count' + +# ---- report script contract ------------------------------------------------ +$report = Get-Content -LiteralPath (Join-Path $root 'scripts\upstream-report.ps1') ` + -Raw -Encoding UTF8 +foreach ($field in @('current_version', 'latest_version', 'latest_release_url', + 'latest_archive_sha256', 'latest_commit', 'strategy_count_before', + 'strategy_count_after', 'validation', 'run_url')) { + Assert-True ($report -match [regex]::Escape($field)) ` + "the machine-readable report must carry the field '$field'" +} +Assert-True ($report -match "if \(\`$release\.draft -or \`$release\.prerelease\)") ` + 'the report must refuse draft and prerelease upstream releases' +Assert-True ($report -match "immutable -ne \`$true") ` + 'the report must require an immutable upstream release' + +# ---- scheduled workflow contract ------------------------------------------- +$workflow = Get-Content -LiteralPath (Join-Path $root '.github\workflows\upstream-check.yml') ` + -Raw -Encoding UTF8 +Assert-True ($workflow -match '(?m)^permissions:\s*$') 'the workflow must declare permissions' +Assert-True ($workflow -match '(?m)^\s*contents:\s*read\s*$') ` + 'the workflow default permission must be contents: read' +Assert-True ($workflow -notmatch '(?m)^\s*contents:\s*write\s*$') ` + 'the scheduled upstream workflow must never take write access to repository contents' +Assert-True ($workflow -match 'issues:\s*write') ` + 'the workflow must request issues: write to create the durable task' +Assert-True ($workflow -notmatch 'pull-requests:\s*write') ` + 'the workflow must not request pull-request write access it does not use' +Assert-True ($workflow -match 'sync-upstream\.ps1 -Version') ` + 'the workflow must run the secure importer for the candidate version' +Assert-True ($workflow -match 'upstream-report\.ps1') ` + 'the workflow must produce the machine-readable report' +Assert-True ($workflow -match 'cheburnet-upstream:') ` + 'the workflow must use a marker to deduplicate the durable task' +Assert-True ($workflow -notmatch '(?i)(gh pr merge|--auto|--admin|--merge)') ` + 'the workflow must never merge anything automatically' + +# Every third-party action must stay pinned to an immutable commit SHA. +foreach ($use in @([regex]::Matches($workflow, '(?m)uses:\s*(\S+)'))) { + $reference = $use.Groups[1].Value + if ($reference.StartsWith('./')) { continue } + Assert-True ($reference -match '@[0-9a-f]{40}$') ` + "third-party action must be pinned to a commit SHA: $reference" +} + +if ($failures.Count -ne 0) { + foreach ($failure in $failures) { Write-Output "FAIL: $failure" } + throw "UPSTREAM_AUTOMATION: FAIL ($($failures.Count))" +} +Write-Output 'UPSTREAM_AUTOMATION: PASS (delta parser, report contract, workflow policy)' From 859e5338d233075db2e0dc568ab2ee57a2263877 Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 10:52:02 +0300 Subject: [PATCH 07/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=88=D0=BB=D1=8E=D0=B7=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20=D0=B2=20=D1=87=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модульные тесты проверяют логику, но не проверяют, что на чистой машине создаётся защищённое дерево с нужными ACL, распаковывается рабочая среда, прерванное обновление не повышается автоматически и повреждённая среда откатывается. Оснастка scripts/e2e/Invoke-CheburnetE2E.ps1 использует то, что вся работа запуска выполняется в конструкторе App до появления интерфейса: сценарии управляются запуском настоящего исполняемого файла и проверкой наблюдаемого состояния, а не имитацией нажатий. Пакетный движок не запускается и драйвер WinDivert не загружается ни в одном сценарии. Сценарии: отказ запуска без прав администратора, первая установка, повторный запуск с существующей конфигурацией, прерванное обновление, откат при повреждённой активной среде и fail-closed при подложенном junction в защищённом каталоге журналов. Каждый сценарий привязан к критерию приёмки. Привилегированные сценарии изменяют машину, поэтому оснастка отказывается работать, если машина не помечена файлом-маркером как одноразовая, если сессия не элевирована или если уже запущен чужой winws.exe. Самопроверка -SelfTest не требует прав, ничего не пишет вне временного каталога и входит в CTest как e2e_harness: проверяет состав сценариев, их привязку к критериям, отказ защиты окружения и схему доказательств. scripts/e2e/New-CheburnetTestVm.ps1 воспроизводимо создаёт VM Hyper-V поколения 2 с Secure Boot и виртуальным TPM, подготовительный диск с артефактами выпуска и скрипт запуска в госте, сверяющий SHA-256 лончера с опубликованным. Выбор редакции Windows и сама установка остаются ручными осознанно: универсальный unattend.xml нельзя зафиксировать, а угадывание молча дало бы не то окружение. На машине разработки сценарий отказа запуска без прав выполнен и пройден; остальные намеренно пропущены защитой окружения. Проверка на чистых VM Windows 10 и Windows 11 не выполнена и зафиксирована как явный блокер стабильного выпуска в docs/production/E2E_RELEASE_GATE.md. Проверено: чистая сборка без предупреждений, CTest 39/39. Co-Authored-By: Claude Opus 5 --- docs/production/E2E_RELEASE_GATE.md | 146 +++++++++ scripts/e2e/Invoke-CheburnetE2E.ps1 | 453 ++++++++++++++++++++++++++++ scripts/e2e/New-CheburnetTestVm.ps1 | 134 ++++++++ scripts/upstream-delta.ps1 | 12 +- tests/CMakeLists.txt | 7 + 5 files changed, 746 insertions(+), 6 deletions(-) create mode 100644 docs/production/E2E_RELEASE_GATE.md create mode 100644 scripts/e2e/Invoke-CheburnetE2E.ps1 create mode 100644 scripts/e2e/New-CheburnetTestVm.ps1 diff --git a/docs/production/E2E_RELEASE_GATE.md b/docs/production/E2E_RELEASE_GATE.md new file mode 100644 index 0000000..3b59c79 --- /dev/null +++ b/docs/production/E2E_RELEASE_GATE.md @@ -0,0 +1,146 @@ +# Релизный шлюз: проверка в чистом окружении Windows + +Документ описывает обязательную проверку стабильного выпуска в чистом клиентском +окружении Windows 10 x64 и Windows 11 x64, её автоматизацию и то, что остаётся +ручным и почему. + +## Почему это отдельный шлюз + +Модульные тесты проверяют логику. Они не проверяют, что на чистой машине +создаётся защищённое дерево с нужными ACL, что распаковывается рабочая среда, +что прерванное обновление не повышается автоматически, что повреждённая среда +откатывается и что драйвер WinDivert вообще загружается. Для стабильного выпуска +этого недостаточно. + +GitHub-hosted runners предоставляют образы Windows Server, а не клиентские +Windows 10/11, поэтому шлюз выполняется на воспроизводимой виртуальной машине. + +## Автоматизированная часть + +`scripts/e2e/Invoke-CheburnetE2E.ps1` + +Ключевая идея: вся работа запуска — bootstrap защищённого дерева, распаковка +рабочей среды, проверка целостности, восстановление после прерванного +обновления и откат при запуске — выполняется в конструкторе `App` **до** +появления интерфейса. Поэтому сценарии управляются запуском настоящего +исполняемого файла и проверкой наблюдаемого состояния +(`%ProgramData%\CHEBURNET`, `active-runtime.json`, `config.json`, коды выхода), +а не имитацией нажатий в текстовом интерфейсе. + +Оснастка **никогда** не запускает пакетный движок и не загружает драйвер +WinDivert. + +| Сценарий | Требует | Критерий | Что проверяется | +|---|---|---|---| +| `refuses-unelevated` | — | AC-08 | Запуск без прав администратора отклоняется Windows (`requireAdministrator` в манифесте), защищённое дерево не изменяется | +| `fresh-install` | elevation | AC-08 | Пустой корень: распаковка, создание конфигурации, перечисление стратегий, запись состояния среды | +| `existing-install` | elevation | AC-08 | Повторный запуск не меняет существующую конфигурацию и текущую версию среды | +| `interrupted-pending` | elevation | AC-04, AC-08 | Состояние `pending`, оставленное прерванным обновлением, **никогда** не повышается до текущего автоматически | +| `startup-rollback` | elevation | AC-04, AC-08 | Повреждённая активная среда либо откатывается к предыдущей рабочей, либо запуск отклоняется | +| `filesystem-guards` | elevation | AC-07 | Junction, подложенный в защищённый каталог журналов, приводит к fail-closed: ни один файл не записывается через него | + +### Защита окружения + +Корень рабочей среды CHEBURNET — фиксированное защищённое расположение, поэтому +привилегированные сценарии изменяют машину, на которой выполняются. Оснастка +отказывается работать, если машина явно не помечена как одноразовая: + +```powershell +New-Item -ItemType File -Path C:\cheburnet-e2e-vm.marker +``` + +Дополнительно она отказывается работать без прав администратора и при наличии +уже запущенного `winws.exe` — чужой процесс не трогается никогда. + +### Самопроверка оснастки + +```powershell +scripts\e2e\Invoke-CheburnetE2E.ps1 -SelfTest +``` + +Не требует прав, ничего не пишет вне временного каталога и входит в набор CTest +как тест `e2e_harness`. Проверяет состав сценариев, привязку каждого сценария к +критерию приёмки, отказ защиты окружения на непомеченной машине и схему записей +доказательств. + +## Воспроизводимая виртуальная машина + +```powershell +scripts\e2e\New-CheburnetTestVm.ps1 -Edition win11 ` + -IsoPath D:\iso\Win11_x64.iso -ReleaseDir C:\Projects\cheburnet\dist +``` + +Автоматизировано: VM Generation 2 с Secure Boot и виртуальным TPM по +зафиксированной спецификации, подготовительный VHDX с артефактами выпуска, +скриптами и маркером одноразовой машины, а также скрипт `Run-InGuest.ps1`, +который внутри гостя сверяет SHA-256 лончера с опубликованным и запускает набор. + +Остаётся ручным: выбор редакции и индекса образа внутри ISO и сама установка +Windows. Универсальный `unattend.xml` с редакцией и ключом продукта нельзя +зафиксировать в репозитории, а угадывание молча дало бы не то окружение. + +Порядок: + +1. `Start-VM` и установка Windows с ISO (редакция выбирается осознанно: нужен + клиентский Windows 10 x64 или Windows 11 x64, не Windows Server); +2. `Checkpoint-VM -SnapshotName 'clean-after-setup'`; +3. в госте из элевированного PowerShell на томе `CHEBURNET-E2E`: + `.\scripts\e2e\Run-InGuest.ps1`; +4. вынести `C:\cheburnet-e2e-results\*\e2e-report.json` и приложить к + `docs/production/FINAL_RELEASE_CERTIFICATION.md`; +5. перед повторным запуском — `Restore-VMSnapshot -Name 'clean-after-setup'`. + +## Ручная часть шлюза + +Эти сценарии требуют реальной загрузки драйвера WinDivert и взаимодействия с +интерфейсом, поэтому выполняются человеком в госте после автоматизированного +набора. Каждый пункт фиксируется в сертификации выпуска. + +| Проверка | Ожидаемый результат | +|---|---| +| Подключение (`Подключиться`) | `winws.exe` запущен, запись процесса создана, состояние «ПОДКЛЮЧЕНО (pid …)» | +| Идентичность процесса | PID, время создания и путь образа совпадают с записью; путь внутри корня рабочей среды | +| Отключение | процесс завершён подтверждённо, запись удалена | +| Повторный запуск после подключения | существующее соединение распознано как своё | +| Выход с сохранением соединения | `winws.exe` продолжает работать по политике объекта заданий | +| Обновление нагрузки | проверенный манифест → скачивание → активация → фиксация состояния | +| Откат обновления | сбой кандидата восстанавливает предыдущую рабочую версию | +| Посторонний `winws.exe` | конфликт сообщается, чужой процесс **не** завершается | + +См. также `docs/SMOKE_TEST.md`. + +## Состав доказательства + +`e2e-report.json`: + +```json +{ + "schema": 1, + "generated_at": "…Z", + "launcher": "…\CHEBURNET.exe", + "launcher_version": "1.0.0.0", + "machine": { "os": "…", "build": "…", "architecture": "AMD64" }, + "environment_refusals": [], + "results": [ { "scenario": "…", "outcome": "passed|failed|skipped", + "detail": "…", "evidence": [ "…" ], "at": "…Z" } ], + "passed": 0, "failed": 0, "skipped": 0 +} +``` + +Стабильный выпуск не может считаться готовым, если по какому-либо обязательному +сценарию нет записи `passed` на обеих целевых ОС. + +## Текущее состояние + +| Окружение | Состояние | +|---|---| +| Самопроверка оснастки | **PASS** (тест CTest `e2e_harness`) | +| `refuses-unelevated` на машине разработки (Windows 11 Pro 26200) | **PASS** — запуск без прав отклонён Windows, защищённое дерево не изменено | +| Привилегированные сценарии на машине разработки | **SKIPPED** — машина не помечена как одноразовая и сессия не элевирована; это намеренный отказ защиты, а не сбой | +| Чистая VM Windows 11 x64 | **НЕ ВЫПОЛНЕНО** | +| Чистая VM Windows 10 x64 | **НЕ ВЫПОЛНЕНО** | +| Ручная часть (подключение, отключение, обновление, откат) | **НЕ ВЫПОЛНЕНО** | + +Это явный блокер стабильного выпуска: запускать привилегированные сценарии на +рабочей машине владельца репозитория недопустимо, а виртуальная машина с +установленной Windows в данном окружении отсутствует. diff --git a/scripts/e2e/Invoke-CheburnetE2E.ps1 b/scripts/e2e/Invoke-CheburnetE2E.ps1 new file mode 100644 index 0000000..f6b761f --- /dev/null +++ b/scripts/e2e/Invoke-CheburnetE2E.ps1 @@ -0,0 +1,453 @@ +<# +CHEBURNET release validation suite for a clean Windows environment. + +WHAT IT DOES + +The launcher performs protected-tree bootstrap, runtime extraction, integrity +verification, interrupted-pending recovery and startup rollback in App's +constructor -- before any user interface exists. The suite therefore drives real +scenarios by starting the real executable and asserting on observable state +(%ProgramData%\CHEBURNET contents, ACLs, active-runtime.json, config.json, +exit codes) instead of scripting a text user interface. + +It never starts the packet engine and never loads the WinDivert driver: connect, +disconnect and relaunch remain the manual part of the gate, described in +docs/production/E2E_RELEASE_GATE.md. + +SAFETY + +CHEBURNET's runtime root is a fixed protected location, so this suite modifies +the machine it runs on. It refuses to run unless the machine is explicitly +marked as a disposable test environment: + + New-Item -ItemType File -Path C:\cheburnet-e2e-vm.marker + +Use -SelfTest to validate the harness itself. Self-test needs no privileges, +touches nothing outside its own temporary directory, and is what CI runs. + +ASCII-only on purpose: PowerShell 5.1 reads .ps1 as ANSI without a BOM. +#> +[CmdletBinding()] +param( + [string]$Launcher, + [string]$ResultsDir, + [ValidateSet('all', 'refuses-unelevated', 'fresh-install', 'existing-install', + 'interrupted-pending', 'startup-rollback', 'filesystem-guards')] + [string[]]$Scenario = @('all'), + [string]$MarkerPath = 'C:\cheburnet-e2e-vm.marker', + [switch]$SelfTest +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$script:RuntimeRoot = Join-Path $env:ProgramData 'CHEBURNET' + +# --------------------------------------------------------------------------- +# Scenario registry. Declared as data so the self-test can verify coverage of +# the acceptance criteria without executing anything privileged. +# --------------------------------------------------------------------------- +$script:Scenarios = @( + [pscustomobject]@{ + Name = 'refuses-unelevated' + Requires = 'none' + Criterion = 'AC-08 fresh install' + Description = 'An unelevated launch is refused by Windows and changes nothing.' + }, + [pscustomobject]@{ + Name = 'fresh-install' + Requires = 'elevation' + Criterion = 'AC-08 fresh install' + Description = 'No existing runtime root: extraction, config creation, strategy enumeration and protected ACLs.' + }, + [pscustomobject]@{ + Name = 'existing-install' + Requires = 'elevation' + Criterion = 'AC-08 relaunch' + Description = 'Existing config and runtime survive a relaunch unchanged.' + }, + [pscustomobject]@{ + Name = 'interrupted-pending' + Requires = 'elevation' + Criterion = 'AC-04 / AC-08 interrupted pending recovery' + Description = 'A pending runtime left by an interrupted update is never promoted automatically.' + }, + [pscustomobject]@{ + Name = 'startup-rollback' + Requires = 'elevation' + Criterion = 'AC-04 / AC-08 rollback' + Description = 'A corrupted active runtime is rolled back to the previous known-good version.' + }, + [pscustomobject]@{ + Name = 'filesystem-guards' + Requires = 'elevation' + Criterion = 'AC-07 filesystem safety' + Description = 'A reparse point planted in the protected tree makes startup fail closed.' + } +) + +function Get-CheburnetE2EScenarios { return $script:Scenarios } + +function Test-CheburnetE2EEnvironment { + <# + Returns the reasons this machine must not run the privileged suite. + An empty list means the suite may run. + #> + param([string]$Marker = $script:MarkerPathValue) + $reasons = New-Object Collections.Generic.List[string] + if (-not (Test-Path -LiteralPath $Marker -PathType Leaf)) { + $reasons.Add("machine is not marked as a disposable test environment ($Marker is missing)") + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $reasons.Add('the privileged scenarios require an elevated session') + } + $foreign = @(Get-Process -Name 'winws' -ErrorAction SilentlyContinue) + if ($foreign.Count -ne 0) { + $reasons.Add("a winws.exe process is already running (pid $($foreign[0].Id)); refusing to disturb it") + } + return $reasons.ToArray() +} + +function New-CheburnetE2EResult { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][ValidateSet('passed', 'failed', 'skipped')][string]$Outcome, + [string]$Detail = '', + [string[]]$Evidence = @() + ) + # .ToArray()/explicit array rather than @(...): PowerShell 5.1 throws + # 'Argument types do not match' when @() wraps an EMPTY generic List. + return [pscustomobject]@{ + scenario = $Name + outcome = $Outcome + detail = $Detail + evidence = [string[]]$Evidence + at = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + } +} + +function Start-CheburnetAndSettle { + <# + Starts the launcher, waits until its startup work is observable, then stops + it. Returns the observed exit behaviour. The launcher is a text UI that + waits for input, so a settled startup is detected by the state it writes. + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [int]$TimeoutSeconds = 60 + ) + $process = Start-Process -FilePath $Path -ArgumentList '--no-animation', '--ascii-only' ` + -PassThru -WindowStyle Minimized + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $settled = $false + while ([DateTime]::UtcNow -lt $deadline) { + if ($process.HasExited) { break } + if ((Test-Path -LiteralPath (Join-Path $script:RuntimeRoot 'config.json')) -and + (Test-Path -LiteralPath (Join-Path $script:RuntimeRoot 'runtime'))) { + $settled = $true + break + } + Start-Sleep -Milliseconds 250 + } + $exitCode = $null + if ($process.HasExited) { + $exitCode = $process.ExitCode + } else { + # Stopping the UI is not part of any scenario's assertion: the startup + # work under test has already completed by this point. + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + $process.WaitForExit(10000) | Out-Null + } + return [pscustomobject]@{ Settled = $settled; ExitCode = $exitCode } +} + +function Read-CheburnetRuntimeState { + $path = Join-Path $script:RuntimeRoot 'runtime\active-runtime.json' + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } + return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json +} + +# --------------------------------------------------------------------------- +# Self-test: validates the harness, not the product. No privileges, no writes +# outside a temporary directory. +# --------------------------------------------------------------------------- +if ($SelfTest) { + $failures = New-Object Collections.Generic.List[string] + function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { $script:failures.Add($Message) } + } + + $scenarios = Get-CheburnetE2EScenarios + Assert-True ($scenarios.Count -ge 6) 'the suite must declare at least six scenarios' + foreach ($required in @('refuses-unelevated', 'fresh-install', 'existing-install', + 'interrupted-pending', 'startup-rollback', 'filesystem-guards')) { + Assert-True (@($scenarios.Name) -ccontains $required) "scenario '$required' must be declared" + } + foreach ($item in $scenarios) { + Assert-True (-not [string]::IsNullOrWhiteSpace($item.Criterion)) ` + "scenario '$($item.Name)' must name the acceptance criterion it covers" + Assert-True (-not [string]::IsNullOrWhiteSpace($item.Description)) ` + "scenario '$($item.Name)' must carry a description" + } + + # The environment guard must refuse a machine that is not marked disposable. + $absentMarker = Join-Path ([IO.Path]::GetTempPath()) ('absent-' + [guid]::NewGuid().ToString('N')) + $reasons = @(Test-CheburnetE2EEnvironment -Marker $absentMarker) + Assert-True ($reasons.Count -gt 0) 'an unmarked machine must be refused' + Assert-True (($reasons -join ' ') -match 'disposable test environment') ` + 'the refusal must name the missing disposable-environment marker' + + # The result records must be shaped the way the evidence report expects. + $sample = New-CheburnetE2EResult -Name 'fresh-install' -Outcome 'passed' -Detail 'x' ` + -Evidence @('a', 'b') + foreach ($field in @('scenario', 'outcome', 'detail', 'evidence', 'at')) { + Assert-True ($null -ne $sample.PSObject.Properties[$field]) ` + "the result record must carry the field '$field'" + } + Assert-True (@($sample.evidence).Count -eq 2) 'evidence must survive as a collection' + $threw = $false + try { New-CheburnetE2EResult -Name 'x' -Outcome 'maybe' | Out-Null } catch { $threw = $true } + Assert-True $threw 'an unknown outcome must be rejected' + + if ($failures.Count -ne 0) { + foreach ($failure in $failures) { Write-Output "FAIL: $failure" } + throw "E2E_HARNESS: FAIL ($($failures.Count))" + } + Write-Output ("E2E_HARNESS: PASS ($($scenarios.Count) scenarios declared, environment guard " + + 'and evidence schema verified)') + exit 0 +} + +# --------------------------------------------------------------------------- +# Privileged run. +# --------------------------------------------------------------------------- +$script:MarkerPathValue = $MarkerPath +if ([string]::IsNullOrWhiteSpace($Launcher)) { + throw 'the privileged suite requires -Launcher pointing at the CHEBURNET.exe under test' +} +$launcherPath = [IO.Path]::GetFullPath($Launcher) +if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { + throw "launcher not found: $launcherPath" +} +if ([string]::IsNullOrWhiteSpace($ResultsDir)) { + $ResultsDir = Join-Path ([IO.Path]::GetTempPath()) 'cheburnet-e2e' +} +$results = Join-Path ([IO.Path]::GetFullPath($ResultsDir)) ([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')) +New-Item -ItemType Directory -Path $results -Force | Out-Null + +$selected = if (@($Scenario) -ccontains 'all') { @($script:Scenarios.Name) } else { @($Scenario) } +$records = New-Object Collections.Generic.List[object] + +# The unelevated refusal is the only scenario that must run WITHOUT elevation, +# so it is evaluated against the environment guard rather than gated by it. +$environmentReasons = @(Test-CheburnetE2EEnvironment -Marker $MarkerPath) +$elevated = ($environmentReasons -join ' ') -notmatch 'elevated session' + +foreach ($name in $selected) { + $definition = @($script:Scenarios | Where-Object Name -ceq $name) + if ($definition.Count -ne 1) { throw "unknown scenario: $name" } + if ($definition[0].Requires -ceq 'elevation' -and $environmentReasons.Count -ne 0) { + $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` + -Detail ("environment refused: " + ($environmentReasons -join '; ')))) + continue + } + + switch ($name) { + 'refuses-unelevated' { + if ($elevated) { + $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` + -Detail 'session is elevated; run this scenario from a standard user session')) + break + } + $before = if (Test-Path -LiteralPath $script:RuntimeRoot) { + (Get-Item -LiteralPath $script:RuntimeRoot).LastWriteTimeUtc + } else { $null } + $refused = $false + $detail = '' + try { + $process = New-Object Diagnostics.Process + $process.StartInfo.FileName = $launcherPath + $process.StartInfo.Arguments = '--version' + $process.StartInfo.UseShellExecute = $false + $process.Start() | Out-Null + $process.WaitForExit(15000) | Out-Null + $detail = "launcher started without elevation (exit $($process.ExitCode))" + } catch { + # ERROR_ELEVATION_REQUIRED (740): the embedded manifest asks for + # requireAdministrator, so CreateProcess refuses outright. + $refused = $true + $detail = [string]$_.Exception.Message + } + $after = if (Test-Path -LiteralPath $script:RuntimeRoot) { + (Get-Item -LiteralPath $script:RuntimeRoot).LastWriteTimeUtc + } else { $null } + $unchanged = ($before -eq $after) + $records.Add((New-CheburnetE2EResult -Name $name ` + -Outcome $(if ($refused -and $unchanged) { 'passed' } else { 'failed' }) ` + -Detail $detail -Evidence @("runtime root unchanged: $unchanged"))) + } + + 'fresh-install' { + if (Test-Path -LiteralPath $script:RuntimeRoot) { + Remove-Item -LiteralPath $script:RuntimeRoot -Recurse -Force + } + $run = Start-CheburnetAndSettle -Path $launcherPath + $state = Read-CheburnetRuntimeState + $strategyCatalogue = Join-Path $script:RuntimeRoot 'runtime' + $evidence = @( + "settled: $($run.Settled)", + "config.json: $(Test-Path -LiteralPath (Join-Path $script:RuntimeRoot 'config.json'))", + "runtime dir: $(Test-Path -LiteralPath $strategyCatalogue)", + "active-runtime.json current: $(if ($state) { $state.current } else { '(absent)' })" + ) + $ok = $run.Settled -and $null -ne $state -and + -not [string]::IsNullOrWhiteSpace([string]$state.current) + $records.Add((New-CheburnetE2EResult -Name $name ` + -Outcome $(if ($ok) { 'passed' } else { 'failed' }) ` + -Detail 'first launch on an empty runtime root' -Evidence $evidence)) + } + + 'existing-install' { + $configPath = Join-Path $script:RuntimeRoot 'config.json' + if (-not (Test-Path -LiteralPath $configPath)) { + $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` + -Detail 'run fresh-install first')) + break + } + $configBefore = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8 + $stateBefore = Read-CheburnetRuntimeState + $run = Start-CheburnetAndSettle -Path $launcherPath + $configAfter = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8 + $stateAfter = Read-CheburnetRuntimeState + $ok = $run.Settled -and ($configBefore -ceq $configAfter) -and + ($null -ne $stateAfter) -and + ([string]$stateBefore.current -ceq [string]$stateAfter.current) + $records.Add((New-CheburnetE2EResult -Name $name ` + -Outcome $(if ($ok) { 'passed' } else { 'failed' }) ` + -Detail 'relaunch with an existing config and runtime' ` + -Evidence @("config unchanged: $($configBefore -ceq $configAfter)", + "current runtime unchanged: $([string]$stateBefore.current -ceq [string]$stateAfter.current)"))) + } + + 'interrupted-pending' { + $statePath = Join-Path $script:RuntimeRoot 'runtime\active-runtime.json' + if (-not (Test-Path -LiteralPath $statePath)) { + $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` + -Detail 'run fresh-install first')) + break + } + $original = Get-Content -LiteralPath $statePath -Raw -Encoding UTF8 + $state = $original | ConvertFrom-Json + $pendingVersion = '99.99.99' + $seeded = [ordered]@{ + schema = 1 + current = [string]$state.current + previous_known_good = '' + pending = $pendingVersion + last_result = 'e2e-seeded-interruption' + } + [IO.File]::WriteAllText($statePath, ($seeded | ConvertTo-Json) + "`n", + [Text.UTF8Encoding]::new($false)) + $run = Start-CheburnetAndSettle -Path $launcherPath + $after = Read-CheburnetRuntimeState + $promoted = ($null -ne $after) -and ([string]$after.current -ceq $pendingVersion) + $cleared = ($null -ne $after) -and [string]::IsNullOrEmpty([string]$after.pending) + $ok = (-not $promoted) -and ($cleared -or -not $run.Settled) + $records.Add((New-CheburnetE2EResult -Name $name ` + -Outcome $(if ($ok) { 'passed' } else { 'failed' }) ` + -Detail 'pending runtime left by an interrupted update' ` + -Evidence @("pending promoted to current: $promoted", + "pending cleared: $cleared", + "last_result: $(if ($after) { $after.last_result } else { '(absent)' })"))) + } + + 'startup-rollback' { + $state = Read-CheburnetRuntimeState + if ($null -eq $state) { + $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` + -Detail 'run fresh-install first')) + break + } + # A corrupted active runtime must not be used. Without a previous + # known-good version the launcher must refuse to start at all. + $versionDir = Join-Path $script:RuntimeRoot ('runtime\' + [string]$state.current) + $victim = @(Get-ChildItem -LiteralPath $versionDir -Recurse -File -Filter '*.txt' | + Select-Object -First 1) + if ($victim.Count -ne 1) { + $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` + -Detail "no list file found under $versionDir to corrupt")) + break + } + Add-Content -LiteralPath $victim[0].FullName -Value 'e2e-corruption' + $run = Start-CheburnetAndSettle -Path $launcherPath -TimeoutSeconds 30 + $after = Read-CheburnetRuntimeState + $refusedOrRolledBack = (-not $run.Settled) -or + (($null -ne $after) -and ([string]$after.last_result -cmatch 'rollback')) + $records.Add((New-CheburnetE2EResult -Name $name ` + -Outcome $(if ($refusedOrRolledBack) { 'passed' } else { 'failed' }) ` + -Detail 'corrupted active runtime' ` + -Evidence @("settled: $($run.Settled)", + "last_result: $(if ($after) { $after.last_result } else { '(absent)' })", + "corrupted file: $($victim[0].FullName)"))) + } + + 'filesystem-guards' { + $logsDir = Join-Path $script:RuntimeRoot 'logs' + $decoy = Join-Path ([IO.Path]::GetTempPath()) ('cheburnet-e2e-decoy-' + + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $decoy -Force | Out-Null + $planted = $false + try { + if (Test-Path -LiteralPath $logsDir) { + Remove-Item -LiteralPath $logsDir -Recurse -Force + } + New-Item -ItemType Junction -Path $logsDir -Target $decoy | Out-Null + $planted = $true + } catch { + $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` + -Detail "could not plant a reparse point: $($_.Exception.Message)")) + break + } + $run = Start-CheburnetAndSettle -Path $launcherPath -TimeoutSeconds 30 + $escaped = @(Get-ChildItem -LiteralPath $decoy -Recurse -File -ErrorAction SilentlyContinue).Count + $ok = (-not $run.Settled) -and ($escaped -eq 0) + $records.Add((New-CheburnetE2EResult -Name $name ` + -Outcome $(if ($ok) { 'passed' } else { 'failed' }) ` + -Detail 'junction planted at the protected logs directory' ` + -Evidence @("planted: $planted", "startup settled: $($run.Settled)", + "files written through the junction: $escaped"))) + if (Test-Path -LiteralPath $logsDir) { + (Get-Item -LiteralPath $logsDir -Force).Delete() + } + Remove-Item -LiteralPath $decoy -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +$summary = [ordered]@{ + schema = 1 + generated_at = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + launcher = $launcherPath + launcher_version = [string]([Diagnostics.FileVersionInfo]::GetVersionInfo($launcherPath).FileVersion) + machine = [ordered]@{ + os = [string](Get-CimInstance Win32_OperatingSystem).Caption + build = [string](Get-CimInstance Win32_OperatingSystem).BuildNumber + architecture = [string]$env:PROCESSOR_ARCHITECTURE + } + environment_refusals = [string[]]$environmentReasons + results = $records.ToArray() + passed = @($records | Where-Object outcome -ceq 'passed').Count + failed = @($records | Where-Object outcome -ceq 'failed').Count + skipped = @($records | Where-Object outcome -ceq 'skipped').Count +} +$summaryPath = Join-Path $results 'e2e-report.json' +[IO.File]::WriteAllText($summaryPath, ($summary | ConvertTo-Json -Depth 8) + "`n", + [Text.UTF8Encoding]::new($false)) + +foreach ($record in $records) { + Write-Output ("[{0,-7}] {1} -- {2}" -f $record.outcome, $record.scenario, $record.detail) +} +Write-Output ("E2E: passed=$($summary.passed) failed=$($summary.failed) " + + "skipped=$($summary.skipped) report=$summaryPath") +if ($summary.failed -ne 0) { exit 1 } diff --git a/scripts/e2e/New-CheburnetTestVm.ps1 b/scripts/e2e/New-CheburnetTestVm.ps1 new file mode 100644 index 0000000..807eb6e --- /dev/null +++ b/scripts/e2e/New-CheburnetTestVm.ps1 @@ -0,0 +1,134 @@ +<# +Reproducible Hyper-V test environment for the CHEBURNET release gate. + +GitHub-hosted runners provide Windows Server images, not Windows 10 / Windows 11 +client, and the release gate needs a clean client environment where the +WinDivert driver can actually load. This script provisions that environment +reproducibly so the manual gate is a checklist, not improvisation. + +What it automates: + * a Generation 2 VM with Secure Boot, TPM and a fixed, documented spec; + * a provisioning VHDX carrying the release artifacts, the repository test + scripts and the disposable-environment marker; + * a checkpoint taken immediately after Windows setup, so every scenario can + start from a genuinely clean state. + +What stays manual, and why: choosing the Windows edition and image index inside +the ISO, and the out-of-box setup itself. An unattend.xml that picks an edition +and a product key cannot be committed generically, and guessing it would +silently produce the wrong environment. + +Run elevated on a Hyper-V host. + +ASCII-only on purpose: PowerShell 5.1 reads .ps1 as ANSI without a BOM. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidateSet('win10', 'win11')][string]$Edition, + [Parameter(Mandatory = $true)][string]$IsoPath, + [Parameter(Mandatory = $true)][string]$ReleaseDir, + [string]$VmName, + [string]$VmRoot = 'C:\CheburnetE2E', + [string]$SwitchName = 'Default Switch', + [int64]$MemoryBytes = 6GB, + [int]$ProcessorCount = 4, + [int64]$DiskBytes = 80GB +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'provisioning a Hyper-V virtual machine requires an elevated session' +} +if (-not (Get-Command Get-VM -ErrorAction SilentlyContinue)) { + throw 'the Hyper-V PowerShell module is not available on this host' +} +$iso = [IO.Path]::GetFullPath($IsoPath) +if (-not (Test-Path -LiteralPath $iso -PathType Leaf)) { throw "ISO not found: $iso" } +$release = [IO.Path]::GetFullPath($ReleaseDir) +foreach ($required in @('CHEBURNET.exe', 'CHEBURNET.exe.sha256')) { + if (-not (Test-Path -LiteralPath (Join-Path $release $required) -PathType Leaf)) { + throw "release directory is missing $required : $release" + } +} +if ([string]::IsNullOrWhiteSpace($VmName)) { + $VmName = "CHEBURNET-E2E-$Edition" +} +if (Get-VM -Name $VmName -ErrorAction SilentlyContinue) { + throw "virtual machine already exists: $VmName (remove it or pass -VmName)" +} + +$vmDir = Join-Path $VmRoot $VmName +New-Item -ItemType Directory -Path $vmDir -Force | Out-Null +$systemDisk = Join-Path $vmDir 'system.vhdx' +$provisioningDisk = Join-Path $vmDir 'provisioning.vhdx' + +Write-Host "== Provisioning disk ==" -ForegroundColor Cyan +New-VHD -Path $provisioningDisk -SizeBytes 2GB -Dynamic | Out-Null +$mounted = Mount-VHD -Path $provisioningDisk -PassThru | Initialize-Disk -PassThru | + New-Partition -AssignDriveLetter -UseMaximumSize +$volume = $mounted | Format-Volume -FileSystem NTFS -NewFileSystemLabel 'CHEBURNET-E2E' -Confirm:$false +$stage = "$($volume.DriveLetter):\" +try { + New-Item -ItemType Directory -Path (Join-Path $stage 'release') -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $release '*') -Destination (Join-Path $stage 'release') -Recurse + New-Item -ItemType Directory -Path (Join-Path $stage 'scripts') -Force | Out-Null + Copy-Item -LiteralPath $PSScriptRoot -Destination (Join-Path $stage 'scripts\e2e') -Recurse + + # Bootstrap run inside the guest. It marks the guest as disposable and runs + # the suite; it never reaches back out to the host. + $bootstrap = @' +# Run elevated inside the guest. +$ErrorActionPreference = 'Stop' +$stage = Split-Path -Parent $PSScriptRoot +New-Item -ItemType File -Path 'C:\cheburnet-e2e-vm.marker' -Force | Out-Null +$launcher = Join-Path $stage 'release\CHEBURNET.exe' +$expected = (Get-Content (Join-Path $stage 'release\CHEBURNET.exe.sha256') -Raw).Split(' ')[0].Trim() +$actual = (Get-FileHash -LiteralPath $launcher -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -cne $expected) { throw "launcher SHA-256 mismatch: $actual" } +Write-Host "launcher SHA-256 verified: $actual" -ForegroundColor Green +& (Join-Path $PSScriptRoot 'Invoke-CheburnetE2E.ps1') -Launcher $launcher ` + -ResultsDir 'C:\cheburnet-e2e-results' -Scenario all +'@ + [IO.File]::WriteAllText((Join-Path $stage 'scripts\e2e\Run-InGuest.ps1'), $bootstrap, + [Text.UTF8Encoding]::new($false)) +} finally { + Dismount-VHD -Path $provisioningDisk +} + +Write-Host "== Virtual machine ==" -ForegroundColor Cyan +New-VHD -Path $systemDisk -SizeBytes $DiskBytes -Dynamic | Out-Null +$vm = New-VM -Name $VmName -Generation 2 -MemoryStartupBytes $MemoryBytes ` + -VHDPath $systemDisk -SwitchName $SwitchName -Path $vmDir +Set-VM -VM $vm -ProcessorCount $ProcessorCount -AutomaticCheckpointsEnabled $false +Add-VMDvdDrive -VM $vm -Path $iso +Add-VMHardDiskDrive -VM $vm -Path $provisioningDisk +# Secure Boot and a virtual TPM are required for a representative Windows 11 +# environment and are harmless for Windows 10. +Set-VMFirmware -VM $vm -EnableSecureBoot On ` + -FirstBootDevice (Get-VMDvdDrive -VM $vm) +try { + Set-VMKeyProtector -VM $vm -NewLocalKeyProtector + Enable-VMTPM -VM $vm +} catch { + Write-Warning "could not enable the virtual TPM: $($_.Exception.Message)" +} + +Write-Host '' +Write-Host "Virtual machine created: $VmName" -ForegroundColor Green +Write-Host " system disk : $systemDisk" +Write-Host " provisioning disk : $provisioningDisk (label CHEBURNET-E2E)" +Write-Host '' +Write-Host 'Remaining manual steps:' -ForegroundColor Yellow +Write-Host " 1. Start-VM -Name $VmName and complete Windows setup from the ISO." +Write-Host ' Choose the edition deliberately: the gate requires Windows 10 x64' +Write-Host ' or Windows 11 x64 client, not Windows Server.' +Write-Host " 2. Checkpoint-VM -Name $VmName -SnapshotName 'clean-after-setup'" +Write-Host ' 3. Inside the guest, open an elevated PowerShell on the CHEBURNET-E2E' +Write-Host ' volume and run: .\scripts\e2e\Run-InGuest.ps1' +Write-Host ' 4. Copy C:\cheburnet-e2e-results\*\e2e-report.json out of the guest and' +Write-Host ' attach it to docs/production/FINAL_RELEASE_CERTIFICATION.md.' +Write-Host " 5. Restore-VMSnapshot -Name 'clean-after-setup' -VMName $VmName before" +Write-Host ' re-running the suite, so every run starts from a clean state.' diff --git a/scripts/upstream-delta.ps1 b/scripts/upstream-delta.ps1 index 630ecbe..7075a0e 100644 --- a/scripts/upstream-delta.ps1 +++ b/scripts/upstream-delta.ps1 @@ -62,12 +62,12 @@ function Get-CheburnetPayloadDelta { $after = $StrategyCountAfter $before = $after - $result['StrategiesAdded'].Count + $result['StrategiesRemoved'].Count return [pscustomobject]@{ - StrategiesAdded = @($result['StrategiesAdded']) - StrategiesRemoved = @($result['StrategiesRemoved']) - StrategiesModified = @($result['StrategiesModified']) - PayloadAdded = @($result['PayloadAdded']) - PayloadRemoved = @($result['PayloadRemoved']) - PayloadModified = @($result['PayloadModified']) + StrategiesAdded = $result['StrategiesAdded'].ToArray() + StrategiesRemoved = $result['StrategiesRemoved'].ToArray() + StrategiesModified = $result['StrategiesModified'].ToArray() + PayloadAdded = $result['PayloadAdded'].ToArray() + PayloadRemoved = $result['PayloadRemoved'].ToArray() + PayloadModified = $result['PayloadModified'].ToArray() StrategyCountAfter = $after StrategyCountBefore = $before } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 54e9626..92a8b0f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,6 +55,13 @@ add_test(NAME release_signing -File "${CMAKE_SOURCE_DIR}/tests/release_signing.ps1" -RepositoryRoot "${CMAKE_SOURCE_DIR}") +# Оснастка релизного шлюза E2E проверяется сама: состав сценариев, защита +# окружения и схема доказательств. Привилегированные сценарии выполняются +# только на одноразовой машине, см. docs/production/E2E_RELEASE_GATE.md. +add_test(NAME e2e_harness + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_SOURCE_DIR}/scripts/e2e/Invoke-CheburnetE2E.ps1" -SelfTest) + # Новый стабильный выпуск upstream обязан превращаться в устойчивую задачу, # без автоматического слияния и без лишних прав. add_test(NAME upstream_automation From ad18a18e48118c393ada5d67471e4455c3a2946f Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 11:04:14 +0300 Subject: [PATCH 08/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7,=20?= =?UTF-8?q?=D0=BC=D1=83=D1=82=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=84=D0=B0=D0=B7=D0=B7=D0=B8=D0=BD=D0=B3=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D1=91=D1=82=D0=BA=D1=83=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Статический анализ MSVC включается опцией CHEBURNET_ENABLE_ANALYZE и работает с /WX, поэтому новая находка останавливает сборку. Все находки первого прогона разобраны по существу, а не подавлены: C6387 в ProcessManager: размер списка атрибутов процесса брался из вызова определения размера без проверки. При неожиданном отказе размер остался бы нулевым, выделение — нулевой длины, а последующие вызовы писали бы через указатель без хранилища, причём в привилегированном пути создания дочернего процесса. Добавлена проверка диапазона и указателя, путь fail-closed. C6262 в WinHttpClient: буфер передачи 64 КиБ лежал в автоматической памяти одного кадра, а эта функция теперь исполняется ещё и в фоновом потоке проверки обновлений. Буфер перенесён в динамическую память. C6326 в Launcher и UiScreens: непустота встроенного resource manifest проверялась во время исполнения, хотя это свойство генерации. Проверка вынесена в static_assert, экран состояния показывает фактическое число записей. Подавлено ровно одно предупреждение — C28285, дефект SAL-аннотации CryptStringToBinaryA в самом wincrypt.h Windows SDK. Обоснование зафиксировано рядом с подавлением. Добавлен процесс CI static-analysis: задание MSVC /analyze и задание CodeQL (c-cpp, security-and-quality) с security-events: write только у него. SHA обоих сторонних actions сверены с GitHub API. Фаззинг: тест fuzzparsers мутирует корректные входные данные детерминированным генератором с фиксированным зерном и проверяет, что принятое значение удовлетворяет инвариантам разборщика, а не только что разборщик не упал. Цели: разбор версии, манифест обновления, строгий JSON, путь внутри пакета, имя файла в защищённой области. Зерно и число итераций переопределяются переменными окружения для длинных кампаний; выполнено 500 000 итераций на зерне по умолчанию и по 50 000 на трёх других зёрнах без находок. Фаззер нашёл ошибку в предикате самого теста: обходом пути считалось ".." в любом месте строки, тогда как обходом он является только как целый компонент. Предикат исправлен, добавлены явные враждебные формы путей. Внесение отказов: тест faultinjection проходит полную решётку точек отказа транзакционной активации и проверяет семь инвариантов, включая то, что кандидат становится текущим только при полной фиксации, что процесс не останавливается до одобрения предварительной проверкой и что откат не запускает кандидата повторно. Проверено: чистая сборка без предупреждений, сборка с /analyze без находок, CTest 41/41, strategy fidelity 88/88. Co-Authored-By: Claude Opus 5 --- .github/workflows/static-analysis.yml | 53 ++++ CMakeLists.txt | 21 ++ docs/production/SECURITY_TOOLING.md | 128 +++++++++ src/app/UiScreens.cpp | 8 +- src/core/Launcher.cpp | 7 +- src/core/ProcessManager.cpp | 15 ++ src/update/WinHttpClient.cpp | 6 +- tests/CMakeLists.txt | 2 +- tests/test_main.cpp | 356 ++++++++++++++++++++++++++ 9 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/static-analysis.yml create mode 100644 docs/production/SECURITY_TOOLING.md diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..cae5922 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,53 @@ +name: Статический анализ + +# Два независимых анализатора: встроенный в MSVC /analyze и CodeQL. +# /analyze запускается с /WX, поэтому любая новая находка в коде CHEBURNET +# останавливает задание. Подавлено ровно одно предупреждение — дефект +# SAL-аннотации в заголовке Windows SDK, обоснование в CMakeLists.txt. + +on: + push: + branches: ['main', 'claude/**'] + pull_request: + schedule: + - cron: '41 5 * * 3' + workflow_dispatch: + +permissions: + contents: read + +jobs: + msvc-analyze: + runs-on: windows-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Сборка с MSVC /analyze + shell: powershell + run: | + . scripts\vsenv.ps1 + Import-VcVars + & $CMakeExe -S . -B build-analyze -G Ninja -DCMAKE_MAKE_PROGRAM="$NinjaExe" ` + -DCMAKE_BUILD_TYPE=Release -DCHEBURNET_ENABLE_ANALYZE=ON + if ($LASTEXITCODE -ne 0) { throw 'configure failed' } + & $CMakeExe --build build-analyze + if ($LASTEXITCODE -ne 0) { throw 'статический анализ MSVC нашёл проблемы' } + + codeql: + runs-on: windows-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4 + with: + languages: c-cpp + queries: security-and-quality + - name: Сборка для CodeQL + shell: powershell + run: scripts\build-release.ps1 -BuildDir build-codeql + - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4 + with: + category: /language:c-cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3782684..a4eb6af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,27 @@ target_link_libraries(cheburnet_lib PUBLIC bcrypt crypt32 winhttp advapi32 shell # Warnings / hardening for our own code. set(CHEBURNET_WARN /W4 /WX /permissive- /utf-8) set(CHEBURNET_HARDEN /guard:cf) + +# Статический анализ MSVC. Выключен по умолчанию: он заметно замедляет сборку и +# не должен мешать обычной разработке. Включается отдельным заданием CI +# (см. .github/workflows/codeql.yml) и локально: +# cmake -S . -B build-analyze -DCHEBURNET_ENABLE_ANALYZE=ON +# Находки разбираются, а не подавляются: список отключённых предупреждений +# ведётся здесь с обоснованием каждого. +option(CHEBURNET_ENABLE_ANALYZE "Enable MSVC /analyze static analysis" OFF) +if(CHEBURNET_ENABLE_ANALYZE) + # /analyze:external- не анализирует заголовки Windows SDK: их находки не + # наши и тонут в шуме. + # + # C28285 подавляется точечно и с обоснованием: это дефект SAL-аннотации + # CryptStringToBinaryA в самом wincrypt.h Windows SDK 10.0.26100.0 + # ("синтаксическая ошибка _Param_(1)"). Он относится к чужому заголовку, + # исправить его на нашей стороне невозможно, и он не указывает ни на какое + # свойство кода CHEBURNET. Никакие другие предупреждения анализа не + # подавляются: все находки в нашем коде разобраны и исправлены. + list(APPEND CHEBURNET_WARN /analyze /analyze:external- /wd28285) + message(STATUS "CHEBURNET: статический анализ MSVC включён") +endif() target_compile_options(cheburnet_lib PRIVATE ${CHEBURNET_WARN}) target_compile_options(cheburnet_lib PUBLIC ${CHEBURNET_HARDEN} $<$:/O2 /Zi>) diff --git a/docs/production/SECURITY_TOOLING.md b/docs/production/SECURITY_TOOLING.md new file mode 100644 index 0000000..a674819 --- /dev/null +++ b/docs/production/SECURITY_TOOLING.md @@ -0,0 +1,128 @@ +# Инструменты безопасности: статический анализ, фаззинг, внесение отказов + +## Статический анализ + +### MSVC `/analyze` + +Включается отдельной опцией, по умолчанию выключен (анализ заметно замедляет +сборку и не должен мешать обычной разработке): + +```powershell +. scripts\vsenv.ps1; Import-VcVars +cmake -S . -B build-analyze -G Ninja -DCMAKE_MAKE_PROGRAM="$NinjaExe" ` + -DCMAKE_BUILD_TYPE=Release -DCHEBURNET_ENABLE_ANALYZE=ON +cmake --build build-analyze +``` + +Анализ работает **с `/WX`**, поэтому любая новая находка в коде CHEBURNET +останавливает сборку. В CI это задание `msvc-analyze` +(`.github/workflows/static-analysis.yml`). + +#### Разобранные находки + +Все находки первого прогона разобраны по существу, а не подавлены. + +| Находка | Место | Разбор | Действие | +|---|---|---|---| +| `C6387` «`attrList` может быть равен 0» | `src/core/ProcessManager.cpp` | Реальный пробел. Размер списка атрибутов брался из вызова `InitializeProcThreadAttributeList(nullptr, …)` без проверки: при неожиданном отказе размер остался бы нулевым, выделение — нулевой длины, а последующие вызовы писали бы через указатель без хранилища. Это привилегированный путь создания дочернего процесса. | Добавлена проверка диапазона размера и указателя; при отказе путь fail-closed, элевированный дочерний процесс не создаётся. | +| `C6262` «функция использует 66040 байт стека» | `src/update/WinHttpClient.cpp` | Реальный риск. Буфер передачи 64 КиБ лежал в автоматической памяти одного кадра; эта же функция теперь исполняется в фоновом потоке проверки обновлений, где запас стека не должен иметь значения. | Буфер перенесён в динамическую память. | +| `C6326` «сравнение константы с константой» ×3 | `src/core/Launcher.cpp`, `src/app/UiScreens.cpp` | Проверка «встроенный resource manifest не пуст» выполнялась во время исполнения, хотя это свойство генерации. | Проверка вынесена в `static_assert`: сборка падает сразу, а не у пользователя при запуске. Экран состояния показывает фактическое число записей. | +| `C28285` «синтаксическая ошибка `_Param_(1)`» | `wincrypt.h` Windows SDK 10.0.26100.0 | Дефект SAL-аннотации `CryptStringToBinaryA` **в чужом заголовке**. На нашей стороне не исправим, о свойствах кода CHEBURNET не говорит. | Подавлено точечно (`/wd28285`) с обоснованием в `CMakeLists.txt`. Единственное подавленное предупреждение анализа. | + +### CodeQL + +Задание `codeql` в том же процессе: `languages: c-cpp`, набор запросов +`security-and-quality`, права `security-events: write` только у этого задания. +Сборка для анализа — обычный `scripts\build-release.ps1`, поэтому CodeQL видит +ровно тот код, который попадает в выпуск. + +## Фаззинг и свойства + +libFuzzer с MSVC в существующем тулчейне не используется: он потребовал бы +отдельной сборки под clang-cl и параллельного тулчейна, что дестабилизирует +основной конвейер. Вместо этого в наборе тестов есть детерминированная +мутационная проверка свойств — тест CTest `fuzzparsers`. + +Устройство: xorshift-генератор с фиксированным зерном мутирует корректные +входные данные (замена, удаление, вставка байта, усечение, внедрение `NUL`, +вставка 4-байтового UTF-8 и недопустимого ведущего байта). Проверяемое свойство +одинаково для всех разборщиков: мутированный вход либо разобран в значение, +удовлетворяющее инвариантам самого разборщика, либо отклонён. Он никогда не +должен аварийно завершаться, зависать или быть принятым с нарушением инварианта. + +| Цель | Проверяемые инварианты принятого значения | +|---|---| +| Разбор версии (`ParseVersion`) | непустое ядро не длиннее 8 компонентов; при `prerelease` непустой набор идентификаторов; ревизия не длиннее одного символа; сравнение рефлексивно и антисимметрично | +| Манифест обновления (`ParseManifest`) | `schema == 1`, канал `stable`, SHA-256 ровно 64 символа, ненулевые размеры, все URL — HTTPS, поставщик точный, версии разбираемы, `key_id` непустой | +| Строгий JSON (`json::Parse`) | документ сверх лимита байтов никогда не принимается | +| Путь внутри пакета (`NormalizePackagePath`) | результат внутри точного allowlist, относительный, без обхода по компонентам, без `\`, `:` и `NUL` | +| Имя файла в защищённой области (`IsSafeStagingFileName`) | длина в пределах, без ведущей точки, без завершающей точки или пробела, без разделителей пути и `:` | + +Дополнительно проверяются явные враждебные формы путей: `../bin/winws.exe`, +`bin/../../evil`, `bin/./winws.exe`, `/bin/winws.exe`, `bin\winws.exe`, +`C:/bin/winws.exe`, `bin/`, пустая строка, `bin/con.exe`, `bin/nul`, +`bin/winws.exe ` (завершающий пробел), `bin/winws.exe.` (завершающая точка), +`bin/..`, `lists/../../../etc/passwd`. + +### Длинные кампании + +CI использует фиксированное зерно и 4000 итераций на цель, поэтому любой сбой +воспроизводится. Для длинной кампании без отдельного тулчейна: + +```powershell +$env:CHEBURNET_FUZZ_ITERATIONS = '2000000' +$env:CHEBURNET_FUZZ_SEED = '12345' +build\tests\cheburnet_tests.exe fuzzparsers +``` + +Выполненные кампании: 500 000 итераций на зерне по умолчанию и по 50 000 на +зёрнах `1`, `999983`, `123456789` — находок нет. + +Найденное фаззером на этапе разработки: предикат теста считал обходом путь +`..` в любом месте строки, тогда как обходом он является только как **целый** +компонент пути (`WinDivert64.s..ys` — обычное имя файла). Ошибка была в тесте, +не в продукте; предикат исправлен на покомпонентную проверку, и добавлены +явные враждебные формы выше. + +## Внесение отказов + +Транзакционная активация рабочей среды (`src/update/RuntimeActivation.cpp`) уже +построена на явных хуках для каждой границы, поэтому отказы вносятся без +изменения продукта. Тест CTest `faultinjection` проходит полную решётку: + +точки отказа × наличие предыдущей рабочей версии + +| Точка отказа | Соответствует реальному событию | +|---|---| +| `Preflight` | кандидат не прошёл предварительную проверку | +| `Stop` | доверенный текущий процесс не удалось остановить | +| `StartCandidate` | кандидат не запустился | +| `HealthCandidate` | кандидат не прошёл проверку состояния | +| `Commit` | состояние не удалось зафиксировать | +| `StartPrevious` | откат не смог запустить предыдущую версию | +| `HealthPrevious` | откат запустил предыдущую версию, но она не прошла проверку | + +Проверяемые инварианты для каждой комбинации: + +1. кандидат становится текущим **только** при полной фиксации; +2. при любом отказе остаётся или восстанавливается предыдущая рабочая версия; +3. фиксация недостижима после отказа на более ранней границе; +4. работающий процесс не останавливается, пока предварительная проверка не + одобрила кандидата; +5. откат никогда не запускает кандидата повторно; +6. зафиксированное состояние всегда помнит заменяемую версию как предыдущую + рабочую; +7. отсутствующий хук трактуется как отказ, а не как успех. + +Состояние `pending` на диске и его невозможность автоматического повышения +проверяются отдельно: тестом `updatestate` и сценарием `interrupted-pending` +релизного шлюза E2E. + +## Что осталось за рамками + +- libFuzzer/ASAN под clang-cl как параллельный тулчейн: не добавлялось, чтобы не + дестабилизировать основной конвейер MSVC; мутационная проверка свойств + покрывает те же разборщики детерминированно; +- внесение отказов в привилегированные операции файловой системы: выполняется + сценарием `filesystem-guards` релизного шлюза E2E, который требует + одноразовой машины. diff --git a/src/app/UiScreens.cpp b/src/app/UiScreens.cpp index 8a59b4a..02789a1 100644 --- a/src/app/UiScreens.cpp +++ b/src/app/UiScreens.cpp @@ -201,8 +201,12 @@ void App::ScreenBoot() { const bool mem = ::GlobalMemoryStatusEx(&ms) != 0; lines.push_back({L"КАРТА ПАМЯТИ", mem ? L"ГОТОВО" : L"ОШИБКА", mem ? ui::Severity::Ok : ui::Severity::Error}); - lines.push_back({L"ТАБЛИЦА РЕСУРСОВ", kEmbeddedResourceCount > 0 ? L"ПРОВЕРЕНА" : L"ПУСТА", - kEmbeddedResourceCount > 0 ? ui::Severity::Ok : ui::Severity::Error}); + // Непустота гарантирована static_assert в Launcher.cpp, поэтому здесь + // показывается фактическое число ресурсов, а не сравнение константы + // с константой (MSVC /analyze C6326). + lines.push_back({L"ТАБЛИЦА РЕСУРСОВ", + std::to_wstring(kEmbeddedResourceCount) + L" ЗАПИСЕЙ", + ui::Severity::Ok}); SC_HANDLE scm = ::OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); lines.push_back({L"КАНАЛ УПРАВЛЕНИЯ", scm ? L"ГОТОВ" : L"ОГРАНИЧЕН", scm ? ui::Severity::Ok : ui::Severity::Warn}); diff --git a/src/core/Launcher.cpp b/src/core/Launcher.cpp index 30eb8a3..44c2dde 100644 --- a/src/core/Launcher.cpp +++ b/src/core/Launcher.cpp @@ -220,9 +220,12 @@ ConnectResult Launcher::Connect(const RuntimeStrategy& strategy, GameFilterMode } // ---- Phase 2: PAYLOAD ---- + // Пустой встроенный resource manifest — ошибка генерации, а не состояние + // времени выполнения, поэтому проверка вынесена на этап компиляции: так + // сборка падает сразу, а не у пользователя при запуске (MSVC /analyze C6326). + static_assert(kEmbeddedResourceCount > 0, + "embedded resource manifest must not be empty"); started(5); - if (kEmbeddedResourceCount <= 0) - return fail(5, L"Пустой resource manifest.", L"Встроенные ресурсы отсутствуют."); okcp(5); started(6); ExtractionResult ex = extractor.EnsureExtracted(); diff --git a/src/core/ProcessManager.cpp b/src/core/ProcessManager.cpp index 3d90c02..461bc43 100644 --- a/src/core/ProcessManager.cpp +++ b/src/core/ProcessManager.cpp @@ -146,10 +146,25 @@ StartResult ProcessManager::Start(const std::wstring& exePath, } // Attribute list restricting inheritance to exactly the three std handles. + // The sizing call must actually produce a size: if it does not, the + // allocation below would be zero-length and every later attribute call + // would write through a pointer with no storage behind it. Fail closed + // instead of creating an elevated child with unrestricted inheritance. SIZE_T attrSize = 0; ::InitializeProcThreadAttributeList(nullptr, 1, 0, &attrSize); + if (attrSize == 0 || attrSize > 64 * 1024) { + res.win32Error = ::GetLastError(); + closeStd(); + Logger::Error(L"запуск: некорректный размер списка атрибутов процесса"); + return res; + } auto attrBuf = std::make_unique(attrSize); auto attrList = reinterpret_cast(attrBuf.get()); + if (attrList == nullptr) { + res.win32Error = ERROR_NOT_ENOUGH_MEMORY; + closeStd(); + return res; + } if (!::InitializeProcThreadAttributeList(attrList, 1, 0, &attrSize)) { res.win32Error = ::GetLastError(); closeStd(); diff --git a/src/update/WinHttpClient.cpp b/src/update/WinHttpClient.cpp index 61c8f14..2856cb9 100644 --- a/src/update/WinHttpClient.cpp +++ b/src/update/WinHttpClient.cpp @@ -160,7 +160,11 @@ HttpResult WinHttpClient::Get(std::wstring_view initialUrl, const HttpOptions& o return result; } result.body.clear(); - std::array buffer{}; + // Heap, not stack: a 64 KiB automatic buffer put ~66 KB on a single + // frame (MSVC /analyze C6262). This function also runs on the + // background update-check thread, so the transfer buffer must not + // depend on how much stack that thread happens to have. + std::vector buffer(64 * 1024, 0); std::uint64_t total = 0; for (;;) { if (Cancelled(options)) { result.status = HttpStatus::Cancelled; return result; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 92a8b0f..66d3f70 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,7 +23,7 @@ set(CHEBURNET_TEST_NAMES layout checkpoints menu effects theme framebuffer configmigration) list(APPEND CHEBURNET_TEST_NAMES jsonstrict updatemanifest updateversion updatesignature updatepackage updaterollback updatestate - updatehttp updatecheckservice operationstate) + updatehttp updatecheckservice operationstate fuzzparsers faultinjection) foreach(t ${CHEBURNET_TEST_NAMES}) add_test(NAME ${t} COMMAND cheburnet_tests ${t}) diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 387676c..37d5bdf 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -1180,6 +1183,357 @@ static int test_updatestate() { } // ---------------------------------------------------------- WinHTTP policy +// ------------------------------------------------ property / mutation fuzzing +// +// Deterministic by construction: a fixed-seed xorshift PRNG mutates known-good +// inputs, so a failure always reproduces. The property under test is uniform +// across every parser: a mutated input is either parsed into a value that +// satisfies the parser's own invariants, or rejected. It must never crash, +// hang, or be accepted while violating an invariant. +namespace { + +class Prng { +public: + explicit Prng(std::uint64_t seed) : state_(seed ? seed : 0x9E3779B97F4A7C15ull) {} + std::uint64_t Next() { + state_ ^= state_ << 13; + state_ ^= state_ >> 7; + state_ ^= state_ << 17; + return state_; + } + std::size_t Below(std::size_t bound) { + return bound ? static_cast(Next() % bound) : 0; + } + +private: + std::uint64_t state_; +}; + +// Byte-level mutations that historically break hand-written parsers. +std::string MutateBytes(std::string input, Prng& prng) { + if (input.empty()) return input; + switch (prng.Below(7)) { + case 0: input[prng.Below(input.size())] = static_cast(prng.Next() & 0xFF); break; + case 1: input.erase(prng.Below(input.size()), 1 + prng.Below(4)); break; + case 2: input.insert(prng.Below(input.size()), 1 + prng.Below(4), + static_cast(prng.Next() & 0xFF)); break; + case 3: input.resize(prng.Below(input.size() + 1)); break; // truncation + case 4: input[prng.Below(input.size())] = '\0'; break; // embedded NUL + case 5: input.insert(prng.Below(input.size()), "\xF0\x9F\x92\xA9"); break; // 4-byte UTF-8 + case 6: input.insert(prng.Below(input.size()), 1, '\xFF'); break; // invalid UTF-8 lead + default: break; + } + return input; +} + +const char* const kVersionSeeds[] = { + "1.0.0", "1.0.0-rc.3", "1.10.2", "1.10.2a", "v1.0.0", "1.0.0+build.7", + "1.0.0-rc.3+abc", "0.0.1", "9.9.9.9.9.9.9.9" +}; + +const char* const kPackagePathSeeds[] = { + "bin/winws.exe", "lists/list-general.txt", "strategies/catalog.json", + "provenance.json", "runtime-manifest.json", "bin/WinDivert64.sys" +}; + +// CI runs a fixed seed and iteration count so a failure always reproduces. +// CHEBURNET_FUZZ_SEED / CHEBURNET_FUZZ_ITERATIONS let a maintainer run a longer +// campaign without a separate toolchain: +// set CHEBURNET_FUZZ_ITERATIONS=2000000 +// set CHEBURNET_FUZZ_SEED=12345 +// build\tests\cheburnet_tests.exe fuzzparsers +unsigned long long FuzzEnv(const char* name, unsigned long long fallback) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return fallback; + unsigned long long value = 0; + const char* end = raw + std::strlen(raw); + const auto parsed = std::from_chars(raw, end, value); + if (parsed.ec != std::errc{} || parsed.ptr != end || value == 0) return fallback; + return value; +} + +} // namespace + +static int test_fuzzparsers() { + g_fail = 0; + const int kIterations = static_cast(FuzzEnv("CHEBURNET_FUZZ_ITERATIONS", 4000)); + const std::uint64_t fuzzSeed = FuzzEnv("CHEBURNET_FUZZ_SEED", 0xC4EB2026ull); + std::printf(" fuzz seed=%llu iterations=%d\n", static_cast(fuzzSeed), + kIterations); + Prng prng(fuzzSeed); + + // ---- version parser ---------------------------------------------------- + for (int i = 0; i < kIterations; ++i) { + const std::string seed = kVersionSeeds[prng.Below(std::size(kVersionSeeds))]; + const std::string mutated = MutateBytes(seed, prng); + const auto parsed = update::ParseVersion(mutated); + if (!parsed) continue; + // Accepted: the invariants the comparator relies on must hold. + if (parsed->parts.empty() || parsed->parts.size() > 8) { + ++g_fail; + std::printf(" FAIL version invariant (parts) for '%s'\n", mutated.c_str()); + continue; + } + if (parsed->prerelease && parsed->prereleaseIds.empty()) { + ++g_fail; + std::printf(" FAIL version invariant (prerelease ids) for '%s'\n", mutated.c_str()); + continue; + } + if (parsed->revision.size() > 1) { + ++g_fail; + std::printf(" FAIL version invariant (revision) for '%s'\n", mutated.c_str()); + continue; + } + // Comparison must be a strict, antisymmetric, reflexive order. + if (update::CompareVersions(*parsed, *parsed) != 0) { + ++g_fail; + std::printf(" FAIL version not reflexive for '%s'\n", mutated.c_str()); + continue; + } + const auto other = update::ParseVersion(kVersionSeeds[prng.Below(std::size(kVersionSeeds))]); + if (other) { + const int forward = update::CompareVersions(*parsed, *other); + const int reverse = update::CompareVersions(*other, *parsed); + if ((forward == 0) != (reverse == 0) || (forward < 0) != (reverse > 0)) { + ++g_fail; + std::printf(" FAIL version not antisymmetric for '%s'\n", mutated.c_str()); + } + } + } + + // ---- update manifest --------------------------------------------------- + const std::string manifestSeed = ValidManifest(); + for (int i = 0; i < kIterations; ++i) { + const std::string mutated = MutateBytes(manifestSeed, prng); + const update::ManifestResult parsed = update::ParseManifest(mutated); + if (!parsed.ok) continue; + const update::Manifest& manifest = parsed.manifest; + // Accepted manifests must still satisfy every contract the updater + // relies on before it will touch the network or the filesystem. + const bool contract = + manifest.schema == 1 && manifest.channel == "stable" && + manifest.launcher.sha256.size() == 64 && manifest.payload.sha256.size() == 64 && + manifest.launcher.size > 0 && manifest.payload.size > 0 && + update::IsHttpsUrl(manifest.launcher.url) && + update::IsHttpsUrl(manifest.payload.url) && + update::IsHttpsUrl(manifest.payload.upstreamReleaseUrl) && + manifest.payload.provider == "Flowseal/zapret-discord-youtube" && + update::ParseVersion(manifest.launcher.version).has_value() && + update::ParseVersion(manifest.payload.version).has_value() && + !manifest.keyId.empty(); + if (!contract) { + ++g_fail; + std::printf(" FAIL manifest accepted while violating its contract (iteration %d)\n", i); + } + } + + // ---- strict JSON ------------------------------------------------------- + const std::string jsonSeed = + "{\"a\":1,\"b\":[true,false,null,1.5e3],\"c\":{\"d\":\"\\u0416\"}}"; + for (int i = 0; i < kIterations; ++i) { + const std::string mutated = MutateBytes(jsonSeed, prng); + json::ParseOptions options; + options.maxBytes = 4096; + options.maxDepth = 8; + options.maxValues = 128; + const json::ParseResult parsed = json::Parse(mutated, options); + if (parsed.ok && mutated.size() > options.maxBytes) { + ++g_fail; + std::printf(" FAIL json accepted an oversized document (iteration %d)\n", i); + } + } + + // ---- package path allowlist ------------------------------------------- + for (int i = 0; i < kIterations; ++i) { + const std::string seed = kPackagePathSeeds[prng.Below(std::size(kPackagePathSeeds))]; + const std::string mutated = MutateBytes(seed, prng); + std::string normalized; + if (!update::NormalizePackagePath(mutated, normalized)) continue; + // Accepted: the normalized path must be inside the exact allowlist, + // relative and free of traversal. Note that ".." only means traversal + // as a COMPLETE component: "WinDivert64.s..ys" is an ordinary name. + const bool allowed = normalized.rfind("bin/", 0) == 0 || + normalized.rfind("lists/", 0) == 0 || + normalized == "strategies/catalog.json" || + normalized == "provenance.json" || + normalized == "runtime-manifest.json"; + bool traversal = false; + std::size_t at = 0; + while (at <= normalized.size()) { + const std::size_t slash = normalized.find('/', at); + const std::string_view component = std::string_view(normalized).substr( + at, slash == std::string::npos ? std::string_view::npos : slash - at); + if (component.empty() || component == "." || component == "..") traversal = true; + if (slash == std::string::npos) break; + at = slash + 1; + } + const bool safe = allowed && !traversal && + normalized.find('\\') == std::string::npos && + normalized.find(':') == std::string::npos && + normalized.find('\0') == std::string::npos && + !normalized.empty() && normalized.front() != '/'; + if (!safe) { + ++g_fail; + std::printf(" FAIL package path accepted unsafe normalization: '%s'\n", + normalized.c_str()); + } + } + + // Explicit traversal and device-name forms must always be rejected. + for (const char* hostile : {"../bin/winws.exe", "bin/../../evil", "bin/./winws.exe", + "/bin/winws.exe", "bin\\winws.exe", "C:/bin/winws.exe", + "bin/", "", "bin/con.exe", "bin/winws.exe ", "bin/winws.exe.", + "bin/..", "bin/nul", "lists/../../../etc/passwd"}) { + std::string normalized; + if (update::NormalizePackagePath(hostile, normalized)) { + ++g_fail; + std::printf(" FAIL package path accepted a hostile input: '%s'\n", hostile); + } + } + + // ---- staging file names ------------------------------------------------ + const wchar_t* const stagingSeeds[] = {L"CHEBURNET-new.exe", L"engine-1.10.2.cbpkg"}; + for (int i = 0; i < kIterations; ++i) { + std::wstring seed = stagingSeeds[prng.Below(std::size(stagingSeeds))]; + switch (prng.Below(5)) { + case 0: seed[prng.Below(seed.size())] = static_cast(prng.Next() & 0xFFFF); break; + case 1: seed.insert(prng.Below(seed.size()), 1, L'\\'); break; + case 2: seed.insert(prng.Below(seed.size()), 1, L'.'); break; + case 3: seed.resize(prng.Below(seed.size() + 1)); break; + case 4: seed.insert(prng.Below(seed.size() + 1), 1, L' '); break; + default: break; + } + if (!update::IsSafeStagingFileName(seed)) continue; + const bool safe = !seed.empty() && seed.size() <= 128 && seed.front() != L'.' && + seed.back() != L'.' && seed.back() != L' ' && + seed.find(L'\\') == std::wstring::npos && + seed.find(L'/') == std::wstring::npos && + seed.find(L':') == std::wstring::npos; + if (!safe) { + ++g_fail; + std::printf(" FAIL staging name accepted an unsafe value\n"); + } + } + return g_fail; +} + +// ------------------------------------------------------- fault injection grid +// +// The transactional activation already exposes hooks for every boundary. This +// walks the whole failure grid and asserts the one invariant that matters: a +// runtime is only ever "current" if the whole transaction committed, and any +// failure leaves or restores a known-good runtime. +static int test_faultinjection() { + g_fail = 0; + using update::ActivationStatus; + + enum class FailAt { None, Preflight, Stop, StartCandidate, HealthCandidate, Commit, + StartPrevious, HealthPrevious }; + const FailAt points[] = {FailAt::None, FailAt::Preflight, FailAt::Stop, + FailAt::StartCandidate, FailAt::HealthCandidate, FailAt::Commit, + FailAt::StartPrevious, FailAt::HealthPrevious}; + + for (const FailAt point : points) { + for (const bool hasPrevious : {true, false}) { + const update::RuntimeState before{"1.10.1", hasPrevious ? "1.10.0" : ""}; + std::vector calls; + bool committed = false; + update::RuntimeState committedState; + + update::ActivationHooks hooks; + hooks.preflight = [&](std::string_view version) { + calls.push_back("preflight:" + std::string(version)); + return point != FailAt::Preflight; + }; + hooks.stopCurrent = [&]() { + calls.push_back("stop"); + return point != FailAt::Stop; + }; + hooks.start = [&](std::string_view version) { + calls.push_back("start:" + std::string(version)); + if (version == "1.10.2") return point != FailAt::StartCandidate; + return point != FailAt::StartPrevious; + }; + hooks.health = [&](std::string_view version) { + calls.push_back("health:" + std::string(version)); + if (version == "1.10.2") return point != FailAt::HealthCandidate; + return point != FailAt::HealthPrevious; + }; + hooks.commit = [&](const update::RuntimeState& state) { + calls.push_back("commit"); + if (point == FailAt::Commit) return false; + committed = true; + committedState = state; + return true; + }; + + const update::ActivationResult activated = + update::ActivateRuntime(before, "1.10.2", hooks); + + const bool isActivated = activated.status == ActivationStatus::Activated; + // 1. The candidate becomes current only on a full commit. + if ((activated.state.current == "1.10.2") != isActivated) { + ++g_fail; + std::printf(" FAIL candidate became current without committing (point %d)\n", + static_cast(point)); + } + if (isActivated != committed) { + ++g_fail; + std::printf(" FAIL commit flag disagrees with status (point %d)\n", + static_cast(point)); + } + // 2. Any failure leaves or restores the previous known-good runtime. + if (!isActivated && activated.status != ActivationStatus::RollbackFailed && + activated.state.current != before.current) { + ++g_fail; + std::printf(" FAIL failure did not restore the known-good runtime (point %d)\n", + static_cast(point)); + } + // 3. Commit is never reached after a failed candidate start/health. + const bool commitCalled = + std::find(calls.begin(), calls.end(), std::string("commit")) != calls.end(); + if ((point == FailAt::StartCandidate || point == FailAt::HealthCandidate || + point == FailAt::Preflight || point == FailAt::Stop) && commitCalled) { + ++g_fail; + std::printf(" FAIL commit reached after an earlier failure (point %d)\n", + static_cast(point)); + } + // 4. Nothing is stopped before preflight approves the candidate. + if (point == FailAt::Preflight && + std::find(calls.begin(), calls.end(), std::string("stop")) != calls.end()) { + ++g_fail; + std::printf(" FAIL the running process was stopped before preflight passed\n"); + } + // 5. Rollback never starts the candidate a second time. + if (!isActivated) { + const std::size_t candidateStarts = static_cast( + std::count(calls.begin(), calls.end(), std::string("start:1.10.2"))); + if (candidateStarts > 1) { + ++g_fail; + std::printf(" FAIL rollback restarted the candidate (point %d)\n", + static_cast(point)); + } + } + // 6. Committed state always records the runtime being replaced. + if (isActivated && committedState.previousKnownGood != before.current) { + ++g_fail; + std::printf(" FAIL commit lost the previous known-good runtime\n"); + } + } + } + + // A missing hook must be treated as a failure, never as success. + { + const update::RuntimeState before{"1.10.1", "1.10.0"}; + update::ActivationHooks empty; + const update::ActivationResult activated = + update::ActivateRuntime(before, "1.10.2", empty); + CHECK(activated.status == ActivationStatus::PreflightFailed); + CHECK(activated.state.current == "1.10.1"); + } + return g_fail; +} + // ------------------------------------------ asynchronous update-check service // // Deterministic by construction: the fake check blocks on a flag the test @@ -1423,6 +1777,8 @@ int wmain(int argc, wchar_t** argv) { {L"updatestate", test_updatestate}, {L"updatehttp", test_updatehttp}, {L"updatecheckservice", test_updatecheckservice}, + {L"fuzzparsers", test_fuzzparsers}, + {L"faultinjection", test_faultinjection}, }; int failures = 0; From 4d091c7fb6ea91b2adf19d91505ec994165f2b78 Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 11:19:36 +0300 Subject: [PATCH 09/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункт меню «Экспорт диагностики» создаёт локальный архив CHEBURNET-diagnostics-ГГГГММДД-ЧЧММСС.zip в каталоге журналов. Перед записью показывается полный состав архива и точный путь, затем требуется отдельное подтверждение. Отправки нет: в коде экспорта отсутствуют сетевые вызовы, и это закреплено регрессионной проверкой по именам сетевых API. В архив входят manifest.json с описанием самого состава, summary.json с версиями, происхождением движка, состоянием рабочей среды и целостности, настройками, сборкой Windows, вердиктом подписи лончера и ожидаемыми SHA-256 файлов CHEBURNET, а также ограниченный хвост журнала. Редактирование скрывает имя пользователя, имя компьютера, путь профиля, любой путь внутри \Users\, адреса IPv4 и IPv6 и строки, похожие на токены. Хеши SHA-256 и SHA-1 сохраняются намеренно: без них диагностика теряет смысл, а о пользователе они ничего не сообщают. ZIP пишется без сжатия собственным кодом: сторонняя библиотека ради одной операции не вводится, формат детерминирован. Файл создаётся с CREATE_NEW, поэтому существующий архив никогда не перезаписывается молча, при ошибке записи частичный файл удаляется, а имена записей проверяются до записи. Тест diagnostics проверяет редактирование (включая регистронезависимость и идемпотентность), сохранение версий и хешей, CRC-32 по каноническому вектору, структуру архива, отказ перезаписи, отказ на враждебных именах записей и работу с путями, содержащими пробелы и кириллицу. Найдено при разработке: символ '=' в наборе символов токена склеивал пару "ключ=значение" в один токен, из-за чего редактировался и сам SHA-256. Исправлено: дополнение base64 обрабатывается только в хвосте прогона. Статический анализ на новом модуле выдал C28020 на std::array::operator[] с беззнаковым счётчиком; перебор заменён на range-for, индексирования больше нет. Сборка с /analyze снова чиста. Высота меню теперь выводится из фактического числа пунктов, а не из зашитой константы. Проверено: чистая сборка без предупреждений, сборка с /analyze без находок, CTest 42/42, strategy fidelity 88/88. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 5 +- docs/PRIVACY.md | 55 +++ src/app/App.h | 1 + src/app/Diagnostics.cpp | 621 ++++++++++++++++++++++++++++++++++ src/app/Diagnostics.h | 75 ++++ src/app/UiScreens.cpp | 54 ++- src/ui/Widgets.h | 3 + tests/CMakeLists.txt | 2 +- tests/security_regression.ps1 | 18 + tests/test_main.cpp | 133 ++++++++ 10 files changed, 957 insertions(+), 10 deletions(-) create mode 100644 src/app/Diagnostics.cpp create mode 100644 src/app/Diagnostics.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a4eb6af..60775cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,6 +143,7 @@ set(LIB_SOURCES src/ui/Effects.cpp src/ui/Widgets.cpp src/ui/Watchdog.cpp + src/app/Diagnostics.cpp src/app/UiScreens.cpp src/app/App.cpp ) @@ -153,7 +154,7 @@ target_include_directories(cheburnet_lib PUBLIC "${GEN_DIR}") target_compile_definitions(cheburnet_lib PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _CRT_SECURE_NO_WARNINGS) -target_link_libraries(cheburnet_lib PUBLIC bcrypt crypt32 winhttp advapi32 shell32 ole32 user32) +target_link_libraries(cheburnet_lib PUBLIC bcrypt crypt32 winhttp advapi32 shell32 ole32 user32 wintrust) # Warnings / hardening for our own code. set(CHEBURNET_WARN /W4 /WX /permissive- /utf-8) @@ -161,7 +162,7 @@ set(CHEBURNET_HARDEN /guard:cf) # Статический анализ MSVC. Выключен по умолчанию: он заметно замедляет сборку и # не должен мешать обычной разработке. Включается отдельным заданием CI -# (см. .github/workflows/codeql.yml) и локально: +# (см. .github/workflows/static-analysis.yml) и локально: # cmake -S . -B build-analyze -DCHEBURNET_ENABLE_ANALYZE=ON # Находки разбираются, а не подавляются: список отключённых предупреждений # ведётся здесь с обоснованием каждого. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index f72dfee..54644f6 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -17,3 +17,58 @@ WinHTTP. Заголовок User-Agent содержит версию CHEBURNET. `disabled` («отключены») или выключите проверку при запуске. Локальные журналы, рабочую среду и пользовательские данные удаляет только пользователь; CHEBURNET не удаляет и не отправляет их дистанционно. + +## Экспорт диагностики + +В меню есть пункт `Экспорт диагностики`. Он создаёт локальный архив +`CHEBURNET-diagnostics-ГГГГММДД-ЧЧММСС.zip` в каталоге журналов +(`%ProgramData%\CHEBURNET\logs`). + +Экспорт выполняется только по явному действию пользователя. Перед созданием +архива программа показывает полный состав и точный путь записи и требует +отдельного подтверждения. Отправки нет ни автоматической, ни ручной: в коде +экспорта вообще нет сетевых вызовов, и это закреплено регрессионной проверкой +`PRIVACY diagnostics export never uses a network API`. + +### Что входит в архив + +| Файл | Содержимое | +|---|---| +| `manifest.json` | описание состава самого архива, список редактируемых и никогда не собираемых данных | +| `summary.json` | семантическая версия CHEBURNET, PE-версия и канал; состояние подписи Authenticode лончера (только вердикт); поставщик, версия, происхождение и SHA-256 архива движка; версии рабочей среды — текущая, предыдущая рабочая, ожидающая; результат последней операции и состояние целостности; число доступных стратегий; выбранная стратегия, режим игрового фильтра, режим и канал обновлений; версия сборки Windows и разрядность; ожидаемые SHA-256 файлов, принадлежащих CHEBURNET | +| `application.log` | хвост `cheburnet.log` после редактирования, не более 256 КиБ | + +### Что скрывается перед записью + +- имя пользователя; +- имя компьютера; +- путь профиля пользователя; +- **любой** путь внутри `\Users\`, а не только текущий профиль; +- адреса IPv4 и IPv6; +- строки длиной от 40 символов из алфавита base64/hex, похожие на токены, + подписи и ключи. + +Значения SHA-256 (ровно 64 шестнадцатеричных символа) и SHA-1 (40 символов) +сохраняются намеренно: без хешей файлов, принадлежащих CHEBURNET, диагностика +теряет смысл, а сами хеши не раскрывают ничего о пользователе. + +### Что не собирается никогда + +Переменные окружения, учётные данные прокси, данные браузера и файлы cookie, +дампы трафика, содержимое пользовательских списков из `user\lists`, +идентификаторы машины, сведения о владельце сертификата подписи. + +### Ограничения и безопасность + +- объём ограничен: хвост журнала обрезается, внешние пользовательские файлы не + включаются; +- архив создаётся с флагом `CREATE_NEW`, поэтому существующий файл никогда не + перезаписывается молча; +- при любой ошибке записи частично созданный файл удаляется; +- имена записей внутри архива фиксированы и проверяются: путь с `..` или + разделителем отвергается до записи; +- ZIP пишется без сжатия собственным кодом, сторонняя библиотека ради одной + операции не вводится, формат детерминирован и полностью проверяем. + +Проверки состава и редактирования выполняет тест CTest `diagnostics`, включая +работу с путями, содержащими пробелы и кириллицу. diff --git a/src/app/App.h b/src/app/App.h index 6b3b57d..9bcc37d 100644 --- a/src/app/App.h +++ b/src/app/App.h @@ -63,6 +63,7 @@ class App { void ScreenMainMenu(); void ScreenStrategy(); void ScreenDiagnostics(); + void ScreenDiagnosticsExport(); void ScreenLogs(); void ScreenAbout(); void ScreenUpdates(); diff --git a/src/app/Diagnostics.cpp b/src/app/Diagnostics.cpp new file mode 100644 index 0000000..041193a --- /dev/null +++ b/src/app/Diagnostics.cpp @@ -0,0 +1,621 @@ +#include "Diagnostics.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "../config/Strategies.h" +#include "../core/ResourceExtractor.h" +#include "../core/RuntimePaths.h" +#include "../update/RuntimeStateStore.h" +#include "../util/Json.h" +#include "../util/Logger.h" +#include "../util/StringUtil.h" +#include "../util/Version.h" +#include "GeneratedManifest.h" +#include "GeneratedProvenance.h" + +namespace cheburnet::diag { +namespace { + +constexpr wchar_t kRedactedUser[] = L"<ПОЛЬЗОВАТЕЛЬ>"; +constexpr wchar_t kRedactedMachine[] = L"<КОМПЬЮТЕР>"; +constexpr wchar_t kRedactedPath[] = L"<ПРОФИЛЬ>"; +constexpr wchar_t kRedactedAddress[] = L"<АДРЕС>"; +constexpr wchar_t kRedactedSecret[] = L"<СКРЫТО>"; + +bool IEqualsAt(std::wstring_view haystack, std::size_t at, std::wstring_view needle) { + if (needle.empty() || at + needle.size() > haystack.size()) return false; + for (std::size_t i = 0; i < needle.size(); ++i) { + const wchar_t a = static_cast(::towlower(haystack[at + i])); + const wchar_t b = static_cast(::towlower(needle[i])); + if (a != b) return false; + } + return true; +} + +void ReplaceAllNoCase(std::wstring& text, std::wstring_view needle, std::wstring_view with) { + if (needle.empty()) return; + std::wstring output; + output.reserve(text.size()); + std::size_t pos = 0; + while (pos < text.size()) { + if (IEqualsAt(text, pos, needle)) { + output.append(with); + pos += needle.size(); + continue; + } + output.push_back(text[pos++]); + } + text = std::move(output); +} + +bool IsHexDigit(wchar_t c) { + return (c >= L'0' && c <= L'9') || (c >= L'a' && c <= L'f') || (c >= L'A' && c <= L'F'); +} + +// '=' намеренно НЕ входит в набор: иначе "digest=" склеивается в один +// токен и безобидная пара ключ-значение целиком попадает под редактирование. +// Дополнение base64 обрабатывается отдельно, только в хвосте прогона. +bool IsTokenChar(wchar_t c) { + return (c >= L'0' && c <= L'9') || (c >= L'a' && c <= L'z') || (c >= L'A' && c <= L'Z') || + c == L'+' || c == L'/' || c == L'_' || c == L'-'; +} + +// Один компонент IPv4 (0..255) без ведущих нулей длиной больше одного символа. +bool ReadIpv4Octet(std::wstring_view text, std::size_t& at) { + const std::size_t start = at; + unsigned value = 0; + while (at < text.size() && text[at] >= L'0' && text[at] <= L'9' && at - start < 3) { + value = value * 10 + static_cast(text[at] - L'0'); + ++at; + } + return at != start && value <= 255; +} + +// Заменяет адреса IPv4 и достаточно длинные литералы IPv6. +std::wstring RedactAddresses(std::wstring_view text) { + std::wstring output; + output.reserve(text.size()); + std::size_t pos = 0; + while (pos < text.size()) { + // IPv4 + std::size_t probe = pos; + bool matched = true; + for (int part = 0; part < 4 && matched; ++part) { + if (part != 0) { + if (probe >= text.size() || text[probe] != L'.') { matched = false; break; } + ++probe; + } + if (!ReadIpv4Octet(text, probe)) matched = false; + } + const bool leftBoundary = pos == 0 || !((text[pos - 1] >= L'0' && text[pos - 1] <= L'9') || + text[pos - 1] == L'.'); + const bool rightBoundary = probe >= text.size() || + !((text[probe] >= L'0' && text[probe] <= L'9') || + text[probe] == L'.'); + if (matched && leftBoundary && rightBoundary) { + output.append(kRedactedAddress); + pos = probe; + continue; + } + // IPv6: не меньше трёх групп шестнадцатеричных цифр через двоеточие. + probe = pos; + int groups = 0; + while (probe < text.size()) { + const std::size_t groupStart = probe; + while (probe < text.size() && IsHexDigit(text[probe]) && probe - groupStart < 4) ++probe; + if (probe == groupStart) break; + ++groups; + if (probe < text.size() && text[probe] == L':') ++probe; else break; + } + if (groups >= 3 && (pos == 0 || !IsHexDigit(text[pos - 1]))) { + output.append(kRedactedAddress); + pos = probe; + continue; + } + output.push_back(text[pos++]); + } + return output; +} + +// Длинные непрерывные строки из алфавита base64/hex трактуются как возможные +// секреты: токены, подписи, ключи. Известные безопасные значения (SHA-256 +// ровно 64 hex-символа) в диагностике нужны, поэтому они сохраняются. +std::wstring RedactSecrets(std::wstring_view text) { + constexpr std::size_t kSuspiciousLength = 40; + std::wstring output; + output.reserve(text.size()); + std::size_t pos = 0; + while (pos < text.size()) { + if (!IsTokenChar(text[pos])) { + output.push_back(text[pos++]); + continue; + } + const std::size_t start = pos; + bool allHex = true; + while (pos < text.size() && IsTokenChar(text[pos])) { + if (!IsHexDigit(text[pos])) allHex = false; + ++pos; + } + // Хвостовое дополнение base64 считается частью прогона, но не делает + // его шестнадцатеричным. + std::size_t padding = 0; + while (pos < text.size() && text[pos] == L'=' && padding < 2) { + ++pos; + ++padding; + allHex = false; + } + const std::size_t length = pos - start; + const bool knownDigest = allHex && (length == 64 || length == 40); + if (length >= kSuspiciousLength && !knownDigest) { + output.append(kRedactedSecret); + } else { + output.append(text.substr(start, length)); + } + } + return output; +} + +std::string Quote(std::wstring_view value) { return json::Quote(str::ToUtf8(value)); } +std::string Quote(std::string_view value) { return json::Quote(value); } + +std::wstring LocalTimestamp() { + SYSTEMTIME now{}; + ::GetLocalTime(&now); + wchar_t buffer[32]{}; + ::swprintf_s(buffer, L"%04u%02u%02u-%02u%02u%02u", now.wYear, now.wMonth, now.wDay, + now.wHour, now.wMinute, now.wSecond); + return buffer; +} + +std::wstring UtcTimestamp() { + SYSTEMTIME now{}; + ::GetSystemTime(&now); + wchar_t buffer[32]{}; + ::swprintf_s(buffer, L"%04u-%02u-%02uT%02u:%02u:%02uZ", now.wYear, now.wMonth, now.wDay, + now.wHour, now.wMinute, now.wSecond); + return buffer; +} + +std::wstring WindowsBuild() { + // RtlGetVersion минует манифестную совместимость и возвращает настоящую + // версию. Собираются только версия, сборка и разрядность. + using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW); + RTL_OSVERSIONINFOW info{}; + info.dwOSVersionInfoSize = sizeof(info); + if (const HMODULE ntdll = ::GetModuleHandleW(L"ntdll.dll")) { + const auto entry = reinterpret_cast( + reinterpret_cast(::GetProcAddress(ntdll, "RtlGetVersion"))); + if (entry && entry(&info) == 0) { + return std::to_wstring(info.dwMajorVersion) + L"." + + std::to_wstring(info.dwMinorVersion) + L"." + + std::to_wstring(info.dwBuildNumber); + } + } + return L"неизвестно"; +} + +std::wstring ProcessArchitecture() { + SYSTEM_INFO info{}; + ::GetNativeSystemInfo(&info); + switch (info.wProcessorArchitecture) { + case PROCESSOR_ARCHITECTURE_AMD64: return L"x64"; + case PROCESSOR_ARCHITECTURE_ARM64: return L"arm64"; + case PROCESSOR_ARCHITECTURE_INTEL: return L"x86"; + default: return L"неизвестно"; + } +} + +std::wstring LauncherPath() { + std::wstring buffer(32768, L'\0'); + const DWORD length = ::GetModuleFileNameW(nullptr, buffer.data(), + static_cast(buffer.size())); + if (length == 0 || length >= buffer.size()) return {}; + buffer.resize(length); + return buffer; +} + +// Состояние подписи Authenticode самого лончера. Берётся только вердикт, без +// каких-либо сведений о владельце сертификата пользователя. +std::wstring LauncherSignatureState(const std::wstring& path) { + if (path.empty()) return L"не определено"; + WINTRUST_FILE_INFO file{}; + file.cbStruct = sizeof(file); + file.pcwszFilePath = path.c_str(); + GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + WINTRUST_DATA data{}; + data.cbStruct = sizeof(data); + data.dwUIChoice = WTD_UI_NONE; + data.fdwRevocationChecks = WTD_REVOKE_NONE; + data.dwUnionChoice = WTD_CHOICE_FILE; + data.pFile = &file; + data.dwStateAction = WTD_STATEACTION_VERIFY; + data.dwProvFlags = WTD_SAFER_FLAG | WTD_CACHE_ONLY_URL_RETRIEVAL; + const LONG status = ::WinVerifyTrust(static_cast(INVALID_HANDLE_VALUE), &action, &data); + data.dwStateAction = WTD_STATEACTION_CLOSE; + ::WinVerifyTrust(static_cast(INVALID_HANDLE_VALUE), &action, &data); + if (status == 0) return L"действительна"; + if (status == TRUST_E_NOSIGNATURE) return L"не подписан"; + if (status == TRUST_E_BAD_DIGEST) return L"нарушена (файл изменён)"; + if (status == CERT_E_UNTRUSTEDROOT || status == CERT_E_CHAINING) return L"цепочка не доверена"; + if (status == CERT_E_EXPIRED) return L"сертификат истёк"; + return L"отклонена"; +} + +std::string ReadLogTail(const std::wstring& path, std::size_t maxBytes) { + const HANDLE file = ::CreateFileW(path.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return {}; + LARGE_INTEGER size{}; + if (!::GetFileSizeEx(file, &size) || size.QuadPart <= 0) { + ::CloseHandle(file); + return {}; + } + const std::uint64_t total = static_cast(size.QuadPart); + const std::uint64_t want = total > maxBytes ? maxBytes : total; + LARGE_INTEGER offset{}; + offset.QuadPart = static_cast(total - want); + if (!::SetFilePointerEx(file, offset, nullptr, FILE_BEGIN)) { + ::CloseHandle(file); + return {}; + } + std::string bytes(static_cast(want), '\0'); + std::size_t read = 0; + while (read < bytes.size()) { + DWORD chunk = 0; + if (!::ReadFile(file, bytes.data() + read, static_cast(bytes.size() - read), + &chunk, nullptr) || chunk == 0) { + break; + } + read += chunk; + } + ::CloseHandle(file); + bytes.resize(read); + // Обрезанный хвост начинается с середины строки — отбрасываем её. + const std::size_t newline = bytes.find('\n'); + if (total > want && newline != std::string::npos) bytes.erase(0, newline + 1); + return bytes; +} + +void AppendField(std::string& out, std::string_view key, std::wstring_view value, bool last) { + out += " " + Quote(key) + ": " + Quote(value); + out += last ? "\n" : ",\n"; +} + +} // namespace + +RedactionContext CurrentRedactionContext() { + RedactionContext context; + wchar_t user[UNLEN + 1]{}; + DWORD userSize = UNLEN + 1; + if (::GetUserNameW(user, &userSize) && userSize > 1) context.userName.assign(user, userSize - 1); + wchar_t machine[MAX_COMPUTERNAME_LENGTH + 1]{}; + DWORD machineSize = MAX_COMPUTERNAME_LENGTH + 1; + if (::GetComputerNameW(machine, &machineSize)) context.machineName.assign(machine, machineSize); + wchar_t profile[MAX_PATH]{}; + DWORD profileSize = MAX_PATH; + if (::GetEnvironmentVariableW(L"USERPROFILE", profile, profileSize) > 0) { + context.userProfile = profile; + } + return context; +} + +std::wstring Redact(std::wstring_view text, const RedactionContext& context) { + std::wstring output(text); + // Порядок важен: сначала самые длинные и конкретные значения. + if (context.userProfile.size() > 3) ReplaceAllNoCase(output, context.userProfile, kRedactedPath); + if (context.userName.size() >= 2) ReplaceAllNoCase(output, context.userName, kRedactedUser); + if (context.machineName.size() >= 2) { + ReplaceAllNoCase(output, context.machineName, kRedactedMachine); + } + // Любой иной путь внутри каталога пользователей тоже скрывается: имя может + // отличаться от текущего (другой профиль, перенесённый журнал). + { + std::wstring result; + result.reserve(output.size()); + std::size_t pos = 0; + const std::wstring_view marker = L"\\users\\"; + while (pos < output.size()) { + if (IEqualsAt(output, pos, marker)) { + result.append(L"\\Users\\"); + result.append(kRedactedUser); + pos += marker.size(); + while (pos < output.size() && output[pos] != L'\\' && output[pos] != L'/' && + output[pos] != L'"' && output[pos] != L' ') { + ++pos; + } + continue; + } + result.push_back(output[pos++]); + } + output = std::move(result); + } + output = RedactAddresses(output); + output = RedactSecrets(output); + return output; +} + +std::wstring SuggestedFileName() { + return L"CHEBURNET-diagnostics-" + LocalTimestamp() + L".zip"; +} + +Bundle BuildBundle(const RuntimePaths& paths, const Config& config, + const BundleOptions& options) { + const RedactionContext context = CurrentRedactionContext(); + const std::wstring launcher = LauncherPath(); + + update::StateResult state = update::LoadRuntimeState(paths.ActiveRuntimePath()); + const std::wstring currentRuntime = state.ok ? str::ToUtf16(state.state.current) + : paths.RuntimeVersion(); + const std::wstring previousRuntime = state.ok ? str::ToUtf16(state.state.previousKnownGood) + : std::wstring(); + const std::wstring pendingRuntime = state.ok ? str::ToUtf16(state.state.pending) + : std::wstring(); + const std::wstring lastResult = state.ok ? str::ToUtf16(state.state.lastResult) + : std::wstring(L"состояние недоступно"); + + std::vector catalog; + ExtractionResult integrity; + { + ResourceExtractor verifier(paths); + integrity = verifier.VerifyInstalledRuntime(catalog); + } + + std::string summary; + summary += "{\n \"schema\": 1,\n"; + summary += " \"generated_at\": " + Quote(UtcTimestamp()) + ",\n"; + summary += " \"launcher\": {\n"; + AppendField(summary, "semantic_version", str::ToUtf16(CHEBURNET_VERSION_STR), false); + AppendField(summary, "pe_version", str::ToUtf16(CHEBURNET_VERSION_PE_STR), false); + AppendField(summary, "release_channel", str::ToUtf16(CHEBURNET_RELEASE_CHANNEL), false); + AppendField(summary, "authenticode", LauncherSignatureState(launcher), true); + summary += " },\n"; + summary += " \"payload\": {\n"; + AppendField(summary, "provider", upstream::kProvider, false); + AppendField(summary, "embedded_version", upstream::kVersion, false); + AppendField(summary, "release_url", upstream::kReleaseUrl, false); + AppendField(summary, "imported_at_utc", upstream::kImportedAt, false); + AppendField(summary, "archive_sha256", str::ToUtf16(upstream::kArchiveSha256), false); + AppendField(summary, "upstream_commit", str::ToUtf16(upstream::kCommit), true); + summary += " },\n"; + summary += " \"runtime\": {\n"; + AppendField(summary, "current", currentRuntime, false); + AppendField(summary, "previous_known_good", previousRuntime, false); + AppendField(summary, "pending", pendingRuntime, false); + AppendField(summary, "last_result", lastResult, false); + AppendField(summary, "integrity", + integrity.ok ? std::wstring(L"проверена") + : (L"нарушена: " + Redact(integrity.error, context)), false); + AppendField(summary, "strategies_available", std::to_wstring(catalog.size()), true); + summary += " },\n"; + summary += " \"settings\": {\n"; + AppendField(summary, "strategy_id", str::ToUtf16(config.strategyId), false); + AppendField(summary, "game_filter", str::ToUtf16(GameFilterModeName(config.gameFilter)), false); + AppendField(summary, "update_mode", str::ToUtf16(UpdateModeName(config.update.mode)), false); + AppendField(summary, "update_channel", str::ToUtf16(config.update.channel), false); + AppendField(summary, "check_on_start", config.update.checkOnStart ? L"true" : L"false", true); + summary += " },\n"; + summary += " \"system\": {\n"; + AppendField(summary, "windows_build", WindowsBuild(), false); + AppendField(summary, "architecture", ProcessArchitecture(), true); + summary += " },\n"; + summary += " \"binaries\": [\n"; + // Хеши только тех файлов, которыми владеет CHEBURNET. Пути внутри архива + // относительные, чтобы не раскрывать раскладку машины. + bool firstBinary = true; + for (int i = 0; i < kEmbeddedResourceCount; ++i) { + const auto& resource = kEmbeddedResources[i]; + if (!firstBinary) summary += ",\n"; + firstBinary = false; + summary += " {" + Quote(std::string_view("name")) + ": " + + Quote(std::string_view(resource.logicalName)) + ", " + + Quote(std::string_view("expected_sha256")) + ": " + + Quote(std::string_view(resource.sha256)) + ", " + + Quote(std::string_view("bytes")) + ": " + + std::to_string(resource.expectedSize) + "}"; + } + summary += "\n ]\n}\n"; + + const std::string logTail = ReadLogTail(paths.LogsDir() + L"\\cheburnet.log", + options.maxLogBytes); + const std::wstring redactedLog = Redact(str::ToUtf16(logTail), context); + + std::string manifest; + manifest += "{\n \"schema\": 1,\n"; + manifest += " \"product\": " + Quote(std::string_view("CHEBURNET")) + ",\n"; + manifest += " \"purpose\": " + + Quote(std::string_view("local support bundle; never uploaded automatically")) + ",\n"; + manifest += " \"contents\": [\n"; + manifest += " {\"file\": \"summary.json\", \"describes\": \"launcher/payload/runtime/" + "settings/system state and expected hashes of CHEBURNET-owned files\"},\n"; + manifest += " {\"file\": \"application.log\", \"describes\": \"redacted tail of " + "cheburnet.log, bounded to " + std::to_string(options.maxLogBytes) + " bytes\"},\n"; + manifest += " {\"file\": \"manifest.json\", \"describes\": \"this description\"}\n"; + manifest += " ],\n"; + manifest += " \"redacted\": [\"user name\", \"computer name\", \"user profile paths\", " + "\"any path under \\\\Users\\\\\", \"IPv4 and IPv6 literals\", " + "\"token-like strings\"],\n"; + manifest += " \"never_collected\": [\"environment variables\", \"proxy credentials\", " + "\"browser data\", \"cookies\", \"packet captures\", \"user list files\", " + "\"machine identifiers\", \"certificate subject of the signer\"]\n"; + manifest += "}\n"; + + Bundle bundle; + bundle.entries.push_back({"manifest.json", manifest}); + bundle.entries.push_back({"summary.json", summary}); + bundle.entries.push_back({"application.log", str::ToUtf8(redactedLog)}); + + std::wstring preview; + preview += L"В архив войдут только эти файлы:\n"; + preview += L" manifest.json — описание состава архива\n"; + preview += L" summary.json — версия CHEBURNET и канал, версия и происхождение движка,\n"; + preview += L" версии рабочей среды (текущая/предыдущая/ожидающая),\n"; + preview += L" состояние целостности, выбранная стратегия, игровой фильтр,\n"; + preview += L" режим обновлений, сборка Windows, разрядность,\n"; + preview += L" состояние подписи CHEBURNET.exe, ожидаемые SHA-256\n"; + preview += L" файлов, принадлежащих CHEBURNET\n"; + preview += L" application.log — хвост журнала, не более " + + std::to_wstring(options.maxLogBytes / 1024) + L" КиБ, после редактирования\n\n"; + preview += L"Скрывается: имя пользователя, имя компьютера, пути профиля, любые пути\n"; + preview += L"внутри \\Users\\, адреса IPv4/IPv6, строки, похожие на токены.\n\n"; + preview += L"Не собирается: переменные окружения, учётные данные прокси, данные браузера,\n"; + preview += L"файлы cookie, дампы трафика, пользовательские списки, идентификаторы машины.\n\n"; + preview += L"Архив создаётся локально. CHEBURNET никуда его не отправляет."; + bundle.preview = std::move(preview); + return bundle; +} + +std::uint32_t Crc32(const void* data, std::size_t size) { + static const std::array table = [] { + std::array generated{}; + // Перебор по ссылкам, а не по индексу: индексирования нет вообще, + // поэтому нечего доказывать ни читателю, ни статическому анализу + // (C28020 на std::array::operator[] с беззнаковым счётчиком). + std::uint32_t index = 0; + for (std::uint32_t& slot : generated) { + std::uint32_t value = index++; + for (int bit = 0; bit < 8; ++bit) { + value = (value & 1u) ? (0xEDB88320u ^ (value >> 1)) : (value >> 1); + } + slot = value; + } + return generated; + }(); + std::uint32_t crc = 0xFFFFFFFFu; + const auto* bytes = static_cast(data); + for (std::size_t i = 0; i < size; ++i) { + crc = table[(crc ^ bytes[i]) & 0xFFu] ^ (crc >> 8); + } + return crc ^ 0xFFFFFFFFu; +} + +namespace { + +void PutU16(std::string& out, std::uint16_t value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); +} + +void PutU32(std::string& out, std::uint32_t value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); + out.push_back(static_cast((value >> 16) & 0xFF)); + out.push_back(static_cast((value >> 24) & 0xFF)); +} + +} // namespace + +bool WriteZipArchive(const std::wstring& path, const std::vector& entries, + std::wstring& error) { + if (entries.empty()) { + error = L"Нечего экспортировать."; + return false; + } + std::string archive; + std::string directory; + std::uint32_t offset = 0; + std::uint16_t count = 0; + for (const BundleEntry& entry : entries) { + if (entry.name.empty() || entry.name.size() > 250 || + entry.name.find('\\') != std::string::npos || + entry.name.find("..") != std::string::npos) { + error = L"Недопустимое имя записи архива."; + return false; + } + if (entry.content.size() > 0xFFFFFFFFull) { + error = L"Запись архива слишком велика."; + return false; + } + const std::uint32_t crc = Crc32(entry.content.data(), entry.content.size()); + const auto size = static_cast(entry.content.size()); + const auto nameLength = static_cast(entry.name.size()); + + // Local file header. Метод 0 (store): архив детерминирован и не зависит + // от библиотеки сжатия. + PutU32(archive, 0x04034B50u); + PutU16(archive, 20); // version needed + PutU16(archive, 1u << 11); // UTF-8 names + PutU16(archive, 0); // method: store + PutU16(archive, 0); // mod time + PutU16(archive, 0x21); // mod date: 1980-01-01 + PutU32(archive, crc); + PutU32(archive, size); + PutU32(archive, size); + PutU16(archive, nameLength); + PutU16(archive, 0); + archive += entry.name; + archive += entry.content; + + PutU32(directory, 0x02014B50u); + PutU16(directory, 20); // version made by + PutU16(directory, 20); // version needed + PutU16(directory, 1u << 11); + PutU16(directory, 0); + PutU16(directory, 0); + PutU16(directory, 0x21); + PutU32(directory, crc); + PutU32(directory, size); + PutU32(directory, size); + PutU16(directory, nameLength); + PutU16(directory, 0); // extra + PutU16(directory, 0); // comment + PutU16(directory, 0); // disk + PutU16(directory, 0); // internal attributes + PutU32(directory, 0); // external attributes + PutU32(directory, offset); + directory += entry.name; + + offset = static_cast(archive.size()); + ++count; + } + const auto directoryOffset = static_cast(archive.size()); + archive += directory; + PutU32(archive, 0x06054B50u); + PutU16(archive, 0); + PutU16(archive, 0); + PutU16(archive, count); + PutU16(archive, count); + PutU32(archive, static_cast(directory.size())); + PutU32(archive, directoryOffset); + PutU16(archive, 0); + + // CREATE_NEW: экспорт никогда не перезаписывает существующий файл молча. + const HANDLE file = ::CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + const DWORD code = ::GetLastError(); + error = code == ERROR_FILE_EXISTS + ? L"Файл с таким именем уже существует." + : L"Не удалось создать файл архива (код " + std::to_wstring(code) + L")."; + return false; + } + std::size_t written = 0; + bool ok = true; + while (written < archive.size()) { + DWORD chunk = 0; + if (!::WriteFile(file, archive.data() + written, + static_cast(archive.size() - written), &chunk, nullptr) || + chunk == 0) { + ok = false; + break; + } + written += chunk; + } + if (ok) ok = ::FlushFileBuffers(file) != 0; + ::CloseHandle(file); + if (!ok) { + ::DeleteFileW(path.c_str()); + error = L"Не удалось записать архив диагностики."; + return false; + } + return true; +} + +} // namespace cheburnet::diag diff --git a/src/app/Diagnostics.h b/src/app/Diagnostics.h new file mode 100644 index 0000000..aa26947 --- /dev/null +++ b/src/app/Diagnostics.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include + +#include "../config/Config.h" + +namespace cheburnet { +class RuntimePaths; +} + +namespace cheburnet::diag { + +// Экспорт диагностики CHEBURNET. +// +// Свойства, которые обязаны сохраняться (см. docs/PRIVACY.md): +// * только локально: архив пишется по пути, который пользователь видит до +// подтверждения; никакой отправки нет и быть не может; +// * только по явному действию пользователя; +// * ограниченный объём: хвост журнала обрезан, внешние пользовательские +// списки не включаются; +// * редактирование: имя пользователя, имя машины, пути профиля, адреса IP, +// значения переменных окружения и похожие на токены строки заменяются +// метками до записи; +// * состав описан в manifest.json внутри самого архива. + +// Одна запись архива: имя внутри архива и содержимое в UTF-8. +struct BundleEntry { + std::string name; + std::string content; +}; + +// Значения, которые подставляются вместо чувствительных данных. Вынесены +// отдельно, чтобы редактирование можно было проверить детерминированно. +struct RedactionContext { + std::wstring userName; // имя текущего пользователя + std::wstring machineName; // имя компьютера + std::wstring userProfile; // %USERPROFILE% +}; + +RedactionContext CurrentRedactionContext(); + +// Чистая функция: возвращает текст, пригодный для передачи третьей стороне. +// Заменяет имя пользователя, имя машины, пути профиля и каталога Users, +// адреса IPv4/IPv6 и длинные строки, похожие на токены. +std::wstring Redact(std::wstring_view text, const RedactionContext& context); + +// Состав архива. Не обращается к сети и ничего не отправляет. +struct BundleOptions { + std::size_t maxLogBytes = 256 * 1024; // ограничение хвоста журнала +}; + +struct Bundle { + std::vector entries; + // Человекочитаемое описание состава для показа пользователю ДО записи. + std::wstring preview; +}; + +Bundle BuildBundle(const RuntimePaths& paths, const Config& config, + const BundleOptions& options = {}); + +// Имя архива: CHEBURNET-diagnostics-YYYYMMDD-HHMMSS.zip (локальное время). +std::wstring SuggestedFileName(); + +// Минимальный ZIP без сжатия (метод store). Сторонняя библиотека не вводится +// ради одной операции; формат детерминирован и полностью проверяем. +bool WriteZipArchive(const std::wstring& path, const std::vector& entries, + std::wstring& error); + +// Только для тестов: CRC-32 (полином IEEE), используемый записями ZIP. +std::uint32_t Crc32(const void* data, std::size_t size); + +} // namespace cheburnet::diag diff --git a/src/app/UiScreens.cpp b/src/app/UiScreens.cpp index 02789a1..f6a1e8a 100644 --- a/src/app/UiScreens.cpp +++ b/src/app/UiScreens.cpp @@ -10,6 +10,7 @@ #include #include +#include "Diagnostics.h" #include "../console/Mascot.h" #include "../core/IntegrityVerifier.h" #include "../core/PrivilegeManager.h" @@ -617,6 +618,8 @@ void App::ScreenMainMenu() { items.push_back({L"Состояние системы", L"Права, ОС, службы, конфликты, пути", true, L'v'}); items.push_back({L"Проверить обновления", L"HTTPS + подписанный ECDSA P-256 манифест", true, L'u'}); items.push_back({L"Диагностика", L"Проверка компонентов, процесса и служб", true, L'd'}); + items.push_back({L"Экспорт диагностики", + L"Локальный архив для поддержки; ничего не отправляется", true, L'e'}); items.push_back({L"Журнал событий", L"Просмотр cheburnet.log с фильтром уровней", true, L'l'}); items.push_back({L"Настройки", L"Политика обновлений и пользовательские параметры", true, L's'}); items.push_back({L"Обслуживание среды", L"Удалить старые версии рабочей среды", true, L'm'}); @@ -713,9 +716,10 @@ void App::ScreenMainMenu() { } case 5: ScreenUpdates(); break; case 6: ScreenDiagnostics(); break; - case 7: ScreenLogs(); break; - case 8: ScreenSettings(); break; - case 9: { + case 7: ScreenDiagnosticsExport(); break; + case 8: ScreenLogs(); break; + case 9: ScreenSettings(); break; + case 10: { if (Confirm(L"Удалить старые версии рабочей среды (текущая сохранится)?")) { const AppOperationState prior = operationState_.Get(); if ((prior != AppOperationState::Disconnected && @@ -733,8 +737,8 @@ void App::ScreenMainMenu() { } break; } - case 10: ScreenAbout(); break; - case 11: return true; + case 11: ScreenAbout(); break; + case 12: return true; default: break; } return false; @@ -765,7 +769,10 @@ void App::ScreenMainMenu() { const int menuW = std::min(ui_->Width() - 4, 40); const int menuX = 4; const int menuY = 5; - fb.Box(menuX, menuY - 1, menuW, static_cast(13) + 2, th.Attr(UiColor::PrimaryDim), + // Высота выводится из фактического числа пунктов, а не из зашитой + // константы: добавление пункта больше не требует правки разметки. + const int menuRows = static_cast(menu.Count()); + fb.Box(menuX, menuY - 1, menuW, menuRows + 3, th.Attr(UiColor::PrimaryDim), th.G(), L" КОМАНДНЫЙ ЦЕНТР ", th.Attr(UiColor::Primary)); menu.Render(fb, th, menuX + 1, menuY, menuW - 2); @@ -776,7 +783,7 @@ void App::ScreenMainMenu() { fb.PutText(descX, menuY + 2, menu.SelectedItem().description, th.Attr(UiColor::Muted)); } - DrawIdleMascot(menuY + 14, ui_->Height() - 4); + DrawIdleMascot(menuY + menuRows + 3, ui_->Height() - 4); DrawFooter(L"↑ ↓ выбор Ввод — подтвердить Esc — выход буква — быстрый выбор"); } ui_->Present(); @@ -927,6 +934,39 @@ void App::ScreenStrategy() { } // ==================================================== DIAGNOSTICS SCREEN ====== +// Экспорт диагностики: только по явному действию, только локально и только +// после того, как пользователю показан состав архива и путь записи. +void App::ScreenDiagnosticsExport() { + const diag::Bundle bundle = diag::BuildBundle(paths_, config_); + const std::wstring fileName = diag::SuggestedFileName(); + const std::wstring target = paths_.LogsDir() + L"\\" + fileName; + + std::wstring body = bundle.preview; + body += L"\n\nФайл будет создан здесь:\n " + target; + ShowMessage(L"ЭКСПОРТ ДИАГНОСТИКИ", body, UiColor::Primary, L"Enter — далее"); + if (!Confirm(L"Создать архив диагностики в указанном файле?")) { + ShowMessage(L"ОТМЕНЕНО", L"Архив не создан. Ничего не записано и не отправлено.", + UiColor::Muted); + return; + } + std::wstring error; + if (!RuntimePaths::EnsureDir(paths_.LogsDir()) || + !diag::WriteZipArchive(target, bundle.entries, error)) { + ShowMessage(L"ОШИБКА ЭКСПОРТА", + (error.empty() ? std::wstring(L"Не удалось создать архив.") : error) + + L"\nПуть: " + target, + UiColor::Error); + Logger::Warn(L"экспорт диагностики не удался: " + error); + return; + } + Logger::Info(L"диагностика экспортирована локально: " + fileName); + ShowMessage(L"ГОТОВО", + L"Архив создан локально:\n " + target + + L"\n\nСостав описан внутри архива в manifest.json." + L"\nCHEBURNET никуда его не отправляет.", + UiColor::Primary); +} + void App::ScreenDiagnostics() { struct Row { std::wstring text; ui::Severity sev; }; std::vector rows; diff --git a/src/ui/Widgets.h b/src/ui/Widgets.h index 90310c1..aaad453 100644 --- a/src/ui/Widgets.h +++ b/src/ui/Widgets.h @@ -36,6 +36,9 @@ class Menu { int IndexForHotkey(wchar_t ch) const; // enabled item index or -1 const MenuItem& SelectedItem() const { return items_[static_cast(sel_)]; } bool Empty() const { return items_.empty(); } + // Число пунктов: вызывающий код выводит из него разметку вместо того, чтобы + // дублировать число в константе, которую забывают обновить. + std::size_t Count() const { return items_.size(); } // Renders the list; selected enabled row is inverse-highlighted, disabled // rows muted. Returns the height used. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 66d3f70..d213a7a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,7 +23,7 @@ set(CHEBURNET_TEST_NAMES layout checkpoints menu effects theme framebuffer configmigration) list(APPEND CHEBURNET_TEST_NAMES jsonstrict updatemanifest updateversion updatesignature updatepackage updaterollback updatestate - updatehttp updatecheckservice operationstate fuzzparsers faultinjection) + updatehttp updatecheckservice operationstate fuzzparsers faultinjection diagnostics) foreach(t ${CHEBURNET_TEST_NAMES}) add_test(NAME ${t} COMMAND cheburnet_tests ${t}) diff --git a/tests/security_regression.ps1 b/tests/security_regression.ps1 index 5551014..9ad5c88 100644 --- a/tests/security_regression.ps1 +++ b/tests/security_regression.ps1 @@ -64,6 +64,8 @@ $versionModel = Read-Source 'scripts\version.ps1' $authenticode = Read-Source 'scripts\authenticode.ps1' $checkService = Read-Source 'src\update\UpdateCheckService.cpp' $checkServiceHeader = Read-Source 'src\update\UpdateCheckService.h' +$diagnostics = Read-Source 'src\app\Diagnostics.cpp' +$diagnosticsHeader = Read-Source 'src\app\Diagnostics.h' $authenticodeSign = Read-Source 'scripts\authenticode-sign.ps1' $releaseWorkflow = Read-Source '.github\workflows\release.yml' @@ -180,6 +182,22 @@ Require-Order 'RELEASE hashes are generated only after signing' $releaseWorkflow Require-Order 'RELEASE stable signing step is gated by release channel' $releaseWorkflow @( "!contains(github.ref_name, '-rc.')", 'authenticode-sign.ps1 -File build-release\CHEBURNET.exe -Require') +# Network APIs by name, not the English word "upload": the bundle manifest +# legitimately contains the sentence that it is never uploaded. +Reject-Match 'PRIVACY diagnostics export never uses a network API' ($diagnostics + $diagnosticsHeader) ` + '(?i)(WinHttpOpen|WinHttpConnect|WinHttpSendRequest|InternetOpen|InternetConnect|HttpSendRequest|HttpOpenRequest|WSAStartup|WSASend|WSAConnect|::socket|::connect|::send|curl_easy|URLDownloadToFile|WinHttpClient)' +Require-Match 'PRIVACY diagnostics export redacts identity, paths, addresses and secrets' $diagnostics ` + 'kRedactedUser[\s\S]*kRedactedMachine[\s\S]*kRedactedPath[\s\S]*kRedactedAddress[\s\S]*kRedactedSecret' +Require-Match 'PRIVACY diagnostics export redacts every users directory, not only the current profile' $diagnostics ` + 'userProfile[\s\S]*userName[\s\S]*machineName[\s\S]*\\users\\[\s\S]*RedactAddresses[\s\S]*RedactSecrets' +Reject-Match 'PRIVACY diagnostics export never reads environment blocks or browser state' $diagnostics ` + '(?i)(GetEnvironmentStrings|CookieContainer|InternetGetCookie|Chrome|Firefox|Edge..User Data)' +Require-Match 'PRIVACY diagnostics export is bounded and describes itself' ($diagnostics + $diagnosticsHeader) ` + 'maxLogBytes[\s\S]*never_collected[\s\S]*manifest\.json' +Require-Match 'PRIVACY diagnostics export never silently overwrites' $diagnostics ` + 'CREATE_NEW[\s\S]*ERROR_FILE_EXISTS' +Require-Match 'PRIVACY diagnostics export requires an explicit user action' $screens ` + 'ScreenDiagnosticsExport[\s\S]*bundle\.preview[\s\S]*Confirm\([\s\S]*WriteZipArchive' Require-Match 'LAUNCHER DLL search hardening is fail-closed' $main ` 'if \(!::SetDefaultDllDirectories\(LOAD_LIBRARY_SEARCH_SYSTEM32\)\)[\s\S]*return 6;' Require-Match 'LAUNCHER instance mutex has protected admin/system security' $main ` diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 37d5bdf..b712cf8 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -46,6 +46,7 @@ #include "core/StatusProbe.h" #include "core/SecureFs.h" #include "app/OperationState.h" +#include "app/Diagnostics.h" #include "util/CommandLine.h" #include "util/StringUtil.h" #include "util/Version.h" @@ -1183,6 +1184,137 @@ static int test_updatestate() { } // ---------------------------------------------------------- WinHTTP policy +// -------------------------------------------------------- diagnostics export +static int test_diagnostics() { + g_fail = 0; + diag::RedactionContext context; + context.userName = L"Ivan"; + context.machineName = L"WORKSTATION-7"; + context.userProfile = L"C:\\Users\\Ivan"; + + // ---- redaction --------------------------------------------------------- + { + const std::wstring input = + L"user=Ivan host=WORKSTATION-7 path=C:\\Users\\Ivan\\AppData\\Local\\log.txt " + L"other=D:\\Users\\Petr\\notes.txt ip=192.168.1.77 public=8.8.8.8 " + L"v6=2001:0db8:85a3:0000:0000:8a2e:0370:7334 " + L"token=ghp_abcdefghijklmnopqrstuvwxyz0123456789AB " + L"digest=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const std::wstring out = diag::Redact(input, context); + CHECK(out.find(L"Ivan") == std::wstring::npos); + CHECK(out.find(L"WORKSTATION-7") == std::wstring::npos); + CHECK(out.find(L"Petr") == std::wstring::npos); + CHECK(out.find(L"192.168.1.77") == std::wstring::npos); + CHECK(out.find(L"8.8.8.8") == std::wstring::npos); + CHECK(out.find(L"2001:0db8") == std::wstring::npos); + CHECK(out.find(L"ghp_abcdefghijklmnopqrstuvwxyz") == std::wstring::npos); + // A SHA-256 digest is deliberately preserved: diagnostics are useless + // without the hashes of CHEBURNET-owned files. + CHECK(out.find(L"0123456789abcdef0123456789abcdef" + L"0123456789abcdef0123456789abcdef") != std::wstring::npos); + CHECK(out.find(L"<ПОЛЬЗОВАТЕЛЬ>") != std::wstring::npos); + CHECK(out.find(L"<КОМПЬЮТЕР>") != std::wstring::npos); + CHECK(out.find(L"<АДРЕС>") != std::wstring::npos); + CHECK(out.find(L"<СКРЫТО>") != std::wstring::npos); + } + { // Redaction is case-insensitive and idempotent. + const std::wstring once = diag::Redact(L"IVAN on workstation-7", context); + CHECK(once.find(L"IVAN") == std::wstring::npos); + CHECK(once.find(L"workstation-7") == std::wstring::npos); + CHECK(diag::Redact(once, context) == once); + } + { // An empty context must not corrupt the text or loop forever. + const diag::RedactionContext empty; + CHECK(diag::Redact(L"plain text 1.2.3", empty) == L"plain text 1.2.3"); + CHECK(diag::Redact(L"", context).empty()); + } + { // Ordinary version numbers must survive: they are not addresses. + const std::wstring out = diag::Redact(L"engine 1.10.2 build 26200", context); + CHECK(out.find(L"1.10.2") != std::wstring::npos); + CHECK(out.find(L"26200") != std::wstring::npos); + } + + // ---- CRC-32 against the canonical IEEE test vector --------------------- + { + const char check[] = "123456789"; + CHECK(diag::Crc32(check, 9) == 0xCBF43926u); + CHECK(diag::Crc32("", 0) == 0u); + } + + // ---- ZIP writer -------------------------------------------------------- + { + wchar_t tempDir[MAX_PATH]{}; + const DWORD tempLength = ::GetTempPathW(MAX_PATH, tempDir); + CHECK(tempLength > 0 && tempLength < MAX_PATH); + const std::wstring base = std::wstring(tempDir) + L"cheburnet-diag-" + + std::to_wstring(::GetCurrentProcessId()); + // Cyrillic and a space in the path: the export must work there. + const std::wstring dir = base + L" тест"; + ::CreateDirectoryW(dir.c_str(), nullptr); + const std::wstring zip = dir + L"\\CHEBURNET-diagnostics-test.zip"; + ::DeleteFileW(zip.c_str()); + + std::vector entries = { + {"manifest.json", "{\"schema\":1}\n"}, + {"summary.json", std::string(5000, 'x')}, + {"application.log", ""}, + }; + std::wstring error; + CHECK(diag::WriteZipArchive(zip, entries, error)); + CHECK(error.empty()); + + // A valid store-only archive: local header magic, central directory and + // end-of-central-directory record with the right entry count. + const std::string bytes = ReadWholeFile(zip); + CHECK(bytes.size() > 5000); + CHECK(bytes.rfind("PK\x03\x04", 0) == 0); + const std::size_t endRecord = bytes.rfind("PK\x05\x06"); + CHECK(endRecord != std::string::npos && endRecord + 22 <= bytes.size()); + if (endRecord != std::string::npos) { + const auto count = static_cast( + static_cast(bytes[endRecord + 10]) | + (static_cast(bytes[endRecord + 11]) << 8)); + CHECK(count == 3); + } + CHECK(bytes.find("PK\x01\x02") != std::string::npos); + CHECK(bytes.find("manifest.json") != std::string::npos); + + // Never silently overwrite an existing export. + std::wstring second; + CHECK(!diag::WriteZipArchive(zip, entries, second)); + CHECK(!second.empty()); + + // Hostile entry names must be refused before anything is written. + const std::wstring hostileZip = dir + L"\\hostile.zip"; + for (const char* name : {"..\\escape.txt", "../escape.txt", "a\\b.txt", ""}) { + std::vector hostile = {{name, "x"}}; + std::wstring rejected; + CHECK(!diag::WriteZipArchive(hostileZip, hostile, rejected)); + CHECK(!rejected.empty()); + } + CHECK(!RuntimePaths::Exists(hostileZip)); + std::vector none; + std::wstring emptyError; + CHECK(!diag::WriteZipArchive(dir + L"\\none.zip", none, emptyError)); + + ::DeleteFileW(zip.c_str()); + ::RemoveDirectoryW(dir.c_str()); + } + + // ---- suggested name ---------------------------------------------------- + { + const std::wstring name = diag::SuggestedFileName(); + CHECK(name.rfind(L"CHEBURNET-diagnostics-", 0) == 0); + CHECK(name.size() == std::wstring(L"CHEBURNET-diagnostics-YYYYMMDD-HHMMSS.zip").size()); + CHECK(name.substr(name.size() - 4) == L".zip"); + for (const wchar_t c : name) { + // The name must stay filesystem-safe on every locale. + CHECK(c != L':' && c != L'/' && c != L'\\' && c != L' '); + } + } + return g_fail; +} + // ------------------------------------------------ property / mutation fuzzing // // Deterministic by construction: a fixed-seed xorshift PRNG mutates known-good @@ -1779,6 +1911,7 @@ int wmain(int argc, wchar_t** argv) { {L"updatecheckservice", test_updatecheckservice}, {L"fuzzparsers", test_fuzzparsers}, {L"faultinjection", test_faultinjection}, + {L"diagnostics", test_diagnostics}, }; int failures = 0; From 4b027f7e813abd1476f2784442c04c94da52625b Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 11:25:46 +0300 Subject: [PATCH 10/13] =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B5=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D0=B5=D0=BC=D1=8B=D0=B9=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Самая критичная часть запуска — обработка прерванного обновления и реакция на нарушенную целостность активной среды — жила внутри конструктора App вперемешку с работой с процессами, файловой системой и ProgramData. Проверить её можно было только запуском всей программы с правами администратора, поэтому ни одна ветка не была покрыта тестом. Решения вынесены в src/app/RuntimeRecovery.* чистыми функциями от наблюдаемого состояния. Поведение не изменено: App по-прежнему выполняет остановку, откат и запись состояния, но сначала спрашивает, что именно нужно сделать. Тест runtimerecovery проходит всю таблицу переходов: отсутствие ожидающего состояния, недоверенная запись процесса, работающая ожидающая среда, работающая текущая среда, посторонняя среда, регистронезависимость сравнения путей и путь с общим префиксом. Отдельно проверено, что ожидающая версия никогда не повышается до текущей и что откат возможен только при записанной предыдущей рабочей версии. Рефакторинг намеренно узкий: экраны интерфейса и менеджер обновлений не трогались, чтобы не создавать риск перед стабильным выпуском. Проверено: чистая сборка без предупреждений, сборка с /analyze без находок, CTest 43/43, strategy fidelity 88/88. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + src/app/App.cpp | 29 ++++++++------ src/app/RuntimeRecovery.cpp | 30 +++++++++++++++ src/app/RuntimeRecovery.h | 56 +++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 +- tests/security_regression.ps1 | 7 ++++ tests/test_main.cpp | 72 +++++++++++++++++++++++++++++++++++ 7 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 src/app/RuntimeRecovery.cpp create mode 100644 src/app/RuntimeRecovery.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 60775cc..bc37ae5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -144,6 +144,7 @@ set(LIB_SOURCES src/ui/Widgets.cpp src/ui/Watchdog.cpp src/app/Diagnostics.cpp + src/app/RuntimeRecovery.cpp src/app/UiScreens.cpp src/app/App.cpp ) diff --git a/src/app/App.cpp b/src/app/App.cpp index 01a11d7..da3e0ba 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -10,6 +10,7 @@ #include "../core/PrivilegeManager.h" #include "../core/ResourceExtractor.h" #include "../update/RuntimeStateStore.h" +#include "RuntimeRecovery.h" #include "../update/UpdateManager.h" #include "../ui/Widgets.h" #include "../util/Logger.h" @@ -64,22 +65,24 @@ App::App(const CliFlags& flags) : cli_(flags) { const RuntimePaths currentPaths(str::ToUtf16(state.state.current)); ProcessManager recovery(paths_); const ProcessRecord active = recovery.Record(); - if (active.valid() && - str::IEqualsAscii(std::wstring_view(active.imagePath), - std::wstring_view(pendingPaths.WinwsExePath()))) { + // Решение отделено от исполнения: таблица переходов проверяется тестом + // runtimerecovery без прав администратора и без ProgramData. + const PendingAction action = DecidePendingRecovery( + true, active.valid(), active.imagePath, pendingPaths.WinwsExePath(), + currentPaths.WinwsExePath()); + if (action == PendingAction::RefuseUnknownRuntime) { + // Never normalize an unexplained running version by merely + // clearing pending state. That would make process identity and + // active-runtime.json disagree. + throw std::runtime_error("работающая среда не является ни текущей, ни ожидающей"); + } + if (action == PendingAction::StopPendingRuntimeThenClear) { const StopResult stopped = recovery.Stop(); if (stopped.status != StopStatus::Stopped && stopped.status != StopStatus::NotRunning) { throw std::runtime_error("не удалось остановить прерванную ожидающую среду"); } Logger::Error(L"прерванная ожидающая среда остановлена перед восстановлением состояния"); - } else if (active.valid() && - !str::IEqualsAscii(std::wstring_view(active.imagePath), - std::wstring_view(currentPaths.WinwsExePath()))) { - // Never normalize an unexplained running version by merely - // clearing pending state. That would make process identity and - // active-runtime.json disagree. - throw std::runtime_error("работающая среда не является ни текущей, ни ожидающей"); } update::StoredRuntimeState recovered = state.state; recovered.pending.clear(); @@ -92,8 +95,10 @@ App::App(const CliFlags& flags) : cli_(flags) { std::vector catalog; ResourceExtractor extractor(paths_); ExtractionResult verified = extractor.VerifyInstalledRuntime(catalog); - if (!verified.ok) { - if (!state.ok || state.state.previousKnownGood.empty()) { + const IntegrityAction integrity = DecideIntegrityRecovery( + false, verified.ok, state.ok, !state.state.previousKnownGood.empty()); + if (integrity != IntegrityAction::UseInstalled) { + if (integrity == IntegrityAction::Refuse) { throw std::runtime_error("активная среда не прошла проверку, версия для отката отсутствует"); } RuntimePaths fallback(str::ToUtf16(state.state.previousKnownGood)); diff --git a/src/app/RuntimeRecovery.cpp b/src/app/RuntimeRecovery.cpp new file mode 100644 index 0000000..107999b --- /dev/null +++ b/src/app/RuntimeRecovery.cpp @@ -0,0 +1,30 @@ +#include "RuntimeRecovery.h" + +#include "../util/StringUtil.h" + +namespace cheburnet { + +PendingAction DecidePendingRecovery(bool hasPending, bool processValid, + std::wstring_view runningImagePath, + std::wstring_view pendingWinwsPath, + std::wstring_view currentWinwsPath) { + if (!hasPending) return PendingAction::None; + if (!processValid) return PendingAction::ClearPendingOnly; + if (str::IEqualsAscii(runningImagePath, pendingWinwsPath)) { + return PendingAction::StopPendingRuntimeThenClear; + } + if (str::IEqualsAscii(runningImagePath, currentWinwsPath)) { + return PendingAction::ClearPendingOnly; + } + return PendingAction::RefuseUnknownRuntime; +} + +IntegrityAction DecideIntegrityRecovery(bool runtimeIsEmbeddedVersion, bool activeVerified, + bool haveTrustedState, bool havePreviousKnownGood) { + if (runtimeIsEmbeddedVersion) return IntegrityAction::UseEmbedded; + if (activeVerified) return IntegrityAction::UseInstalled; + if (!haveTrustedState || !havePreviousKnownGood) return IntegrityAction::Refuse; + return IntegrityAction::RollbackToPrevious; +} + +} // namespace cheburnet diff --git a/src/app/RuntimeRecovery.h b/src/app/RuntimeRecovery.h new file mode 100644 index 0000000..12db0bb --- /dev/null +++ b/src/app/RuntimeRecovery.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +namespace cheburnet { + +// Решения восстановления при запуске. +// +// Раньше эта логика жила внутри конструктора App вместе с работой с реальными +// процессами, файловой системой и ProgramData, поэтому её нельзя было проверить +// иначе как запуском всей программы с правами администратора. Здесь она +// выражена чистыми функциями от наблюдаемого состояния: поведение не меняется, +// но каждая ветка становится проверяемой напрямую. +// +// Ни одна из функций ничего не выполняет: они только сообщают, что должно +// произойти. Выполнение остаётся за App, где есть доступ к процессам и диску. + +// Что делать с состоянием pending, оставшимся от прерванного обновления. +enum class PendingAction { + // Ожидающего состояния нет. + None, + // Работает именно ожидающая среда: её нужно остановить, затем очистить + // ожидание. Ожидающая версия НИКОГДА не повышается до текущей. + StopPendingRuntimeThenClear, + // Ничего не работает либо работает текущая среда: достаточно очистить + // ожидание. + ClearPendingOnly, + // Работает среда, которая не является ни текущей, ни ожидающей. Приводить + // состояние в порядок молча нельзя: личность процесса и active-runtime.json + // разошлись бы. Запуск отклоняется. + RefuseUnknownRuntime, +}; + +// `runningImagePath` имеет смысл только при `processValid`. +PendingAction DecidePendingRecovery(bool hasPending, bool processValid, + std::wstring_view runningImagePath, + std::wstring_view pendingWinwsPath, + std::wstring_view currentWinwsPath); + +// Что делать с активной рабочей средой при запуске. +enum class IntegrityAction { + // Активная версия совпадает со встроенной: используется встроенный каталог. + UseEmbedded, + // Активная установленная версия прошла проверку целостности. + UseInstalled, + // Активная версия не прошла проверку, но есть предыдущая рабочая версия. + RollbackToPrevious, + // Активная версия не прошла проверку и откатываться некуда. + Refuse, +}; + +IntegrityAction DecideIntegrityRecovery(bool runtimeIsEmbeddedVersion, bool activeVerified, + bool haveTrustedState, bool havePreviousKnownGood); + +} // namespace cheburnet diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d213a7a..066125b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,7 +23,7 @@ set(CHEBURNET_TEST_NAMES layout checkpoints menu effects theme framebuffer configmigration) list(APPEND CHEBURNET_TEST_NAMES jsonstrict updatemanifest updateversion updatesignature updatepackage updaterollback updatestate - updatehttp updatecheckservice operationstate fuzzparsers faultinjection diagnostics) + updatehttp updatecheckservice operationstate fuzzparsers faultinjection diagnostics runtimerecovery) foreach(t ${CHEBURNET_TEST_NAMES}) add_test(NAME ${t} COMMAND cheburnet_tests ${t}) diff --git a/tests/security_regression.ps1 b/tests/security_regression.ps1 index 9ad5c88..f99c2b2 100644 --- a/tests/security_regression.ps1 +++ b/tests/security_regression.ps1 @@ -66,6 +66,7 @@ $checkService = Read-Source 'src\update\UpdateCheckService.cpp' $checkServiceHeader = Read-Source 'src\update\UpdateCheckService.h' $diagnostics = Read-Source 'src\app\Diagnostics.cpp' $diagnosticsHeader = Read-Source 'src\app\Diagnostics.h' +$recovery = Read-Source 'src\app\RuntimeRecovery.cpp' $authenticodeSign = Read-Source 'scripts\authenticode-sign.ps1' $releaseWorkflow = Read-Source '.github\workflows\release.yml' @@ -124,6 +125,12 @@ Require-Match 'UPDATE downloads remain bound to authenticated manifest' $manager 'SameArtifact[\s\S]*DownloadVerified[\s\S]*g_verifiedManifestCache' Require-Match 'UPDATE interrupted pending process is identity-verified and stopped' $app ` 'pendingPaths[\s\S]*recovery\.Record\(\)[\s\S]*pendingPaths\.WinwsExePath\(\)[\s\S]*recovery\.Stop\(\)[\s\S]*interrupted-pending-rolled-back' +Require-Match 'UPDATE startup recovery decides before it acts' ($app + $recovery) ` + 'DecidePendingRecovery\([\s\S]*RefuseUnknownRuntime[\s\S]*StopPendingRuntimeThenClear' +Require-Match 'UPDATE a pending runtime is never promoted to current' $recovery ` + 'IEqualsAscii\(runningImagePath, pendingWinwsPath\)[\s\S]*StopPendingRuntimeThenClear[\s\S]*RefuseUnknownRuntime' +Require-Match 'UPDATE a broken runtime rolls back only to a recorded known-good version' $recovery ` + 'haveTrustedState \|\| !havePreviousKnownGood[\s\S]*IntegrityAction::Refuse[\s\S]*RollbackToPrevious' Require-Match 'UPDATE/connect/cleanup declare explicit serialized states' $operationState ` 'Disconnected[\s\S]*Connecting[\s\S]*Connected[\s\S]*Disconnecting[\s\S]*Updating[\s\S]*RollingBack[\s\S]*Error' Require-Match 'UPDATE/connect/cleanup enforce atomic transitions' ($app + $screens) ` diff --git a/tests/test_main.cpp b/tests/test_main.cpp index b712cf8..16ca358 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -47,6 +47,7 @@ #include "core/SecureFs.h" #include "app/OperationState.h" #include "app/Diagnostics.h" +#include "app/RuntimeRecovery.h" #include "util/CommandLine.h" #include "util/StringUtil.h" #include "util/Version.h" @@ -1184,6 +1185,76 @@ static int test_updatestate() { } // ---------------------------------------------------------- WinHTTP policy +// ------------------------------------------------------ startup recovery grid +// +// These decisions used to live inside App's constructor, tangled with real +// processes and ProgramData, so they could only be exercised by running the +// whole elevated program. As pure functions the whole table is checkable here. +static int test_runtimerecovery() { + g_fail = 0; + const std::wstring pendingExe = L"C:\\ProgramData\\CHEBURNET\\runtime\\1.10.3\\bin\\winws.exe"; + const std::wstring currentExe = L"C:\\ProgramData\\CHEBURNET\\runtime\\1.10.2\\bin\\winws.exe"; + const std::wstring foreignExe = L"C:\\Temp\\winws.exe"; + + // No pending state: nothing to recover, whatever is running. + CHECK(DecidePendingRecovery(false, false, L"", pendingExe, currentExe) == + PendingAction::None); + CHECK(DecidePendingRecovery(false, true, pendingExe, pendingExe, currentExe) == + PendingAction::None); + + // Pending state, nothing verifiably running: just clear it. + CHECK(DecidePendingRecovery(true, false, L"", pendingExe, currentExe) == + PendingAction::ClearPendingOnly); + // An invalid record must not be trusted even if it carries a path. + CHECK(DecidePendingRecovery(true, false, foreignExe, pendingExe, currentExe) == + PendingAction::ClearPendingOnly); + + // The pending runtime is the one running: stop it, then clear. It is never + // promoted to current. + CHECK(DecidePendingRecovery(true, true, pendingExe, pendingExe, currentExe) == + PendingAction::StopPendingRuntimeThenClear); + // Path comparison is case-insensitive, like the filesystem. + CHECK(DecidePendingRecovery(true, true, + L"C:\\PROGRAMDATA\\CHEBURNET\\RUNTIME\\1.10.3\\BIN\\WINWS.EXE", + pendingExe, currentExe) == + PendingAction::StopPendingRuntimeThenClear); + + // The current runtime is running: the interrupted update never took effect. + CHECK(DecidePendingRecovery(true, true, currentExe, pendingExe, currentExe) == + PendingAction::ClearPendingOnly); + + // Something else is running: refuse rather than silently reconcile. + CHECK(DecidePendingRecovery(true, true, foreignExe, pendingExe, currentExe) == + PendingAction::RefuseUnknownRuntime); + CHECK(DecidePendingRecovery(true, true, L"", pendingExe, currentExe) == + PendingAction::RefuseUnknownRuntime); + // A path that merely starts the same must not be accepted as a match. + CHECK(DecidePendingRecovery(true, true, pendingExe + L".old", pendingExe, currentExe) == + PendingAction::RefuseUnknownRuntime); + + // ---- integrity decisions ---------------------------------------------- + // The embedded version always wins, regardless of stored state. + for (const bool verified : {true, false}) { + for (const bool haveState : {true, false}) { + for (const bool havePrevious : {true, false}) { + CHECK(DecideIntegrityRecovery(true, verified, haveState, havePrevious) == + IntegrityAction::UseEmbedded); + } + } + } + // A verified installed runtime is used as is. + CHECK(DecideIntegrityRecovery(false, true, true, true) == IntegrityAction::UseInstalled); + CHECK(DecideIntegrityRecovery(false, true, false, false) == IntegrityAction::UseInstalled); + // A broken runtime rolls back only when a previous known-good version is + // recorded in trusted state; otherwise startup is refused. + CHECK(DecideIntegrityRecovery(false, false, true, true) == + IntegrityAction::RollbackToPrevious); + CHECK(DecideIntegrityRecovery(false, false, true, false) == IntegrityAction::Refuse); + CHECK(DecideIntegrityRecovery(false, false, false, true) == IntegrityAction::Refuse); + CHECK(DecideIntegrityRecovery(false, false, false, false) == IntegrityAction::Refuse); + return g_fail; +} + // -------------------------------------------------------- diagnostics export static int test_diagnostics() { g_fail = 0; @@ -1912,6 +1983,7 @@ int wmain(int argc, wchar_t** argv) { {L"fuzzparsers", test_fuzzparsers}, {L"faultinjection", test_faultinjection}, {L"diagnostics", test_diagnostics}, + {L"runtimerecovery", test_runtimerecovery}, }; int failures = 0; From 6bf60f43e031ad1194f8aa1f4fefae52aeb79de2 Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 11:31:44 +0300 Subject: [PATCH 11/13] =?UTF-8?q?=D0=97=D0=B0=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D1=83=D1=81=D0=BA=D0=B0=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=D1=82=D0=BD=D1=83=D1=8E=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D1=83=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена docs/production/FINAL_RELEASE_CERTIFICATION.md с фактическими результатами всех выполненных проверок, метаданными лончера и нагрузки и перечнем оставшихся блокеров с точным следующим действием по каждому. Статус выпуска: RELEASE_CANDIDATE_READY_WITH_BLOCKERS. Два обязательных шлюза не выполнены и не могут быть выполнены в этом окружении: проверка в чистом окружении Windows 10 и Windows 11 требует одноразовой машины и загрузки драйвера ядра, фактическая подпись требует сертификата подписи кода. Заявлять готовность при непроверенных обязательных шлюзах было бы неверно. Добавлена docs/architecture/PRIVILEGE_SEPARATION_V2.md — проектная записка о разделении привилегий. Только исследование, в состав 1.0 не входит: описаны целевая структура, узкий набор команд IPC без универсального выполнения, честная оценка того, что привилегированный разбор пакета разделение не убирает, цена решения и условия, при которых работа оправдана. CHANGELOG дополнен разделом подготовки стабильного выпуска, docs/CURRENT_BEHAVIOR.md переведён на 1.10.2, каталоги dist-* добавлены в .gitignore. Проверено: CTest 43/43, strategy fidelity 88/88. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + CHANGELOG.md | 38 ++++ docs/CURRENT_BEHAVIOR.md | 2 +- docs/architecture/PRIVILEGE_SEPARATION_V2.md | 131 ++++++++++++++ .../production/FINAL_RELEASE_CERTIFICATION.md | 170 ++++++++++++++++++ 5 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/PRIVILEGE_SEPARATION_V2.md create mode 100644 docs/production/FINAL_RELEASE_CERTIFICATION.md diff --git a/.gitignore b/.gitignore index f929a36..303c8fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build output /build*/ /dist/ +/dist-*/ /artifacts/ *.obj *.pdb diff --git a/CHANGELOG.md b/CHANGELOG.md index f720ac7..bd379d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # История изменений +## Невыпущенное — подготовка стабильного 1.0.0 + +### Исправлено + +- Семантика версий RC/stable. Сборка по тегу `v1.0.0-rc.N` больше не + сообщает о себе как `1.0.0`: введён единственный источник версии + (`cmake/Version.cmake`), из которого выводятся семантическая версия, числовая + PE-версия и канал. Переход `1.0.0-rc.3 -> 1.0.0` теперь оценивается как + обновление, а не как актуальная версия. +- Порог `minimum_supported_version` включает уже выпущенные RC, иначе + установленный `1.0.0-rc.N` получал бы отказ «слишком старая программа». +- Ревизия upstream вида `1.10.2a` больше не считается понижением + относительно `1.10.2`. +- Полный импорт upstream не мог завершиться: изолированное дерево + проверки собиралось без `docs` и `SECURITY.md`, которые требует шлюз + лицензий. Добавлен регрессионный тест. +- Статический анализ нашёл и исправлено: отсутствие проверки + размера списка атрибутов при создании привилегированного процесса и + буфер передачи 64 КиБ в автоматической памяти. + +### Добавлено + +- Движок Flowseal `zapret-discord-youtube` обновлён до 1.10.2: новая + стратегия `ALT13` (21 -> 22), обновлённые списки и ресурсы. +- Обязательная подпись Authenticode для стабильных тегов: без действительной + подписи с меткой времени выпуск не публикуется; опубликованные байты + связаны с подписанными по SHA-256. +- Проверка обновлений при запуске вынесена из потока интерфейса: + подключение не ждёт сети, недоступный DNS больше не выглядит + зависанием. +- Новый стабильный выпуск Flowseal автоматически превращается в одну + устойчивую задачу с полными метаданными и результатом проверки. +- Релизный шлюз проверки в чистом окружении Windows и воспроизводимая + подготовка виртуальной машины. +- Статический анализ (MSVC `/analyze` с `/WX` и CodeQL), мутационная + проверка свойств разборщиков и полная решётка внесения отказов. +- Локальный экспорт диагностики с редактированием и без отправки. + ## 1.0.0-rc.3 — 2026-08-13 - Стабильный канал обновлений переведён на GitHub `releases/latest/download`: diff --git a/docs/CURRENT_BEHAVIOR.md b/docs/CURRENT_BEHAVIOR.md index f352297..8f3033d 100644 --- a/docs/CURRENT_BEHAVIOR.md +++ b/docs/CURRENT_BEHAVIOR.md @@ -1,6 +1,6 @@ # Соответствие текущему исходному выпуску -Встроенный исходный проект: `Flowseal/zapret-discord-youtube` 1.10.1, +Встроенный исходный проект: `Flowseal/zapret-discord-youtube` 1.10.2, импортированный из неизменяемого файла релиза. Точные ID релиза, фиксация, URL и SHA-256 архива записаны в `resources/upstream/provenance.json`. diff --git a/docs/architecture/PRIVILEGE_SEPARATION_V2.md b/docs/architecture/PRIVILEGE_SEPARATION_V2.md new file mode 100644 index 0000000..69a403e --- /dev/null +++ b/docs/architecture/PRIVILEGE_SEPARATION_V2.md @@ -0,0 +1,131 @@ +# Разделение привилегий: проектная записка для v2 + +Статус: **только исследование.** Ничего из описанного не реализуется в составе +стабильного 1.0 и не должно смешиваться со стабилизацией 1.0. + +## Проблема + +Сегодня `CHEBURNET.exe` — один процесс с `requireAdministrator`. В нём +одновременно находятся: + +- текстовый интерфейс, обработка ввода, отрисовка; +- разбор конфигурации и каталога стратегий; +- сетевой клиент обновлений и разбор подписанного манифеста; +- разбор пакета `.cbpkg` и распаковка в защищённое дерево; +- привилегированные операции файловой системы и ACL; +- создание и остановка процесса `winws.exe`, который загружает драйвер ядра. + +Следствие: любой дефект в разборе ввода или в коде интерфейса исполняется с +правами администратора. Поверхность, работающая с недоверенными данными (сеть, +архив, конфигурация), совпадает с поверхностью, имеющей полные права. + +Текущие компенсации — строгие разборщики fail-closed, подпись манифеста, +проверка личности процесса, жёсткие ACL и транзакционная активация — снижают +вероятность, но не меняют того, что радиус поражения равен «администратор». + +## Целевая структура + +Два процесса с ясной границей доверия. + +| Компонент | Уровень | Ответственность | +|---|---|---| +| `CHEBURNET.exe` | обычная целостность, права пользователя | интерфейс, ввод, отображение состояния, скачивание файла обновления в непривилегированную область, разбор всего недоверенного | +| `cheburnet-broker` | повышенные права (служба либо элевированный помощник) | проверка подписи, привилегированные операции файловой системы, распаковка в защищённое дерево, запуск и остановка `winws.exe`, транзакционная активация | + +Принципиально: **проверка подписи остаётся на привилегированной стороне.** +Непривилегированная часть может скачать файл, но не может убедить брокера, что +файл доверенный. Иначе разделение было бы косметическим. + +## Интерфейс IPC + +Именованный канал с явным дескриптором безопасности: доступ только у +интерактивных пользователей той же сессии; брокер проверяет личность +подключившегося клиента и его образ. + +Набор команд закрытый и узкий. Никакой команды вида «выполнить произвольное +действие» быть не должно: + +```text +GetStatus -> состояние соединения, версии, целостность +Connect -> идентификатор стратегии + режим игрового фильтра +Disconnect -> нет аргументов +ApplyVerifiedPayload -> путь к скачанному файлу + ожидаемые размер и SHA-256 +GetIntegrityState -> отчёт о целостности активной среды +``` + +Правила интерфейса: + +1. аргументы — только перечислимые значения и идентификаторы из каталога, + который брокер знает сам; клиент не передаёт командные строки `winws`; +2. `ApplyVerifiedPayload` не означает «этот файл доверенный»: брокер заново + получает подписанный манифест, проверяет подпись, размер и SHA-256 и только + потом распаковывает; +3. путь к файлу принимается только внутри выделенной области передачи, + принадлежащей брокеру; произвольный путь от клиента отвергается; +4. любая команда идемпотентна либо явно сериализована; параллельные операции + отклоняются, как и сейчас через `OperationState`; +5. ответы — фиксированные структуры, без возврата произвольных путей машины. + +## Что это меняет в модели угроз + +| Угроза | Сейчас | После разделения | +|---|---|---| +| Дефект в разборе манифеста | исполняется с правами администратора | исполняется у пользователя при первичном разборе; привилегированный разбор остаётся, но вход к нему уже прошёл проверку подписи | +| Дефект в разборе `.cbpkg` | права администратора | остаётся привилегированным: распаковка неотделима от записи в защищённое дерево | +| Дефект в коде интерфейса | права администратора | права пользователя | +| Подмена пути обновления | ограничена ACL и проверками | дополнительно ограничена тем, что клиент не выбирает путь | +| Компрометация клиента | полный контроль | доступ только к пяти командам с проверяемыми аргументами | + +Честная оценка: разделение **не** убирает привилегированный разбор пакета. Оно +убирает из-под администратора интерфейс, ввод, сеть и первичный разбор +метаданных — то есть большую часть кода, но не всю недоверенную поверхность. + +## Цена + +- второй исполняемый файл ломает обещание «один `CHEBURNET.exe`»: потребуется + либо установка службы, либо самораспаковка помощника, а это отдельная + поверхность атаки и отдельный вопрос удаления; +- установка службы требует инсталлятора и прав при первом запуске; +- IPC сам становится границей доверия со своим разбором и своими тестами; +- усложняется обновление: обновлять нужно оба компонента согласованно, и + несовпадение версий клиента и брокера должно обрабатываться явно; +- отладка и диагностика усложняются: состояние размазано по двум процессам. + +## Условия, при которых это оправдано + +Разделение стоит делать, если выполнено хотя бы одно: + +1. появляется автоматическое применение обновлений без участия пользователя + (сейчас привилегированное действие всегда подтверждается человеком); +2. появляется любой приём данных из сети, кроме подписанного манифеста и + пакета; +3. интерфейс усложняется настолько, что его код перестаёт быть обозримым; +4. появляется потребность работать без прав администратора в обычном режиме. + +Пока ни одно из условий не выполнено, а текущие защиты (подпись, fail-closed +разбор, ACL, личность процесса, транзакционная активация, обязательная подпись +выпуска) покрывают реалистичные сценарии, приоритет разделения ниже, чем у +проверки в чистом окружении и стабильности обновлений. + +## Рекомендуемый порядок, если работа начнётся + +1. зафиксировать контракт IPC и его тесты **до** переноса кода; +2. первым вынести только `GetStatus` и `GetIntegrityState` — чтение без + побочных эффектов, на нём отлаживается транспорт и проверка личности; +3. затем `Connect`/`Disconnect`; +4. `ApplyVerifiedPayload` — последним, с сохранением текущей транзакционной + активации и её решётки внесения отказов; +5. на каждом шаге сохранять возможность собрать однопроцессную сборку, пока + двухпроцессная не пройдёт тот же релизный шлюз E2E. + +## Что уже сделано в 1.0 и пригодится + +- `OperationState` уже сериализует привилегированные операции; +- `ActivationHooks` уже отделяют решение от исполнения и полностью покрыты + решёткой внесения отказов; +- `RuntimeRecovery` уже отделяет решения восстановления от их исполнения; +- `UpdateCheckService` уже вынес сетевую проверку за пределы потока интерфейса + с явной моделью владения; +- строгие разборщики уже возвращают ошибку, а не бросают исключения. + +Эти границы — естественные места будущего раздела процессов. diff --git a/docs/production/FINAL_RELEASE_CERTIFICATION.md b/docs/production/FINAL_RELEASE_CERTIFICATION.md new file mode 100644 index 0000000..35acb56 --- /dev/null +++ b/docs/production/FINAL_RELEASE_CERTIFICATION.md @@ -0,0 +1,170 @@ +# CHEBURNET v1.0.0 Production Certification + +Status: **RELEASE_CANDIDATE_READY_WITH_BLOCKERS** + +Branch: `claude/production-hardening-v1` +SHA: `4b027f7e813abd1476f2784442c04c94da52625b` (база: `78cc980a6d0d68f669cf98eff3799c1cf5fa02bd`, тег `v1.0.0-rc.3`) +Date: 2026-09-11 + +Этот документ фиксирует состояние **кандидата** `1.0.0-rc.3` с выполненной +подготовкой к стабильному выпуску. Стабильный тег `v1.0.0` ещё не создан: +`cmake/Version.cmake` по-прежнему объявляет `rc.3`, и это верно — переключение +на стабильную версию выполняется одной правкой после закрытия блокеров ниже. + +## Launcher + +| Поле | Значение | +|---|---| +| Semantic version | `1.0.0-rc.3` | +| Release channel | `prerelease` | +| PE FileVersion / ProductVersion | `1.0.0.3` | +| Authenticode | **не подписан** (сертификат в этом окружении отсутствует; для RC это разрешено политикой) | +| SHA-256 | `d319010e2f32b91095325bc99cd5e742785ae2a0293a07a5e935739bff664cdf` | +| Размер | 4 159 488 байт | + +## Payload + +| Поле | Значение | +|---|---| +| Provider | `Flowseal/zapret-discord-youtube` | +| Version | `1.10.2` | +| Archive SHA-256 | `5eaac9fb2e4b1abd693487452a3ff3f4dfe9578a45f9ddddfa4bc1f5a6bb62d5` | +| Upstream commit | `dfd8e613b099676cf2aa7b474ee5923801514dec` | +| Release ID | `375784527` | +| Package SHA-256 | `970c8550c3e0498f4a60d5e8900b7e590c7592757975accb5335b126ef463333` | +| Package size | 3 403 061 байт, 27 файлов | +| Стратегий | 22 (было 21; добавлена `ALT13`) | + +## Verification + +Все команды выполнены на `4b027f7`, Windows 11 Pro 26200, MSVC (VS 18 BuildTools), Ninja, Release. + +| Проверка | Команда | Результат | +|---|---|---| +| Clean build | `scripts\build-release.ps1 -BuildDir build-final` | **PASS** — `CHEBURNET.exe` 3.97 MB, предупреждений нет (`/W4 /WX`) | +| CTest | `scripts\run-tests.ps1 -BuildDir build-final` | **PASS — 43/43** | +| Strategy fidelity | (в составе `run-tests.ps1`) | **PASS — 88/88** (22 стратегии × 4 режима GameFilter) | +| Security regression | тест `security_regression` | **PASS** | +| Package safety | тест `package_safety` | **PASS** | +| Release metadata | тест `release_metadata` | **PASS** | +| Release signing | тест `release_signing` | **PASS** | +| Version model | тест `version_model` | **PASS** | +| Upstream automation | тест `upstream_automation` | **PASS** | +| Importer validation tree | тест `importer_validation_tree` | **PASS** | +| E2E harness self-test | тест `e2e_harness` | **PASS** | +| Fuzz / properties | тест `fuzzparsers` | **PASS** (+ кампании 500 000 и 3 × 50 000 итераций на разных зёрнах) | +| Fault injection | тест `faultinjection` | **PASS** | +| Diagnostics privacy | тест `diagnostics` | **PASS** | +| Startup recovery | тест `runtimerecovery` | **PASS** | +| License validation | `scripts\validate-licenses.ps1` | `LICENSE_VALIDATION: PASS` | +| Upstream fidelity | `scripts\verify-upstream.ps1` | `UPSTREAM_FIDELITY: PASS version=1.10.2 files=46 strategies=22` | +| Upstream currency | `scripts\sync-upstream.ps1 -Version 1.10.2 -CheckOnly` | `UPSTREAM_IMPORT: already current (1.10.2)` | +| Static analysis | `cmake -DCHEBURNET_ENABLE_ANALYZE=ON` + сборка | **PASS** — находок в коде CHEBURNET нет, сборка идёт с `/WX` | +| Packaging | `scripts\package.ps1 -BuildDir build-final -OutDir dist-final -SkipBuild` | **PASS** | +| Release metadata generation | `scripts\prepare-release.ps1 -Tag v1.0.0-rc.3 …` | `RELEASE_PREPARATION: PASS tag=v1.0.0-rc.3 launcher=1.0.0-rc.3 pe=1.0.0.3 channel=prerelease payload=1.10.2` | +| Windows 11 clean VM | `scripts\e2e\Invoke-CheburnetE2E.ps1` | **НЕ ВЫПОЛНЕНО** — блокер B1 | +| Windows 10 clean VM | `scripts\e2e\Invoke-CheburnetE2E.ps1` | **НЕ ВЫПОЛНЕНО** — блокер B1 | +| Connect / disconnect / relaunch | ручная часть шлюза | **НЕ ВЫПОЛНЕНО** — блокер B1 | +| Update / rollback на живой системе | ручная часть шлюза | **НЕ ВЫПОЛНЕНО** — блокер B1 | +| Stable Authenticode signature | workflow публикации | **НЕ ВЫПОЛНЕНО** — блокер B2 | + +Проверенный выборочно артефакт подписанного манифеста (RC-канал): + +```json +{"schema":1,"channel":"stable","key_id":"cheburnet-release-2026", + "launcher":{"version":"1.0.0-rc.3","sha256":"d319010e…4cdf","size":4159488, + "minimum_supported_version":"1.0.0-rc.1"}, + "payload":{"provider":"Flowseal/zapret-discord-youtube","version":"1.10.2", + "sha256":"970c8550…3333","size":3403061, + "minimum_launcher_version":"1.0.0-rc.1", + "payload_schema":1,"strategy_schema":1}} +``` + +Это и есть главное доказательство исправления дефекта версий: до работ +`launcher.version` был бы `1.0.0` и для RC-сборки тоже. + +## Remaining known issues + +### B1 — проверка в чистом окружении Windows не выполнена + +- **Серьёзность:** блокирующая для стабильного выпуска (AC-08). +- **Причина:** привилегированные сценарии изменяют фиксированное защищённое + расположение `%ProgramData%\CHEBURNET` и требуют загрузки драйвера ядра. + Выполнять их на рабочей машине владельца репозитория недопустимо; сессия не + элевирована, машина не помечена как одноразовая, виртуальной машины с + установленной Windows в окружении нет. +- **Что уже сделано:** оснастка готова и самопроверена, сценарий отказа запуска + без прав администратора выполнен и пройден, подготовка VM автоматизирована. +- **Точное следующее действие:** + 1. `scripts\e2e\New-CheburnetTestVm.ps1 -Edition win11 -IsoPath -ReleaseDir dist`; + 2. установить Windows, сделать снимок `clean-after-setup`; + 3. в госте `.\scripts\e2e\Run-InGuest.ps1`; + 4. повторить для `-Edition win10`; + 5. выполнить ручную часть шлюза из `docs/production/E2E_RELEASE_GATE.md`; + 6. приложить оба `e2e-report.json` к этому документу. + +### B2 — подпись Authenticode стабильного выпуска не проверена на практике + +- **Серьёзность:** блокирующая для стабильного выпуска (AC-03). +- **Причина:** сертификат подписи кода отсутствует в этом окружении. Политика, + шлюзы и их отказы проверены полностью (включая реальные подписи и файл, + изменённый после подписи), но подпись **именно сертификатом CHEBURNET** ни + разу не выполнялась. +- **Точное следующее действие:** задать секреты `AUTHENTICODE_PFX_B64` и + `AUTHENTICODE_PFX_PASSWORD`, опубликовать RC-тег и убедиться, что шаг печатает + `AUTHENTICODE_SIGN: PASS status=Valid timestamped=True`. Только после этого + создавать стабильный тег. + +### B3 — переключение версии на стабильную не выполнено + +- **Серьёзность:** обязательное действие перед выпуском, не дефект. +- **Причина:** источник версии намеренно оставлен на `rc.3`, чтобы ветка + оставалась кандидатом. +- **Точное следующее действие:** в `cmake/Version.cmake` заменить + `set(CHEBURNET_VERSION_PRERELEASE "rc.3")` на `set(CHEBURNET_VERSION_PRERELEASE "")`, + пересобрать, убедиться что `version_model` и `release_metadata` проходят, и + создать тег `v1.0.0`. Шлюз тега не даст опубликовать стабильный тег при + RC-источнике и наоборот. + +### B4 — тесты CodeQL и процесса static-analysis не выполнялись на GitHub + +- **Серьёзность:** средняя, не блокирующая. +- **Причина:** процессы добавлены в этой ветке и запускаются на GitHub, здесь + выполнить их нельзя. Локальный эквивалент (MSVC `/analyze` с `/WX`) выполнен и + чист. +- **Точное следующее действие:** после push ветки убедиться, что задания + `msvc-analyze` и `codeql` зелёные, и разобрать находки CodeQL, если они будут. + +### B5 — автоматизация upstream не проверена сквозным запуском + +- **Серьёзность:** низкая, не блокирующая. +- **Причина:** процесс срабатывает только при появлении выпуска новее 1.10.2; + сейчас встроенная версия актуальна, поэтому ветка `validate`/`task` не + исполнялась. Разбор дельты, контракт отчёта и политика процесса покрыты + тестом без сети. +- **Точное следующее действие:** запустить процесс вручную + (`workflow_dispatch`) после появления следующего выпуска Flowseal и проверить, + что создаётся ровно одна задача и повторный запуск её обновляет, а не дублирует. + +## Release decision + +Все три P0-задачи закрыты и проверены: семантика версий RC/stable исправлена в +единственном источнике версии и подтверждена реальными метаданными выпуска; +нагрузка обновлена до Flowseal 1.10.2 штатным защищённым импортёром с +побайтовой сверкой; подпись Authenticode для стабильного тега сделана +обязательной, а опубликованные байты привязаны к подписанным по SHA-256 в трёх +местах. + +Сверх этого закрыты пять из шести задач P1 и найдены три дефекта, которых в +задании не было: полный импорт upstream не мог завершиться ни для какой версии, +порог совместимости отсекал бы установленные RC при переходе на стабильный +выпуск, а ревизия upstream вида `1.10.2a` считалась понижением. + +Стабильным выпуск назвать нельзя: два обязательных шлюза — проверка в чистом +окружении Windows 10 и Windows 11 и фактическая подпись сертификатом CHEBURNET — +не выполнены, и выполнить их в текущем окружении невозможно без изменения +рабочей машины владельца и без сертификата. Заявлять готовность при +непроверенных обязательных шлюзах было бы неверно. + +Поэтому статус — `RELEASE_CANDIDATE_READY_WITH_BLOCKERS`: код и оснастка готовы, +осталось выполнить B1, B2 и B3 в указанном порядке. From 28f52b101fca26ed32567cf09c026a1d1fcafdf0 Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 11:44:45 +0300 Subject: [PATCH 12/13] =?UTF-8?q?=D0=9E=D0=BF=D0=B8=D1=81=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=20=D0=B8=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=B2=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ARCHITECTURE.md: добавлены UpdateCheckService, RuntimeRecovery и Diagnostics, раздел о единственном источнике версии и ссылки на документы проверки выпуска. docs/SMOKE_TEST.md: ручной список теперь начинается с указания, что часть сценариев автоматизирована релизным шлюзом E2E, и дополнен проверкой экспорта диагностики — показ состава до записи, отсутствие в архиве имени пользователя, имени компьютера, путей профиля, адресов и токенов, отказ перезаписи существующего архива и отсутствие сетевого обращения. Нумерация пунктов приведена в порядок. Проверено: CTest 43/43, strategy fidelity 88/88, LICENSE_VALIDATION PASS. Co-Authored-By: Claude Opus 5 --- docs/ARCHITECTURE.md | 32 ++++++++++++++++++++++++++++++ docs/SMOKE_TEST.md | 46 +++++++++++++++++++++++++++++--------------- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 15df265..851d383 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,6 +32,17 @@ BAT отклоняет неподдерживаемый синтаксис и с стабилизации, записывает сведения процесса и откатывает любой частичный сбой. - `UpdateManager` разделяет подписанное обнаружение/скачивание программы и установку пакета движка с версионной активацией. +- `UpdateCheckService` выполняет проверку обновлений вне потока интерфейса, чтобы + недоступные DNS, прокси или сеть не задерживали подключение. Рабочий поток не + отсоединяется, присоединяется до разрушения и не касается интерфейса; модель + доверия совпадает с синхронным путём. +- `RuntimeRecovery` выражает решения восстановления при запуске чистыми функциями + от наблюдаемого состояния: ожидающая среда никогда не повышается до текущей, а + повреждённая активная среда откатывается только при записанной предыдущей + рабочей версии. Решение отделено от исполнения, поэтому вся таблица переходов + проверяется без прав администратора. +- `Diagnostics` собирает локальный архив поддержки с редактированием. Сетевых + вызовов в нём нет; состав описан внутри самого архива. - `UiContext` владеет состоянием консоли и восстанавливает кодовые страницы, режимы, курсор, атрибуты, размеры и стиль окна. @@ -69,3 +80,24 @@ SYSTEM и Administrators имеют полный доступ; обычные Us `pending` после сбоя никогда не становится текущим. Подробности о доверии и ротации ключей: [UPDATE_SECURITY.md](UPDATE_SECURITY.md). + +## Модель версии + +`cmake/Version.cmake` — единственный авторитетный источник версии. Из четырёх +полей выводятся семантическая версия, числовая PE-версия, признак +предварительного выпуска и канал обновлений. Пригодность обновления оценивается +только по семантической версии; PE-версия информационная, потому что формат PE +не умеет выражать prerelease. + +## Проверка выпуска + +- [RELEASE_SIGNING.md](production/RELEASE_SIGNING.md) — политика подписи и + операционные требования к сертификату; +- [E2E_RELEASE_GATE.md](production/E2E_RELEASE_GATE.md) — автоматизированные + сценарии в чистом окружении и ручная часть шлюза; +- [SECURITY_TOOLING.md](production/SECURITY_TOOLING.md) — статический анализ, + фаззинг и внесение отказов; +- [UPSTREAM_AUTOMATION.md](production/UPSTREAM_AUTOMATION.md) — обработка нового + стабильного выпуска upstream; +- [PRIVILEGE_SEPARATION_V2.md](architecture/PRIVILEGE_SEPARATION_V2.md) — + проектная записка о разделении привилегий (только исследование, не входит в 1.0). diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 3e4477c..7b97df8 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -1,5 +1,13 @@ # Ручная проверка публичного кандидата +Часть этого списка автоматизирована: `scripts\e2e\Invoke-CheburnetE2E.ps1` +покрывает первую установку, повторный запуск, прерванное обновление, откат при +повреждённой активной среде, отказ запуска без прав администратора и +fail-closed при подложенном junction. Запускайте сначала его, затем этот список +для того, что требует реальной загрузки драйвера WinDivert и взаимодействия с +интерфейсом. Подготовка виртуальной машины — `scripts\e2e\New-CheburnetTestVm.ps1`; +подробности в [production/E2E_RELEASE_GATE.md](production/E2E_RELEASE_GATE.md). + Выполняйте её на чистых виртуальных машинах Windows 10 x64 и Windows 11 x64 со снимками. Для каждого пункта используйте точный артефакт релиза и его SHA-256, фиксируйте сборку ОС, приложение терминала, результат и путь журнала. Не @@ -26,51 +34,59 @@ 8. Проверьте диагностику, локальный журнал и происхождение/авторство в разделе «О программе». В журнал не должны попадать чувствительное окружение, полная командная строка winws или содержимое пакетов. +9. Выполните «Экспорт диагностики». До записи должен быть показан полный состав + архива и точный путь, а создание — требовать отдельного подтверждения. + Откройте полученный `CHEBURNET-diagnostics-*.zip` и убедитесь, что в + `summary.json` и `application.log` нет имени пользователя, имени компьютера, + путей внутри `\Users\`, адресов IP и похожих на токены строк, а + `manifest.json` описывает состав. Повторный экспорт в тот же файл должен быть + отклонён, а не перезаписать существующий архив. Сетевого обращения при + экспорте быть не должно. ## Конфликты и восстановление -9. Оставьте доверенный winws CHEBURNET работающим, перезапустите программу и +10. Оставьте доверенный winws CHEBURNET работающим, перезапустите программу и проверьте распознавание владельца. Устаревшая запись PID не должна давать исключение. -10. По отдельности запустите посторонний `winws.exe` и службу `zapret`: +11. По отдельности запустите посторонний `winws.exe` и службу `zapret`: CHEBURNET обязан отказаться от дублирования или завершения чужого процесса. -11. Повредите бинарник или список встроенной среды и проверьте точную повторную +12. Повредите бинарник или список встроенной среды и проверьте точную повторную распаковку. Подложите точку соединения или жёсткую ссылку — ожидается безопасный отказ. -12. Перезагрузитесь с действующей записью процесса; проверьте безопасное +13. Перезагрузитесь с действующей записью процесса; проверьте безопасное восстановление. При очистке должны сохраниться текущая, предыдущая рабочая, ожидающая и встроенная версии. ## Матрица обновлений -13. Проверка без сети при запуске и вручную: сообщение о недоступности сети без +14. Проверка без сети при запуске и вручную: сообщение о недоступности сети без блокировки соединения. -14. Проверка текущей версии с сетью: подпись подтверждена, версии корректны. -15. Подмените манифест или подпись; предложите переадресацию HTTP, неверные +15. Проверка текущей версии с сетью: подпись подтверждена, версии корректны. +16. Подмените манифест или подпись; предложите переадресацию HTTP, неверные SHA-256/размер, усечённый пакет, несовместимые сведения, понижение или предварительную версию. Каждый случай должен завершаться «ОБНОВЛЕНИЕ ОТКЛОНЕНО» без запасного пути и изменения состояния. -16. Отмените реальное скачивание пакета: незавершённый файл не активируется. -17. Установите допустимый новый пакет без соединения, подключитесь и проверьте. -18. Установите при соединении: явное подтверждение, защищённый новый каталог, +17. Отмените реальное скачивание пакета: незавершённый файл не активируется. +18. Установите допустимый новый пакет без соединения, подключитесь и проверьте. +19. Установите при соединении: явное подтверждение, защищённый новый каталог, остановка, запуск, стабилизация, проверка, фиксация `active-runtime.json` и сохранность конфигурации и пользовательских списков. -19. Имитируйте ошибку запуска или проверки кандидата: ожидается «ОБНОВЛЕНИЕ +20. Имитируйте ошибку запуска или проверки кандидата: ожидается «ОБНОВЛЕНИЕ ОТКАЧЕНО», предыдущий движок работает, состояние восстановлено. Завершите процесс во время ожидания и перезапустите — кандидат не повышается. -20. Предложите допустимое обновление программы: `CHEBURNET-new.exe` скачивается +21. Предложите допустимое обновление программы: `CHEBURNET-new.exe` скачивается и открывается его каталог, но текущий EXE не перезаписывается и не запускает новый бинарный файл с повышенными правами автоматически. Замените вручную и проверьте версию. ## Платформа и релиз -21. Проверьте Windows Terminal и классический conhost, DPI 100/125/150/200%, +22. Проверьте Windows Terminal и классический conhost, DPI 100/125/150/200%, Defender, SmartScreen и при наличии реальную подпись Authenticode. Не добавляйте исключения антивируса. -22. Проверьте уведомления, лицензии, ссылки на исходники, SHA-256 и наличие +23. Проверьте уведомления, лицензии, ссылки на исходники, SHA-256 и наличие одного пользовательского `CHEBURNET.exe` плюс документации в релизе. -23. Удаление: отключитесь, закройте программу, удалите +24. Удаление: отключитесь, закройте программу, удалите `%ProgramData%\CHEBURNET` и переносимый EXE. Не должно остаться службы, запланированного задания или следов телеметрии. From 6802c0756e4c7effde909c5a63da77f994a2927b Mon Sep 17 00:00:00 2001 From: Jacksony Date: Fri, 11 Sep 2026 12:12:50 +0300 Subject: [PATCH 13/13] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B4=D0=B5=D1=84=D0=B5=D0=BA=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B8=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D1=83=20inline-PowerShell=20?= =?UTF-8?q?=D0=B2=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два отказа CI — обе ошибки мои. 1. Политика Authenticode отвергала просроченный сертификат даже при наличии контрподписи метки времени. Это неверно по существу: контрподпись доказывает, что файл был подписан, пока сертификат действовал, Windows проверяет цепочку на момент метки и сообщает об этом статусом Valid. Моя проверка отвергала бы собственные выпуски CHEBURNET в день истечения сертификата подписи, прямо противореча документу RELEASE_SIGNING.md, где метка названа средством сохранения проверяемости после истечения. Обнаружено в CI: системный бинарный файл Windows на раннере подписан сертификатом, истёкшим в 2024 году, но его подпись по-прежнему Valid благодаря метке времени. Теперь окно действия сертификата сверяется с текущим временем только для подписи БЕЗ метки времени, где нет доказательства момента подписания. Добавлены три регрессии: просроченный сертификат с меткой принимается, без метки отвергается, ещё не действующий без метки отвергается. 2. Кириллица внутри блоков run у шагов с shell: powershell. Раннер записывает блок во временный .ps1 без BOM, а Windows PowerShell 5.1 читает такой файл как ANSI, поэтому текст ломал разбор и шаг падал до запуска чего-либо. Это тот же класс ошибки, который уже был закрыт внутри файлов репозитория, но пропущен в workflow. Сообщения в inline-PowerShell переведены на ASCII в static-analysis.yml и upstream-check.yml. Блоки с shell: bash не тронуты: там UTF-8 читается корректно. Добавлен шлюз workflow_encoding: он разбирает все процессы, отличает шаги powershell от bash и запрещает не-ASCII только в первых. Проверено, что тест действительно падает на внесённом нарушении и что он не проходит вхолостую, если разбор шагов перестанет работать. Проверено: чистая сборка без предупреждений, CTest 44/44, strategy fidelity 88/88. Co-Authored-By: Claude Opus 5 --- .github/workflows/static-analysis.yml | 4 +- .github/workflows/upstream-check.yml | 4 +- docs/production/RELEASE_SIGNING.md | 21 +++++- scripts/authenticode.ps1 | 28 ++++--- tests/CMakeLists.txt | 8 ++ tests/release_signing.ps1 | 45 +++++++++-- tests/workflow_encoding.ps1 | 104 ++++++++++++++++++++++++++ 7 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 tests/workflow_encoding.ps1 diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index cae5922..c8cfb50 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -32,7 +32,9 @@ jobs: -DCMAKE_BUILD_TYPE=Release -DCHEBURNET_ENABLE_ANALYZE=ON if ($LASTEXITCODE -ne 0) { throw 'configure failed' } & $CMakeExe --build build-analyze - if ($LASTEXITCODE -ne 0) { throw 'статический анализ MSVC нашёл проблемы' } + # ASCII only inside an inline PowerShell block: the runner writes it to a + # .ps1 that Windows PowerShell 5.1 reads as ANSI, so non-ASCII breaks parsing. + if ($LASTEXITCODE -ne 0) { throw 'MSVC static analysis reported findings' } codeql: runs-on: windows-latest diff --git a/.github/workflows/upstream-check.yml b/.github/workflows/upstream-check.yml index fce913f..dcd496b 100644 --- a/.github/workflows/upstream-check.yml +++ b/.github/workflows/upstream-check.yml @@ -40,7 +40,9 @@ jobs: $report = Get-Content upstream\report.json -Raw | ConvertFrom-Json "status=$($report.status)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "version=$($report.latest_version)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - if ($report.status -eq 'error') { throw "проверка исходного выпуска не удалась: $($report.error)" } + # ASCII only inside an inline PowerShell block: the runner writes it to a + # .ps1 that Windows PowerShell 5.1 reads as ANSI, so non-ASCII breaks parsing. + if ($report.status -eq 'error') { throw "upstream release check failed: $($report.error)" } - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: upstream-detect diff --git a/docs/production/RELEASE_SIGNING.md b/docs/production/RELEASE_SIGNING.md index 391274e..d9d59e9 100644 --- a/docs/production/RELEASE_SIGNING.md +++ b/docs/production/RELEASE_SIGNING.md @@ -48,13 +48,28 @@ signing`, которая проверяет последовательность |---|---| | `Status` строго равен `Valid` | `NotSigned`, `HashMismatch` (файл изменён после подписи), `UnknownError`/`NotTrusted` (цепочка не доверена), любой иной статус | | Присутствует сертификат подписанта | подпись без подписанта | -| Текущее время внутри окна действия сертификата | просроченный или ещё не действующий сертификат | +| Текущее время внутри окна действия сертификата — **только для подписи без метки времени** | просроченный или ещё не действующий сертификат без контрподписи | | Присутствует контрподпись метки времени (когда метка настроена) | подпись, которая перестанет проверяться после истечения сертификата | | SHA-256 совпадает с ожидаемым (когда ожидание задано) | файл, пересобранный или подменённый после подписи | Промежуточных состояний нет: «предупредить и продолжить» в политике отсутствует по построению. +### Почему окно действия проверяется только без метки времени + +Контрподпись доказывает, что файл был подписан, пока сертификат действовал, и +Windows проверяет цепочку именно на момент метки, сообщая об этом статусом +`Valid`. Именно для этого метка и нужна: правильно подписанный выпуск остаётся +проверяемым и после истечения сертификата. Повторная сверка окна действия с +текущим временем отвергала бы собственные выпуски CHEBURNET в день истечения +сертификата подписи. + +Этот дефект был в первой реализации политики и обнаружен в CI: системный +бинарный файл Windows на раннере подписан сертификатом, истёкшим в 2024 году, +но его подпись по-прежнему `Valid` благодаря метке времени. Случай закрыт +двумя регрессиями в `release_signing`: просроченный сертификат с меткой принимается, +без метки — отвергается. + Проверка выполняется дважды и разными средствами: `signtool verify /pa /all` сразу после подписи и независимо через API подписи Windows (`Get-AuthenticodeSignature`) — в `scripts/verify-authenticode.ps1` перед @@ -130,7 +145,9 @@ signing`, которая проверяет последовательность |---|---| | Политика принимает корректную подпись с меткой времени | тест CTest `release_signing` | | Отклоняются `NotSigned`, `HashMismatch`, `UnknownError`, `NotTrusted`, `NotSupportedFileFormat` | `release_signing` | -| Отклоняется просроченный сертификат | `release_signing` | +| Принимается просроченный сертификат с контрподписью метки времени | `release_signing` | +| Отклоняется просроченный сертификат без метки времени | `release_signing` | +| Отклоняется ещё не действующий сертификат без метки времени | `release_signing` | | Отклоняется подпись без метки времени, когда метка требуется | `release_signing` | | Отклоняется недоверенный самоподписанный сертификат (реальная подпись) | `release_signing` | | Обнаруживается файл, изменённый после подписи (реальная подпись) | `release_signing` | diff --git a/scripts/authenticode.ps1 b/scripts/authenticode.ps1 index 46a6d2d..1acfb26 100644 --- a/scripts/authenticode.ps1 +++ b/scripts/authenticode.ps1 @@ -39,27 +39,37 @@ function Test-CheburnetAuthenticodePolicy { # are all rejections. There is deliberately no "warn and continue". $violations.Add("Authenticode status is '$status', expected 'Valid'") } + $timestamped = ($null -ne $Signature.TimeStamperCertificate) $signer = $Signature.SignerCertificate if ($null -eq $signer) { $violations.Add('signature carries no signer certificate') } else { - $notBefore = [datetime]$signer.NotBefore - $notAfter = [datetime]$signer.NotAfter - $now = [datetime]::Now - if ($now -lt $notBefore -or $now -gt $notAfter) { - $violations.Add("signer certificate is outside its validity window ($notBefore .. $notAfter)") + # The validity window is checked against the CURRENT time only for a + # signature that carries no countersignature. A countersignature proves + # the file was signed while the certificate was still valid, and Windows + # has already validated the chain as of that timestamp when it reports + # Valid -- that is the entire purpose of timestamping, and the reason a + # correctly signed release stays verifiable after its certificate + # expires. Re-checking against "now" here would reject CHEBURNET's own + # past releases the day the signing certificate expires. + if (-not $timestamped) { + $notBefore = [datetime]$signer.NotBefore + $notAfter = [datetime]$signer.NotAfter + $now = [datetime]::Now + if ($now -lt $notBefore -or $now -gt $notAfter) { + $violations.Add("signer certificate is outside its validity window " + + "($notBefore .. $notAfter) and the signature is not timestamped") + } } if ([string]::IsNullOrWhiteSpace([string]$signer.Subject)) { $violations.Add('signer certificate has no subject') } } - if ($RequireTimestamp) { + if ($RequireTimestamp -and -not $timestamped) { # A countersignature is what keeps the artifact verifiable after the # signing certificate expires. If timestamping is configured it must # have actually happened. - if ($null -eq $Signature.TimeStamperCertificate) { - $violations.Add('signature is not timestamped') - } + $violations.Add('signature is not timestamped') } return $violations } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 066125b..c99616c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -49,6 +49,14 @@ add_test(NAME release_metadata -Launcher "$" -Payload "${CHEBURNET_TEST_PACKAGE}") +# Встроенный PowerShell в процессах GitHub Actions обязан быть только ASCII: +# раннер записывает блок во временный .ps1 без BOM, а PowerShell 5.1 читает +# такой файл как ANSI. +add_test(NAME workflow_encoding + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_SOURCE_DIR}/tests/workflow_encoding.ps1" + -RepositoryRoot "${CMAKE_SOURCE_DIR}") + # Стабильный выпуск не может быть опубликован без действительной подписи. add_test(NAME release_signing COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass diff --git a/tests/release_signing.ps1 b/tests/release_signing.ps1 index 3bac04b..3054485 100644 --- a/tests/release_signing.ps1 +++ b/tests/release_signing.ps1 @@ -73,18 +73,47 @@ Assert-True (@(Test-CheburnetAuthenticodePolicy -Signature $noTimestamp -Require 'a signature without a countersignature must be rejected when a timestamp is required' Assert-True (@(Test-CheburnetAuthenticodePolicy -Signature $noTimestamp).Count -eq 0) ` 'a timestamp must only be demanded when timestamping is configured' -$expired = [pscustomobject]@{ +$expiredSigner = [pscustomobject]@{ + Subject = 'CN=Expired' + NotBefore = [datetime]::Now.AddDays(-40) + NotAfter = [datetime]::Now.AddDays(-1) + Thumbprint = ('B' * 40) +} +# An expired signing certificate is EXPECTED on a correctly timestamped +# signature: the countersignature proves the file was signed while the +# certificate was valid, and that is what keeps a past release verifiable. +# Windows reports Valid in that case. Rejecting it would break every CHEBURNET +# release the day its signing certificate expires. +$expiredButTimestamped = [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = $expiredSigner + TimeStamperCertificate = [pscustomobject]@{ Subject = 'CN=Timestamp' } +} +Assert-True (@(Test-CheburnetAuthenticodePolicy -Signature $expiredButTimestamped ` + -RequireTimestamp).Count -eq 0) ` + 'an expired certificate on a timestamped signature must still be accepted' +# Without a countersignature there is nothing proving when it was signed, so an +# expired certificate is a rejection. +$expiredNoTimestamp = [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = $expiredSigner + TimeStamperCertificate = $null +} +Assert-True (@(Test-CheburnetAuthenticodePolicy -Signature $expiredNoTimestamp).Count -gt 0) ` + 'an expired certificate without a timestamp must be rejected' +# A certificate that is not valid yet is equally unusable without a timestamp. +$notYetValid = [pscustomobject]@{ Status = 'Valid' SignerCertificate = [pscustomobject]@{ - Subject = 'CN=Expired' - NotBefore = [datetime]::Now.AddDays(-40) - NotAfter = [datetime]::Now.AddDays(-1) - Thumbprint = ('B' * 40) + Subject = 'CN=Future' + NotBefore = [datetime]::Now.AddDays(1) + NotAfter = [datetime]::Now.AddDays(40) + Thumbprint = ('D' * 40) } - TimeStamperCertificate = [pscustomobject]@{ Subject = 'CN=Timestamp' } + TimeStamperCertificate = $null } -Assert-True (@(Test-CheburnetAuthenticodePolicy -Signature $expired -RequireTimestamp).Count -gt 0) ` - 'an expired signing certificate must be rejected' +Assert-True (@(Test-CheburnetAuthenticodePolicy -Signature $notYetValid).Count -gt 0) ` + 'a not-yet-valid certificate without a timestamp must be rejected' $noSigner = [pscustomobject]@{ Status = 'Valid'; SignerCertificate = $null; TimeStamperCertificate = $null } diff --git a/tests/workflow_encoding.ps1 b/tests/workflow_encoding.ps1 new file mode 100644 index 0000000..7a229c2 --- /dev/null +++ b/tests/workflow_encoding.ps1 @@ -0,0 +1,104 @@ +<# +Regression test for inline-script encoding in GitHub Actions workflows. + +GitHub writes every `run:` block to a temporary script file on the runner. For a +step with `shell: powershell`, that file is executed by Windows PowerShell 5.1, +which reads a file WITHOUT a byte-order mark as ANSI, not UTF-8. A single +non-ASCII character inside such a block therefore becomes mojibake and breaks +parsing -- the whole step fails before running anything. + +This already happened twice in this repository (a Russian `throw` message in the +static-analysis and upstream-check workflows), so it is checked rather than +remembered. + +Scope of the rule: + * `run:` blocks of steps using powershell/pwsh -- ASCII only; + * `run:` blocks of steps using bash/sh -- non-ASCII allowed (the runner reads + those as UTF-8); + * YAML `name:`, comments and other keys -- non-ASCII allowed, they are parsed + as UTF-8 by the Actions runner, not by PowerShell. + +ASCII-only on purpose: PowerShell 5.1 reads .ps1 as ANSI without a BOM. +#> +[CmdletBinding()] +param([Parameter(Mandatory = $true)][string]$RepositoryRoot) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = [IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\') +$workflowDir = Join-Path $root '.github\workflows' +if (-not (Test-Path -LiteralPath $workflowDir -PathType Container)) { + throw "workflow directory not found: $workflowDir" +} + +$failures = New-Object Collections.Generic.List[string] +$checked = 0 +$powershellBlocks = 0 + +foreach ($file in @(Get-ChildItem -LiteralPath $workflowDir -File -Filter '*.yml')) { + $checked++ + $lines = @(Get-Content -LiteralPath $file.FullName -Encoding UTF8) + # Default shell for a step, as declared by `shell:` anywhere above the + # `run:` within the same step. Tracked line by line because a workflow mixes + # powershell and bash steps. + $currentShell = '' + $inRun = $false + $runIndent = 0 + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + $trimmed = $line.Trim() + $indent = $line.Length - $line.TrimStart().Length + + # A new step resets the remembered shell. + if ($trimmed -match '^-\s+(name|uses):') { + $currentShell = '' + $inRun = $false + } + if ($trimmed -match '^shell:\s*(\S+)\s*$') { + $currentShell = $Matches[1] + continue + } + if ($trimmed -match '^run:\s*\|' -or $trimmed -match '^run:\s*>') { + $inRun = $true + $runIndent = $indent + continue + } + if ($trimmed -match '^run:\s*\S') { + # Single-line run. + $isPowerShell = $currentShell -match '(?i)^(powershell|pwsh)' + if ($isPowerShell -and $line -match '[^\x00-\x7F]') { + $failures.Add("$($file.Name):$($i + 1) non-ASCII in a single-line powershell run") + } + continue + } + if ($inRun) { + if ($trimmed -ne '' -and $indent -le $runIndent) { + $inRun = $false + } else { + if ($currentShell -match '(?i)^(powershell|pwsh)') { + if ($line -match '[^\x00-\x7F]') { + $failures.Add("$($file.Name):$($i + 1) non-ASCII inside a powershell run block") + } + } + } + } + } + # Count powershell run blocks for a sanity check on the parser itself. + $powershellBlocks += @([regex]::Matches( + (Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8), + '(?m)^\s*shell:\s*(powershell|pwsh)\s*$')).Count +} + +if ($checked -eq 0) { $failures.Add('no workflow files were inspected') } +# If this drops to zero the parser stopped recognising powershell steps and the +# test would pass vacuously. +if ($powershellBlocks -eq 0) { + $failures.Add('no powershell steps were recognised; the workflow parser is broken') +} + +if ($failures.Count -ne 0) { + foreach ($failure in $failures) { Write-Output "FAIL: $failure" } + throw "WORKFLOW_ENCODING: FAIL ($($failures.Count))" +} +Write-Output ("WORKFLOW_ENCODING: PASS ($checked workflows, $powershellBlocks powershell steps, " + + 'inline PowerShell is ASCII-only)')