krona-staff/docs/audit/2026-09-26/AUDIT.md
Victor Trushenko f5ac179623
Some checks are pending
ci / verify (push) Waiting to run
docs(audit): add 2026-09-26 audit, acceptance and executor tasks
Executors could not see the audit: Grok reported that the owner's
AUDIT.md and evidence never reached it, so its scenarios could not be
mapped to the audit IDs. Keeping the audit, the acceptance report and
the task for each executor in the repository lets Codex, Antigravity
and Grok take work and report through git.

The live-check scripts are stored as .mjs.txt so the project linter,
which type-checks every .mjs file, does not break for everyone; the
maintained version of these checks is Grok's harness in tests/e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:13:56 +07:00

213 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Полный аудит krona-staff — 2026-09-26
- Аудитор: Claude (Opus 5.5), режим только чтения: код, ветки и удалённый репозиторий не менялись
- Проверено: основная ветка `codex/bootstrap-platform-foundation` на коммите `3e20544` (2026-09-26 12:31 +07, версия 0.3.2) и ветки `antigravity/offline-sync-backend` (`5ee7517`, офлайн-синхронизация) и `codex/frontend-library` (`1812870`, оформление портала)
- Действует для: `778ba2d` — текущая основная ветка, в которую обе ветки влиты без изменения проверенного кода
- Сверка: аудит Grok на том же коммите `3e20544` (14 старых и 14 новых находок) — каждая перепроверена заново
- Доказательства: папка [evidence](evidence) — сценарии живой проверки и их вывод
> Во время проверки обе ветки были влиты в основную (`3595792`), плюс косметический коммит `778ba2d`. Код синхронизации совпадает с проверенным `5ee7517`, поэтому находки S1–S9 относятся к основной ветке, а S1 (устройство создаёт подтверждённые смены, и они попадают в зарплату) — действующий дефект.
## 1. Итог в трёх строках
1. **Выпускать нельзя.** На настоящей PostgreSQL главная цепочка «подтвердить смену → рассчитать → утвердить» не работает (500), а создать сотрудника через API невозможно.
2. **Деньги считаются неверно даже там, где работает.** Одна смена может быть оплачена трижды, утверждённую смену можно отклонить задним числом без сторно, импорт из Excel теряет суммы, а устройство офлайн-синхронизации может само создать «подтверждённую» смену на 48 часов.
3. **Терминал пока не отправляет отметки на сервер.** Сотрудники на терминале получают свои идентификаторы, сервер их не знает и отвечает 404; связь служба ↔ интерфейс не работает из-за разных имён канала.
## 2. Что реально запускалось
| Проверка | Результат |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm ci`, lint, typecheck, build, check:migrations, check:contracts | пройдено |
| `format:check` | не пройдено — 16 файлов ([evidence/format-check.txt](evidence/format-check.txt)) |
| vitest без базы | 156 пройдено, 1 пропущен |
| vitest на живой PostgreSQL 17.10 (роль приложения) | тест изоляции арендаторов падает при очистке: `permission denied for table staff_employees` |
| Миграции 0001–0010 мигратором **без** SUPERUSER: вверх, откат 10 шагов, вверх, повтор | пройдено |
| Миграции ветки синхронизации 0001–0011, откат 0011 и повтор | пройдено |
| `scripts/create-app-role.mjs` мигратором без SUPERUSER | ошибка `permission denied to alter role` |
| RLS на живой базе | 23 из 23 таблиц с `tenant_id`: ENABLE + FORCE + политика; роль приложения без SUPERUSER, BYPASSRLS, CREATEROLE; журналы только на чтение и вставку |
| Живой API: сервер на реальной базе, реальные HTTP-запросы | 19 сценариев, см. раздел 4 |
| `npm audit` (всё и production) | 0 уязвимостей |
| .NET: сборка службы Release, тесты терминала | сборка без ошибок; тесты 43 из 43 |
| Named pipe службы | проверено запуском отдельного тестового проекта |
| Не проверено | Docker и compose (нет на машине), WinUI-интерфейс вживую, DPAPI и ACL на отдельной Windows-машине, Snyk, реальные push-сервисы |
Стенд: Windows 11, Node 24, PostgreSQL 17.10 (встроенная сборка), .NET 10 SDK. База аудита создавалась в UTF-8; первая попытка в WIN1251 дала ложную ошибку кодировки, её результаты отброшены.
## 3. Сводка
| Уровень | Открыто | Из них подтверждено вживую |
| ------- | ------- | -------------------------- |
| P0 | 2 | 1 |
| P1 | 17 | 10 |
| P2 | 32 | 6 |
| P3 | 31 | 3 |
Сверка с Grok: из его 28 находок 22 подтверждены, 4 подтверждены частично (F8, F11, N7, N10), 1 исправлена (F5), 1 не воспроизведена (N13). Сверх Grok — 57 новых замечаний, главное — в денежной логике, терминале, эксплуатации и офлайн-синхронизации.
## 4. Подтверждено на живом сервере
| ID | Что проверено | Результат |
| --- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| F2 | Сервер без токенов, `STANDALONE_ACCESS=manager`, запросы без авторизации | `GET /portal` 200, `GET /payroll` 200, `GET /finance` 200, **запись** `POST /professions` 201 |
| F2 | То же при `STANDALONE_ACCESS=employee` (значение по умолчанию в compose) | `GET /portal` 200, `GET /payroll` 200 анонимно |
| F3 | Вход менеджером, выход, повтор старой cookie | cookie содержит сам токен менеджера; после `DELETE /session` старая cookie даёт `GET /portal` 200 |
| F4 | 60 неверных входов подряд | 60 × 401, ни одного 429 |
| N1 | `POST /employees` | 500, `42P08 inconsistent types deduced for parameter $2` |
| N2 | Подтверждение смены, расчёт | оба 500, `23502 null value in column "occurred_at" of relation "staff_outbox"` |
| N4 | Одна смена 8 ч × 300 ₽, три пересекающихся расчёта с утверждением | 3 начисления в реестре, **7 200 ₽ вместо 2 400 ₽** |
| D5 | Отклонение смены, уже оплаченной по утверждённой ведомости | 200, сторно не создано, начисления остались |
| N12 | Ставка `rate_minor = 1e20` с лишним полем | принято, 200 |
| — | Расчёт за `month = 2026-13` | 500 |
| N3 | Сотрудник подписывает push на `https://127.0.0.1:18443`, затем отметка на терминале | сервер открыл соединение на внутренний адрес и отправил TLS ClientHello |
| N9 | Сотрудник Б регистрирует endpoint сотрудника А | 201, подписка перешла к Б |
| A4 | Ошибки 500 | наружу уходят код и текст ошибки PostgreSQL |
| N10 | Токен терминала | `GET /professions` 200 со всем справочником; `GET /org-units` отдаёт пустой список — эта часть находки Grok не подтвердилась |
| N13 | Два одновременных первых расчёта периода | **не воспроизведено**: обе заявки получили один расчёт |
| S1 | Устройство синхронизации отправляет смену `status: confirmed, worked_minutes: 2880` | смена сохранена подтверждённой, подтвердил «никто», журнал аудита пуст; расчёт начислил **14 400 ₽** |
| S2 | Устройство отправляет отметку с `occurred_at = 2020-01-01` | сохранена |
| S9 | Удаление точки, к которой привязано устройство синхронизации | ошибка `23502 null value in column "tenant_id"` — точку удалить нельзя |
| — | Заголовки портала | CSP `script-src 'self'`, HSTS присутствуют |
Для N4, D5 и S1 в базе аудита временно задавалось значение по умолчанию для `staff_outbox.occurred_at` (иначе мешает N2); после сценария оно снималось.
## 5. P0 — блокируют любое использование вне стенда
**K1. Открытый режим без токенов (F2, подтверждено вживую).** Если токены менеджера и терминала не заданы, любой запрос без авторизации получает права `STANDALONE_ACCESS` (`src/adapters/identity.ts:32-49`, `src/config.ts:45-47`). Корневой `compose.yaml` публикует порт 8080 на всех интерфейсах по HTTP и токенов не задаёт; в пилотном compose переменные подставляются без `:?`, поэтому пустая строка в `.env` молча открывает сервис.
**K2. Production-режим недостижим, пилот работает как development (F1).** Адаптер платформенного JWT — заглушка (`identity.ts:60-66`), standalone в production запрещён. Поэтому оба compose ставят `NODE_ENV=development` (`compose.yaml:5`, `deploy/pilot/compose.yaml:8`) — это обходит проверки «standalone запрещён» и «HTTPS обязателен» на пилоте с реальными ФИО и зарплатами.
## 6. P1 — денежная логика и работоспособность
| ID | Суть | Где | Подтверждение |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------- |
| K3 (N2) | Вставки в outbox без `occurred_at`: подтверждение смены, расчёт и утверждение всегда 500 | `src/portal.ts:1145-1163`, `src/domain/payroll/service.ts:770-784`, `917-935` | вживую |
| K4 (N1) | `POST /employees` всегда 500 из-за типа параметра `$2` | `src/portal.ts:465-483` | вживую |
| K5 (N4) | Пересекающиеся и «точка / все точки» расчёты начисляют смену повторно; при пересчёте не обновляется `period_end` | `service.ts:425-454`, `759-768`, `860-903`; индексы `0006:196-199` | вживую, 7 200 ₽ за 2 400 ₽ |
| K6 (D5) | Смены и строки утверждённой ведомости можно менять; у роли приложения есть DELETE на строки и UPDATE на ведомости, неизменяемость держится только кодом | `portal.ts:1024-1106`, `0006:296-300` | вживую |
| K7 (D2) | Импорт начислений и выплат теряет деньги: дельта считается по всем записям дня, а вставка по конкретному ключу | `src/operations.ts:1017-1052`, `scripts/import-owner-workbook.py:147,168,176` | по коду |
| K8 (D1) | Версии правил фиктивны: v1 изменяемая, даты действия не учитываются, условия точки не читаются, в строке ведомости нет дерева правила и ревизии смены — утверждённый расчёт нельзя воспроизвести | `service.ts:152-180`, `517-529`, `686-695` | по коду |
| K9 (D3) | Параметры пресетов кроме ставки, процента и гарантии не сохраняются, метрики кроме выручки и позиции не загружаются: официант по пресету получает 0 ₽, премия за порог выручки — 0 ₽ | `service.ts:295-299`, `488-500`; `src/portal-ui.ts:273-275` | по коду |
| K10 (F3) | В cookie лежит сам токен, выход не отзывает сессию; токен сотрудника бессрочный | `portal.ts:262-276` | вживую |
| K11 (F4, A2) | Нет ограничения частоты; каждый неверный токен открывает транзакцию в БД при пуле 10 соединений — отказ в обслуживании | `portal.ts:1276-1296`, `src/infrastructure/database.ts:9` | вживую (F4) |
| K12 (N3) | SSRF через push-подписку: разрешён любой `https://`, редиректы следуются, фрагмент ответа сохраняется | `src/domain/notification/push-dispatcher.ts:80`, `web-push.ts:226-258` | вживую |
| K13 (S1) | Офлайн-синхронизация: устройство создаёт и перезаписывает подтверждённые смены и строки табеля | `src/domain/sync/service.ts:784-990` | вживую, 14 400 ₽ |
| K14 (T4) | Отметки терминала не доходят до сервера: локальные ULID сотрудников сервер не знает (404), повтор каждые 15 с бесконечно; контракт в `docs/TERMINAL-API-REQUIREMENTS.md` расходится с кодом | `apps/KronaStaffTerminal/MainPage.xaml.cs:479-492`, `TerminalModels.cs:157` | по коду + ответ сервера 404 |
| K15 (T1) | Служба создаёт pipe `\\.\pipe\global\KronaStaffTerminalPipe`, интерфейс подключается к `\\.\pipe\KronaStaffTerminalPipe`: IPC не работает, свободное имя может занять любой процесс и получать отметки с ФИО | `apps/KronaStaffTerminal/Services/TerminalIpc.cs:84`, `:236` | проверено запуском |
| K16 (T2) | Служба от LocalSystem выдаёт операторам полный доступ к каталогу данных и сама ротирует там логи и меняет ACL — путь к правам SYSTEM через junction | `TerminalStore.cs:305-360`, `CrashLogService.cs:331-371`, `installer/KronaStaffTerminal.nsi:119` | по коду (механизм), PoC не делался |
| K17 (T5) | Биометрия: нет письменного согласия (152-ФЗ ст. 11), шаблоны остаются в резервных копиях после удаления, уведомления в Telegram — трансграничная передача | `MainPage.xaml.cs:685-776`, `TerminalStore.cs:96-100, 376-384` | по коду |
| K18 (D1 эксплуатации) | Нет резервного копирования пилотной базы с реальными ФИО, табелем и зарплатой; `BACKUP-RESTORE.md` — только шаблон | `docs/BACKUP-RESTORE.md`, `deploy/pilot/` | по коду |
| K19 (D4 расчёта) | Утверждается устаревший расчёт: ревизии смен и выручки не сверяются, предупреждения «нет выручки», «нет профиля оплаты» не сохраняются, сотрудник без профиля выпадает из фонда и его долю делят остальные | `service.ts:632-641`, `840-903` | по коду |
## 7. P2
**Сервер и расчёт**
- N5 — тест изоляции арендаторов удаляет сотрудников ролью приложения, у которой нет DELETE: CI с базой красный (подтверждено). Маршрутные тесты работают на поддельной базе, поэтому N1 и N2 не ловятся.
- N6 — `create-app-role.mjs` требует мигратора-суперпользователя (подтверждено).
- N7 — импорт-скрипт отключает проверку TLS при `--allow-self-signed` и принимает токен в аргументах командной строки. **Уточнение к Grok:** ответа 400 на `/professions` не будет — Fastify по умолчанию молча отбрасывает лишние поля (`removeAdditional`), поэтому ответ 201, а задуманный код профессии теряется.
- N12, D9 — нет верхних границ ставок и `additionalProperties:false` на назначении оплаты; `2026-13` и `2026-02-30` проходят шаблон и дают 500 (подтверждено).
- F7 — `Idempotency-Key` и таблица ключей не используются, хотя ADR и контракт приложения их требуют.
- F9 — healthcheck в Dockerfile по HTTP при HTTPS-only production.
- F10, A6 — проверка Origin пропускает запросы без Origin и с любым `Authorization`; практическая эксплуатация закрыта `SameSite=Strict`, но должно быть fail-closed.
- A3 — один общий токен терминала на все устройства, отметки до 180 дней назад.
- A4 — ошибки 500 раскрывают текст PostgreSQL (подтверждено).
- D6 — одна роль «менеджер» и считает, и утверждает, и выплачивает, без привязки к точке — разделения обязанностей нет.
- D13 — `additionalProperties:false` в схемах на деле не отклоняет лишние поля, а молча их удаляет: опечатки клиентов не видны.
- D7 — округление строк после деления фонда выплачивает на 1 ₽ больше фонда; вес фонда у правила без доли фонда теряет эту часть фонда.
**Уведомления и outbox**
- F6 — outbox пишется, но публикатора нет, `dispatchPending` не вызывается, строки в `pending` не повторяются никогда; отправка живёт в `setImmediate` и теряется при рестарте.
- U1 — некорректные ключи подписки роняют цикл отправки остальным устройствам.
- U2 — нет лимита подписок и таймаутов: одна отметка может породить тысячи запросов.
- U3 — нет захвата строки при отправке: при двух экземплярах возможна двойная отправка.
**Офлайн-синхронизация**
- S2 — время отметки задаётся клиентом без ограничений (подтверждено: 2020 год принят).
- S3 — гонки идемпотентности, потеря обновлений без проверки ревизии, одна плохая команда откатывает весь пакет из 100.
- S4 — начальная синхронизация отдаёт устройству весь арендатор, включая уволенных и всю историю минут.
- S5 — курсор теряет изменения за пределами первых 100 строк; изменения из портала устройство не видит.
- S6 — отметки через синхронизацию не пишут outbox и принимаются для уволенных.
- S9 — внешний ключ `ON DELETE SET NULL` на составном ключе ломает удаление точки (подтверждено).
**Терминал и служба**
- T3 — член группы операторов может расшифровать состояние: хеш PIN, токены сервера и ботов, токен обновлений, шаблоны отпечатков.
- T6 — служба и интерфейс перезаписывают один файл без межпроцессной блокировки: теряются смена PIN, счётчик попыток, блокировки.
- T7 — установщик не задаёт ACL на выбранный каталог установки; предсозданный пользователем каталог данных остаётся в его владении.
- T8 — терминал запускается с правами администратора после установки и автообновления; через диалог экспорта можно получить оболочку.
- T9 — проверка обновления сильная (HTTPS, SHA-256, Authenticode, закреплённый издатель), но файл проверяется в профиле пользователя и запускается позже (TOCTOU); на каждом терминале лежит токен чтения приватного репозитория.
- T10 — служба от SYSTEM загружает `libzkfp.dll` по PATH, если драйвер не установлен.
**Эксплуатация**
- D3 — самоподписанный сертификат на IP: у сотрудников не заработают PWA и push; ключ лежит рядом с pfx незашифрованным; продление ломает пиннинг на всех терминалах.
- D4 — обновление без тегированных образов и отката, без бэкапа перед миграцией, без `lock_timeout`.
- D5 — скрипт настройки терминала пишет не туда, где терминал читает состояние после переноса в `%ProgramData%`.
- D6 — `docker build` в CI внутри docker-раннера, down-миграции в CI не проверяются, пилотный compose тестами не покрыт.
## 8. P3 (кратко)
N8 эфемерные VAPID-ключи и кэш публичного ключа на сутки; N9 перепривязка чужого endpoint (подтверждено, но нужен секретный endpoint); N10 справочник профессий без проверки прав; N11 слабая валидация импорта; N13 гонка первого расчёта (не воспроизведена); N14 GET-запросы переписывают правило v1; F12 деньги в `number`; F13 плавающие теги в CI; F14 форматирование 16 файлов; A5 валидация и импорт до проверки прав, открытый `/openapi.json`; A7 новый токен сотрудника без `no-store`; A8 все менеджеры — один актор в аудите; A9 correlation id от клиента; U4 нет политики повторов и dead letter; U5 scope service worker не покрывает `/staff`; U6 выход не отзывает push-подписку; U7 FK без `tenant_id`; S7 очистка входящих не работает (передаётся не тот идентификатор); S8 CSRF и белый список scope устройств; W1 `/staff/*` отвечает 200 на любой путь; T11 DoS через IPC до проверки HMAC; T12 нет backoff в синхронизации терминала; T13 молчаливый откат службы к резервной копии, нет порога совпадения отпечатка; D8 документация расходится с кодом; D9 нет лимитов ресурсов контейнеров; D10 `.dockerignore` не исключает `deploy/pilot/.env` и секреты; D11 миграции из build-стадии от root; D12 smoke-тест ничего не проверяет и пишет в боевую базу; D14 нет редактирования PII в логах; D15 дефолты корневого compose; D16 коммиты без тела и смешанные по темам, 67 файлов bin/obj в истории.
## 9. Сверка с аудитом Grok
| ID Grok | Вердикт | Комментарий |
| ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| F1, F2, F3, F4 | подтверждено | F2–F4 вживую; к F1 добавлено: пилот обходит запрет через `NODE_ENV=development` |
| F5 | исправлено | неизвестный сотрудник → 404 |
| F6 | подтверждено, серьёзнее | строки `pending` не повторяются никогда |
| F7, F9, F12, F13, F14 | подтверждено | F13: `ajv allErrors` в проверке контрактов вектором не является |
| F8 | исправлено по форме, остаток P1 | ACL и HMAC есть, но имена pipe не совпадают (K15) |
| F10 | подтверждено, практически закрыто cookie `SameSite=Strict` | |
| F11 | API есть, но неработоспособно на реальной базе (K3) | |
| N1, N2, N3, N4, N5, N6 | подтверждено вживую | |
| N7 | частично | TLS и токен в argv — да; ответ 400 — нет (поля молча отбрасываются) |
| N8, N11, N12, N14 | подтверждено | |
| N9 | подтверждено вживую | эксплуатация требует знать секретный endpoint |
| N10 | частично | `/professions` — да; `/org-units` для не-менеджеров отфильтрован |
| N13 | не воспроизведено вживую | по коду возможно, оставлено P3 |
Чего в аудите Grok нет: денежные дефекты K6–K9 и K19, терминал K14–K17, отсутствие бэкапа, пилот в режиме development, ветка синхронизации целиком (она появилась после его аудита).
## 10. Что сделано хорошо
- Изоляция арендаторов: RLS с FORCE на всех 23 таблицах, составные внешние ключи, `set_config(..., true)` в транзакции, арендатор никогда не берётся из запроса.
- Роли базы: приложение без SUPERUSER и BYPASSRLS, журналы начислений и аудита — только чтение и вставка на уровне прав.
- Миграции: SHA-256, advisory lock, откат только последней и только явным флагом; работают мигратором без суперпользователя.
- Токены: сравнение за постоянное время, 256 бит, в базе только хеш; cookie `__Host-`, HttpOnly, Secure, SameSite=Strict; CSP `script-src 'self'`, экранирование в портале.
- Web-push: шифрование по RFC 8291/8188 и VAPID реализованы корректно, в уведомлениях нет персональных данных.
- Калькулятор: чистая функция, распределение фонда методом наибольшего остатка, тесты на реальной таблице владельца.
- Терминал: закрыты все прошлые находки по хранилищу и PIN (Argon2id, маркер первичной настройки в HKLM, отличие повреждения от временной ошибки), шаблоны отпечатков не уходят на сервер, автообновление проверяет подпись и издателя.
- Контейнеры не от root, read-only, образы по digest; секретов в истории нет; UTF-8 без BOM и LF соблюдены.
## 11. Порядок исправлений
1. **Разблокировать работу на базе:** K3 (одна строка в трёх местах), K4 и сразу интеграционные тесты этих маршрутов на PostgreSQL в CI.
2. **Закрыть открытый доступ и пилот:** K1, K2 (профиль `pilot` с обязательными токенами и HTTPS), K10, K11, бэкап K18.
3. **Деньги:** K5, K6, K7, K8, K9, K19 — до первого боевого расчёта.
4. **Синхронизация (уже в основной ветке):** сразу отключить приём смен и табеля от устройств и не регистрировать устройства в пилоте; затем закрыть K13, S2, S3, S9.
5. **Уведомления:** K12, F6, U1–U3.
6. **Терминал:** K14 (без этого отметки не попадают на сервер), K15, K16, K17.
7. P2 и P3 по мере работы.
Распределение по исполнителям — в файлах [TASK-CODEX.md](TASK-CODEX.md), [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md).
## 12. Решения владельца (2026-09-26)
**Роли и точки (закрывает вопрос D6):**
- **бухгалтер** готовит расчёт и ведомость выплат и отправляет пакет на подтверждение;
- **руководитель** получает оповещение, просматривает и подписывает пакет (или возвращает с комментарием); только его подпись регистрирует начисления и выплаты — руководитель проводит оплаты;
- **управляющий** привязан к своим точкам и работает с ними; утверждать и выплачивать не может;
- сотрудники и управляющие **могут работать на других точках**: привязка к основной точке сохраняется, но смена на другой точке допустима и относится к той точке, где отработана.
**Согласие на обработку биометрии (закрывает вопрос K17):** согласие обязательно; подтверждается в мобильном приложении сотрудника при авторизации.
Юридическая оговорка для исполнителя: 152-ФЗ требует для биометрии согласия в письменной форме. Электронное согласие равнозначно письменному, если подписано электронной подписью, в том числе простой, при наличии соглашения о её использовании (например, пункт трудового договора или локального акта). Текст согласия и такое соглашение готовит юрист.
## 13. Локальная ветка
Ветка `codex/payroll-foundation` удалена по решению владельца. Её незакоммиченные копии, уже влитые в основную ветку коммитом `3464998`, сохранены в `git stash` на случай сверки.