18 KiB
Отчёт 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:
test_a41читал вывод скрипта в кодировке системы. Скрипт стал писать UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался наUnicodeDecodeError, оставляяproc.stdoutравнымNone. Кодировка задана явно с обеих сторон трубы.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
-
Zip-slip — НЕ ВОСПРОИЗВОДИТСЯ. Это единственное расхождение с аудитом по существу, и оно в пользу продукта. Архив с
../, с абсолютным путём и с записью-ссылкой распакован черезzipfile.extractall: ничего за пределы каталога не вышло, абсолютный путь стал относительным,../схлопнулись, а запись-ссылка легла обычным файлом. CPython санирует пути сам.Но это свойство реализации, а не обещание формата, и распаковка идёт в корень установки. Граница сделана собственным инвариантом: каждая запись проверяется до записи на диск, отклоняются абсолютные пути, выход через
.., ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не оставлен на усмотрение стандартной библиотеки. -
pricing fallback — исправлено:
safe_loadвместоsafe_dump.dumpсериализовал текст обратно в строку,isinstance(data, dict)не выполнялось никогда, таблица цен не загружалась ни разу, аexceptэто глушил. -
CI-матрица Windows + Linux — сделано, обе джобы. Именно отсутствие Linux-джоба и позволяло четырём платформенным допущениям прятаться; на первом же прогоне матрицы Linux-джоб поймал флейк, который Windows не показывал.
-
Прочее из аудита (failover error policy,
uv sync --frozen, лишнийwebextra, 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-раннера, то есть релизной джобе нужна матрица. Работа небольшая, но проверяется только настоящей публикацией по тегу — поэтому оставлена за владельцем.