248 lines
18 KiB
Markdown
248 lines
18 KiB
Markdown
# Отчёт HUB-1: зелёный main и P0 из аудита
|
||
|
||
## Сдача
|
||
|
||
| | |
|
||
|---|---|
|
||
| Ветка | `hub/audit-p0-green-main` |
|
||
| `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` |
|
||
| Последний рабочий коммит | `f061961600dd2eb3d6901cc154dbf5a82456dd14` |
|
||
| `origin/main` на момент сдачи | `93da1b2` (не двигался) |
|
||
| PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 |
|
||
| Зелёный прогон CI | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33729660525 |
|
||
| `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) |
|
||
|
||
### Зелёный CI — все четыре джоба
|
||
|
||
| Джоб | Итог |
|
||
|---|---|
|
||
| Clean Runner Test (windows-latest) | **pass** |
|
||
| Clean Runner Test (ubuntu-latest) | **pass** |
|
||
| Headless Run (windows-latest) | **pass** |
|
||
| Headless Run (ubuntu-latest) | **pass** |
|
||
|
||
`ruff check .` — чисто. Release Gate — PASSED на обеих системах.
|
||
|
||
Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы —
|
||
739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён.
|
||
|
||
---
|
||
|
||
## P0-1. Зелёный main
|
||
|
||
### Обе причины из задания подтвердились — и обе оказались шире описания
|
||
|
||
**1. Инвариант A37 не держался на Windows.** Причина именно та, что
|
||
предполагалась. Воспроизведено локально на окружении Windows (нет переменной
|
||
`HOME`): `os.path.expandvars("$HOME/.hermes")` оставляет строку как есть, путь
|
||
перестаёт быть абсолютным, склеивается с каталогом проекта и оказывается
|
||
«внутри разрешённого корня» — команда проходит.
|
||
|
||
Заодно нашлась **зеркальная дыра, в задании не названная**: на Linux так же
|
||
проходили `rm -rf %USERPROFILE%\.hermes` и `del /f /q C:\Windows\System32`.
|
||
`Path("C:/Windows").resolve()` на Linux приписывает пути текущий каталог, и
|
||
удаление системного каталога Windows выглядело работой внутри проекта.
|
||
|
||
Разбор пути сведён в один конвейер, как и требовало задание: классификация
|
||
диалекта shell **по самой команде, а не по системе-хозяину** → раскрытие
|
||
распознанных переменных, с разрешением `HOME`/`USERPROFILE` в домашний каталог
|
||
даже когда их нет в окружении → нормализация разделителей → канонизация →
|
||
сравнение с защищёнными корнями. Каждый несостоявшийся шаг **закрывает
|
||
проход**: непроверяемый путь не считается разрешённым. Через тот же конвейер
|
||
пропущены `validate_path`, `is_forbidden_path`, `is_inside_allowed_root`.
|
||
|
||
Доказательство: новые тесты воспроизводят окружение обеих систем на любой из
|
||
них. На прежнем guard они падают — **6 failed**, ровно на дефекте из CI и на
|
||
зеркальных случаях; на новом проходят. `test_a37_isolation_guards` зелёный на
|
||
Windows-раннере.
|
||
|
||
**2. UTF-8 ронял verification-скрипт.** Воспроизведено точно: строка 63, тот же
|
||
`UnicodeEncodeError`. Общий помощник `console_encoding.force_utf8_output`
|
||
ставит UTF-8 на потоки и оставляет запасной путь, если поток перекодировать
|
||
нельзя. Той же реализацией заменён самодельный блок в `cli_commands`.
|
||
Скрипт проходит **10/10** под `PYTHONIOENCODING=cp1252` и под `ascii`.
|
||
|
||
### Причин красного CI было не две, а семь
|
||
|
||
Это главное расхождение с заданием. Ревьюер видел две; живой прогон на
|
||
Windows после их устранения показал ещё пять. Четыре из них — **не дефекты
|
||
продукта, а допущения тестов, зашитые под Linux**:
|
||
|
||
1. `test_a41` читал вывод скрипта в кодировке системы. Скрипт стал писать
|
||
UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на
|
||
`UnicodeDecodeError`, оставляя `proc.stdout` равным `None`. Кодировка
|
||
задана явно с обеих сторон трубы.
|
||
2. `test_p0_3_stop_running_hub` знал только про ветку Linux (`os.kill` по
|
||
списку от `pgrep`). На Windows процессы останавливает `taskkill` по списку
|
||
от `wmic`. Инвариант один — «чужой процесс останавливается, свой PID не
|
||
трогаем» — теперь проверяется на обеих ветках.
|
||
3-4. Оба теста установки подсовывали bash-скрипт `hermes-hub-setup.sh`; на
|
||
Windows выбирается `HermesHubSetup.exe`, и установка честно отвечала «в
|
||
релизе не найден подходящий файл обновления». Установщик берётся под ту
|
||
систему, на которой идёт прогон. Проверка сообщения смотрит, назван ли код
|
||
возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3».
|
||
|
||
Пятая — моя собственная: добавленный русский вывод Release Gate уронил шаг
|
||
на cp1252. Тот же класс дефекта, то же лекарство.
|
||
|
||
Шестая — **флейк, из-за которого main краснел случайно**: базовый прогон до
|
||
начала работы дал то 738, то 739. Причина найдена по падению ubuntu-джоба:
|
||
`test_seq_token_prevents_stale_refresh_clobber` сравнивал поколения до и после
|
||
устаревшего вызова, а `HubStateStore` — процессный синглтон, и фоновый сборщик
|
||
квот от другого теста успевает поднять `generation` между вызовами. Теперь
|
||
проверяется инвариант (устаревший ответ отброшен ровно один раз, состояние
|
||
назад не откатывается), а не равенство. Пять полных прогонов со случайным
|
||
порядком — 777 passed.
|
||
|
||
---
|
||
|
||
## P0-2. Остальные P0 аудита — каждый подтверждён исполнением
|
||
|
||
### 1. Release Gate заявлял проверку хеша, которой не было — **подтвердилось**
|
||
|
||
Хуже, чем в аудите. `hashlib` в `scripts/release_gate.py` **не вызывался ни
|
||
разу**: скачивались байты 0-10 через заголовок `Range`, и этого хватало, чтобы
|
||
напечатать `PACKAGE_HASH_VERIFIED=True`. «Проверенным ассетом» при этом
|
||
оказывался первый в списке — `checksums.txt`, а не пакет.
|
||
|
||
### 2. Publication gate fail-open — **подтвердилось**, во всех трёх условиях
|
||
|
||
Измерено прогоном самой функции:
|
||
|
||
| Условие | Прежний вердикт |
|
||
|---|---|
|
||
| Полный обрыв сети | **PASS** |
|
||
| Манифест 404 (релиза нет) | **PASS** |
|
||
| Пакет 404 (ассет не загружен) | **PASS** |
|
||
|
||
Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза.
|
||
|
||
Разделено, как требовало задание: офлайновая часть (версии, тесты, updater,
|
||
статика, секреты, список разрешённых адресов) блокирует всегда; Publication
|
||
Gate проверяет, что релиз есть, ассеты есть, пакет скачан **целиком** и
|
||
SHA-256 сошёлся с опубликованным `checksums.txt`. Блокирует в режиме
|
||
публикации (`--publication` или `HERMES_RELEASE_PUBLICATION_GATE=1`); в
|
||
обычном прогоне CI, где релиза для ветки нет и быть не должно, результат
|
||
сообщается как есть и не блокирует. Неизмеренное называется причиной, а не
|
||
выдаётся за проверенное.
|
||
|
||
Проверено на живом релизе `v0.1.3-b1`: два пакета скачаны целиком, хеши
|
||
сошлись. Проверено на отказах: обрыв сети и 404 теперь **FAIL**.
|
||
|
||
### 3. localhost `/api/action` без CSRF — **подтвердилось**
|
||
|
||
CORS уже закрыт правкой ревьюера, но CORS мешает **прочитать** ответ, а не
|
||
**отправить** запрос. Измерено на конфигурации по умолчанию
|
||
(`web_api_host=127.0.0.1`): POST с `Content-Type: text/plain` уходит
|
||
кросс-сайтом без предварительного запроса (простой запрос по правилам CORS), а
|
||
`request.json()` разбирает тело независимо от `Content-Type`. Запрос с
|
||
`Origin: https://evil.example.com` и без токена доходил до исполнителя
|
||
действий — отвечало уже само действие. Среди доступных действий
|
||
`clear_accounts`, `delete_credentials`, `set_main`.
|
||
|
||
Проверяется `Sec-Fetch-Site`, при его отсутствии — `Origin` против адреса
|
||
запроса. Замер после правки:
|
||
|
||
| Запрос | Итог |
|
||
|---|---|
|
||
| чужой сайт, `Sec-Fetch-Site: cross-site` | **403** |
|
||
| чужой сайт, старый браузер (только `Origin`) | **403** |
|
||
| собственный интерфейс | 200 |
|
||
| адресная строка / расширение | 200 |
|
||
| не-браузерный клиент (curl, CLI) | 200 |
|
||
|
||
Защита распространена на все пять небезопасных методов, не только на
|
||
`/api/action`.
|
||
|
||
---
|
||
|
||
## P1
|
||
|
||
1. **Zip-slip — НЕ ВОСПРОИЗВОДИТСЯ.** Это единственное расхождение с аудитом
|
||
по существу, и оно в пользу продукта. Архив с `../`, с абсолютным путём и с
|
||
записью-ссылкой распакован через `zipfile.extractall`: ничего за пределы
|
||
каталога не вышло, абсолютный путь стал относительным, `../` схлопнулись, а
|
||
запись-ссылка легла обычным файлом. CPython санирует пути сам.
|
||
|
||
Но это свойство реализации, а не обещание формата, и распаковка идёт в
|
||
корень установки. Граница сделана собственным инвариантом: каждая запись
|
||
проверяется до записи на диск, отклоняются абсолютные пути, выход через
|
||
`..`, ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не
|
||
оставлен на усмотрение стандартной библиотеки.
|
||
|
||
2. **pricing fallback** — исправлено: `safe_load` вместо `safe_dump`. `dump`
|
||
сериализовал текст обратно в строку, `isinstance(data, dict)` не
|
||
выполнялось никогда, таблица цен не загружалась ни разу, а `except` это
|
||
глушил.
|
||
|
||
3. **CI-матрица Windows + Linux** — сделано, обе джобы. Именно отсутствие
|
||
Linux-джоба и позволяло четырём платформенным допущениям прятаться; на
|
||
первом же прогоне матрицы Linux-джоб поймал флейк, который Windows не
|
||
показывал.
|
||
|
||
4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web`
|
||
extra, secret-scan шире) — не трогал, по заданию это отдельные задания.
|
||
|
||
---
|
||
|
||
## Ограничения задания — соблюдены
|
||
|
||
- Правки ревьюера из `main` не откатывались; в `main` напрямую не пушил.
|
||
- Фронтенд не трогал: npm, сборки и фреймворков не добавлено.
|
||
- Проверка SHA-256 **усилена**, а не ослаблена; список разрешённых адресов не
|
||
тронут.
|
||
- Учётные данные и `~/.hermes/agy_profiles/` не тронуты.
|
||
- Версия `0.1.3` не понижена.
|
||
- Неизмеренное названо причиной: Publication Gate вне режима публикации
|
||
печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`.
|
||
|
||
---
|
||
|
||
## Найдено сверх задания: релизный конвейер был мёртв, и мой же фикс снимал с него защиту
|
||
|
||
Это самое важное из того, что не значилось ни в задании, ни в аудите.
|
||
|
||
**Каждый прогон `Release Pipeline` завершался ошибкой** — все пять последних,
|
||
включая тег текущего релиза `v0.1.3-b1`. Причина ровно та же, что у красного
|
||
CI: шаг `Run Release Gate Check` падал на `test_a37_isolation_guards` и
|
||
`test_a41_clean_install`. До публикации не доходил ни один прогон, а релизы
|
||
выкладывались мимо конвейера.
|
||
|
||
**Отсюда ловушка.** `release.yml` собирает `hermes-hub-<версия>.zip` и
|
||
`update_manifest.json`, а `update_manager` ищет в релизе строго
|
||
`HermesHubSetup.exe` или `hermes-hub-setup.sh`. Настоящие релизы содержат
|
||
`HermesHubSetup.exe`, `hermes-hub-setup.sh` и `checksums.txt` — то есть
|
||
собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего
|
||
не публиковал; **как только я их починил, случайная защита исчезла**: первый
|
||
же тег привёл бы к публикации «latest» без установщиков, и любая попытка
|
||
обновиться отвечала бы «В релизе не найден подходящий файл обновления для
|
||
текущей платформы».
|
||
|
||
Ловушка закрыта явно, до публикации: шаг `Built assets must be installable by
|
||
the updater` (`release_gate.py --assets dist`) проверяет, что собранный набор
|
||
содержит установщик и `checksums.txt`, и падает с названной причиной и
|
||
подсказкой про `installer/build_installer.ps1` и
|
||
`installer/build_installer_linux.sh`. После публикации добавлен шаг
|
||
`Publication Gate` (`release_gate.py --publication-only`) — тот самый строгий
|
||
режим, ради которого ворота и разделялись.
|
||
|
||
Чего я **не** делал: не переписывал сборку установщиков в `release.yml`.
|
||
Проверить это можно только выкладыванием настоящего релиза по тегу, а это
|
||
решение владельца, не исполнителя. Конвейер по-прежнему не доходит до
|
||
публикации — но теперь падает с честной причиной вместо чужой.
|
||
|
||
## Что стоит решить ревьюеру
|
||
|
||
- Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test
|
||
(windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не
|
||
сломалось; если её будут включать — имена проверок брать новые.
|
||
- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе
|
||
каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже
|
||
встроен и блокирует (`--publication-only`, после публикации). Вручную:
|
||
`python scripts/release_gate.py --publication`.
|
||
- **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает
|
||
zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и
|
||
`installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик
|
||
требует Linux-раннера, то есть релизной джобе нужна матрица. Работа
|
||
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
|
||
оставлена за владельцем.
|