110 KiB
110 KiB
Регистр дефектов и уязвимостей платформы
Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»). Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда; закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста считается НЕ закрытым). Класс:
vuln— эксплуатируемо или ослабляет защиту ·bug— неверное поведение ·hardening— защита в глубину / латентное. Статус:open·fixed(<commit>)·accepted-risk(<кем, когда>).
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-1 | hardening | minor | internal/pgstore/pg_test.go:89 |
Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «Digest возвращает плейнтекст» выживает — тест сверяет хранимое через тот же auth.Digest (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» — закрыто: internal/pgstore/pg_test.go — TestStoredCredentialIsAHashNotTheToken: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «Digest возвращает плейнтекст» ПАДАЕТ (проверено) |
fixed(P1, дерево сессии) | приёмка P0 (посадка №1) |
| PD-2 | vuln | major, ЖИВАЯ (не латентная) | cmd/tmplatformd/main.go:73-82 |
Нет ReadTimeout ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: net/http дренирует непрочитанное тело <256 КБ ВНУТРИ chunkWriter.writeHeader до отправки заголовка ответа (net/http/server.go:1389-1435), и этот чтение-шаг наследует отсутствующий дедлайн. Репродуцировано оркестратором на собранном бинаре: 50 полу-кормленных POST на охраняемый /v0/* → сервер отработал и залогировал 50×401 ms:0, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (ReadTimeout; для будущего SSE — per-conn дедлайны через ResponseController). Вторая половина (MaxBytesReader) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера — закрыто: ReadTimeout 30 с в httpapi.DefaultTimeouts; пин — TestHalfFedRequestIsDroppedByTheServer на РЕАЛЬНОМ http.Server. Живая проба: полу-кормленный POST теперь отпускается через 30.0 с (был бесконечно). Вторая половина закрыта LimitBody на поддереве /v0 и /auth. Побочное обязательство «ReadTimeout рубил бы и SSE» ОПРОВЕРГНУТО в P2 (PD-51/PD-63): net/http снимает дедлайн сам, помощник ClearReadDeadline удалён как воспроизводивший ровно этот дефект; поток пинит TestStreamOutlivesReadTimeout |
fixed(P1, дерево сессии) | приёмка P0 (security-линза + скептик + собственная репродукция) |
| PD-3 | bug | minor | internal/httpapi/middleware.go:57 |
Recover логирует сырой r.URL.Path на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — закрыто: Recover логирует route, не r.URL.Path |
fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
| PD-4 | hardening | minor | internal/pgstore/sessions.go:41 |
WHERE у Touch слабее, чем у Lookup (нет idle_expires_at > now): прямой вызов воскресил бы idle-истёкшую сессию. Через Require недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину — закрыто: клауза idle_expires_at > $2 добавлена; пин — TestTouchCannotResurrectAnIdleExpiredSession (посадка падает) |
fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
| PD-5 | bug | minor | internal/auth/middleware.go:36,45 |
Ошибки стора невидимы: сбойный Lookup → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), Touch глотается _ =. На проводе различать нельзя (оракул) — но лог обязан различать — закрыто: Authenticator.Log: сбой Lookup (кроме ErrNoSession) и сбой Touch уходят в ERROR с request_id; на проводе по-прежнему неразличимо |
fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) |
| PD-6 | hardening | info | internal/auth/csrf.go:51 |
GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) |
| PD-7 | bug | info | internal/pgstore/sessions.go:78 |
DeleteExpiredSessions никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) — закрыто: свип сессий раз в час в демоне (sweepSessions), плюс свип брошенных логинов раз в 15 минут |
fixed(P1, дерево сессии) | приёмка P0 |
| PD-8 | hardening | info | internal/auth/session.go:18 |
Писателя куки ещё нет; __Host- требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде — закрыто: auth.Cookies{Insecure} — dev-профиль меняет ИМЯ вместе с атрибутами (tm_session без __Host-), TM_PLATFORM_INSECURE_COOKIES=1, демон предупреждает в лог |
fixed(P1, дерево сессии) | приёмка P0 |
| PD-9 | bug | minor | cmd/tmplatformd/main.go:81 |
BaseContext возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж Shutdown мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown — закрыто: BaseContext — собственный контекст, отменяется ПОСЛЕ Shutdown; пин — TestShutdownDrainsInFlightRequests (посадка «BaseContext = сигнальный ctx» падает) |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-10 | bug | minor | internal/ingest/decoder.go:42,61,67 |
Три ужесточения декодера: (а) hello с пустым engine_run_id принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream hello (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 — закрыто: три ужесточения + ErrBadHandshake/ErrRepeatedHello; пины — TestHandshakeMustIdentifyTheStream и FuzzDecoder (4.4 млн исполнений, инварианты — оракулы) |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-11 | bug | minor | internal/pgstore/migrations/00002_readmodel.sql:137,177 |
Неиндексированные FK-каскады: notes.chapter_id и bank_decisions.term_id — каскадное удаление сканирует таблицы — закрыто: notes_chapter_idx + bank_decisions_term_idx; notes.unit_id уже был |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-12 | bug | info | internal/ingest/supervisor.go:82-84 |
Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в io.Discard часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 — закрыто: сбой синка ОСТАНАВЛИВАЕТ прогон (stop() после Ingest), а не дренирует его в io.Discard; пин — TestFailingSinkStopsTheRun. Политика ретраев самого синка — при постройке материализатора |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-13 | bug | info | internal/ingest/supervisor.go:64 |
Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 — закрыто: группа процессов (Setpgid + сигнал группе) закрывает обычную остановку; краш платформы закрывает cgroup юнита — deploy/tmplatformd.service (проверен systemd-analyze verify, живого прогона под systemd не было) |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-14 | hardening | info | internal/httpapi/server.go:79 |
readyz: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое — закрыто: собственный таймаут 2 с на ping; rate-limit на ops-слое (edge), в зоне не строим |
fixed(P1, дерево сессии) | приёмка P0 |
| PD-15 | bug | info | internal/ingest/resync.go:32 |
Деньги в ре-синке — float64, а usage_windows хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых — закрыто: деньги на шве — money.MicroUSD через big.Rat, округление ВВЕРХ; пины — TestSpendConvertsExactlyAndRoundsUp, TestParseUSDIsExactAndRoundsAwayFromZero |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-16 | bug | minor | internal/httpapi/server.go:81 |
readyz глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет — закрыто: ошибка ping уходит в ERROR |
fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
| PD-17 | bug | minor | Makefile:41-42 |
Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) — закрыто: один прогон сьюта под -race, баннер печатается только при наличии скипов |
fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
| PD-18 | bug | info | internal/pgstore/migrations/00002_readmodel.sql:9,139 |
Коммент шапки «engine vocabulary never crosses this seam» противоречит notes.reason (движковая причина хранится, не проецируется); коммент переписать честно — закрыто: шапка миграции переписана: исключение (notes.reason) названо там же |
fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
| PD-19 | bug | info | internal/ingest/resync.go:44 |
WorstFlagReason задокументирован «stored», а колонки в chapters нет — доккоммент или схема, одно из двух — закрыто: WorstFlagReason убран из аллоулиста — в контракте v0 у главы нет читателя для него |
fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
| PD-20 | bug | minor | internal/ingest/supervisor.go:78 |
Один сигнал остановки ТЕРЯЕТСЯ, если послан в первые миллисекунды жизни ребёнка: воспроизведено на стенде отдельным экспериментом (8 запусков, промах на нулевой задержке) и как флейк собственного теста PD-12 (1 падение из 3). Последствие серьёзнее самого промаха: единственный оставшийся механизм — SIGKILL по WaitDelay, а движок держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта, и после kill лок остаётся — закрыто: askToStop повторяет SIGINT на 30/120/400 мс с проверкой «процесс ещё наш» через os.Process; пин — TestFailingSinkStopsTheRun (25 прогонов подряд зелёные, до фикса падал) |
fixed(P1, дерево сессии) | самопроверка P1 (флейк собственного теста) |
| PD-21 | vuln | minor | internal/login/login.go:safeReturnTo |
Открытый редирект в ?return_to: /\evil.example проходил проверку — url.Parse читает это как обычный путь, а браузер нормализует \ в / и получает протокол-относительный URL, то есть чужой хост. Найдено ПОСАДКОЙ мутации: ослабление проверки тест пережило, значит тест был слабый — закрыто: аллоулист (первый символ /, второй не /, обратных слэшей нет, Scheme/Host/Opaque пусты), тест переписан на «каждый враждебный вход даёт ПУСТО»; посадка теперь падает. Дефект не покидал дерево сессии |
fixed(P1, дерево сессии) | самопроверка P1 (посадка мутации) |
| PD-22 | hardening | info | deploy/ |
Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: ReadTimeout ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (LimitNOFILE, edge) — строка заведена, чтобы это было решением, а не забывчивостью |
accepted-risk(платформа P1, 05.08) | самопроверка P1 |
| PD-23 | hardening | info | internal/pgstore/migrations/00001_identity.sql |
Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода | open | самопроверка P1 |
| PD-24 | bug | major | internal/pgstore/migrations/ |
Переиспользование номера миграции: удалённый 00003_usage.sql и новый 00003_credits.sql заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а DownTo на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — закрыто: выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт migrations.sha256 + TestReleasedMigrationsAreUnchanged; апгрейд со старого релиза пинится TestDatabaseAtAnOlderReleaseCatchesUp |
fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
| PD-25 | bug | major | internal/pgstore/credits.go, 00007_credits.sql |
Ключ идемпотентности леджера не содержал user_id: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку hold в леджере (деньги списаны, вернуть нечем), после чего освободившийся engine_run_id давал холд БЕЗ списания, а его релиз печатал деньги — закрыто: ключ стал (user_id, source, source_id), пустой ключ запрещён DDL, book_id перешёл на составной FK к books(id, owner_id) с on delete restrict, appendLedger возвращает «применилось», Hold падает при повторе. Пины: TestBookWithAnOpenHoldCannotBeDeleted, TestSecondHoldOnOneAttemptIsRefused, TestGrantIsIdempotentBySource |
fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
| PD-26 | bug | minor | internal/pgstore/credits.go |
Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. Settle/Release брали строку резервации раньше баланса — закрыто: lockBalance первым во всех операциях |
fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
| PD-27 | bug | minor | internal/pgstore/credits.go |
Settle принимал любую сумму: одно завышенное committed_usd уводило баланс в минус, дальше каждый прогон получал ErrInsufficientCredit без диагностики — закрыто: расчёт capped потолком холда, факт записан в note; пин TestSettlementIsCappedAtTheHold |
fixed(P1, дерево сессии) | ревью денежного пути · ревью «вне карты» |
| PD-28 | bug | minor | internal/ingest/supervisor.go |
cmd.Wait() на отменённой команде возвращает context.Canceled, а не *ExitError, поэтому исход читался как failed: штатный SIGTERM пометил бы ВСЕ идущие прогоны провалившимися — закрыто: исход из ProcessState, факт остановки едет в ошибке; пин TestStoppedRunKeepsTheEnginesOutcome |
fixed(P1, дерево сессии) | ревью стиля (клейм) + собственная проверка исполнением |
| PD-29 | vuln | minor | internal/login/login.go |
GET /auth/callback — неаутентифицированная ручка, ПИШУЩАЯ в БД, без лимита и без ретеншена: замерено 2000 строк за 2.28 с с одного хоста (~76 млн строк/сутки), строки отказов недостижимы через API и не удалялись никогда — закрыто: лимитер на колбэке, ретеншен журнала 180 дней свипом |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-30 | vuln | minor | internal/pgstore/identity.go |
Грант фри-тира выдавался за каждую новую пару (provider, subject) без учёта email_verified: провайдер с саморегистрацией превращал каждый новый sub в $5, потолок задавал только глобальный лимитер (~$864k/сутки на бумаге) — закрыто: грант только подтверждённой личности, аккаунт создаётся с нулём, начисление руками из админки. ⚠ Продуктовое следствие — вопрос владельцу в журнале |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-31 | bug | minor | internal/login/login.go |
discover держал мьютекс на время сетевого вызова без таймаута: шесть параллельных входов при медленном IdP заняли 4/8/12/16/20/24 с вместо ~4 — закрыто: запрос вне лока, свой таймаут 5 с |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-32 | vuln | minor | internal/login/login.go, cmd/tmplatformd/main.go |
Имя провайдера захардкожено "google" независимо от issuer, а State.Provider писался и не сверялся: смена issuer тихо кладёт чужие sub в старое пространство имён (новые аккаунты, старые недостижимы), а при двух провайдерах стейт одного редимится колбэком другого (IdP mix-up) — закрыто: TM_PLATFORM_OIDC_PROVIDER, сверка st.Provider в колбэке |
fixed(P1, дерево сессии) | ревью безопасности · ревью «вне карты» |
| PD-33 | vuln | minor | internal/auth/csrf.go |
Требование X-TM-Client снималось ЛЮБЫМ заголовком Authorization, включая мусорный: покрытие CSRF-слоя выбирал атакующий (сегодня упиралось в 401, но пережило бы любое послабление в present) — закрыто: снимает только валидный Bearer, через ту же функцию, что аутентифицирует |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-34 | bug | minor | internal/httpapi/serve.go, cmd/tmplatformd/main.go |
Две регрессии остановки: второй SIGTERM больше не прерывал дренаж (процесс жил ровно 15 с), а просроченный дренаж возвращал ошибку и давал exit 1 — при Restart=on-failure штатная остановка читается systemd как крах — закрыто: сигнал разрегистрируется при начале дренажа, просрочка логируется WARN и даёт exit 0, добавлена строка stopped |
fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
| PD-35 | bug | minor | internal/httpapi/middleware.go |
Лимит тела стоял самым внешним слоем, поэтому обещанное «ручка загрузки регистрирует свой, больший лимит» не работало: вложенный MaxBytesReader не может ослабить внешний, а контракт требует загрузку книги (23 МБ) — закрыто: лимит стал пер-маршрутным аргументом guard |
fixed(P1, дерево сессии) | ревью «вне карты» |
| PD-36 | hardening | minor | internal/httpapi/middleware.go |
Не было HSTS, CSP и запрета фрейминга; __Host- защищает запись куки, а не первый навигационный запрос — закрыто: Content-Security-Policy: default-src 'none'; frame-ancestors 'none', X-Frame-Options: DENY, HSTS в прод-профиле (в dev выключен: пин политики на localhost — долгая ошибка) |
fixed(P1, дерево сессии) | ревью безопасности |
| PD-37 | bug | minor | internal/login/login.go |
safeReturnTo заявляла защиту, которой не давала: проверка обратного слэша работала по уже раскодированной строке, а браузер декодирует цель редиректа ещё раз (/%5c/evil.example). Эксплуатируемого редиректа не получено, но три проверки из четырёх держались на поведении браузера — закрыто: проверка обеих форм, теста добавлены процент-кодированные входы |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-38 | hardening | info | internal/pgstore/sessions.go, 00005_identity_oauth.sql |
Отозванные сессии не удалялись до абсолютного срока (90 дней); журнал входов каскадно стирался вместе с аккаунтом, хотя объявлен доказательством для расследования — закрыто: свип берёт отозванные и idle-протухшие, login_events.user_id перешёл на on delete set null (строка анонимизируется, не уничтожается) |
fixed(P1, дерево сессии) | ревью безопасности |
| PD-39 | bug | info | internal/money/money.go |
Док обещал округление «от нуля», код округляет к +∞; отрицательные дроби не были покрыты тестом вовсе. Плюс USD() на MinInt64 печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — закрыто: док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания |
fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля |
| PD-40 | bug | info | internal/ingest/resync.go |
Отсутствующий/null/пустой committed_usd декодировался в 0 — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — закрыто: Spend стал указателем, пустая строка — ошибка |
fixed(P1, дерево сессии) | ревью «вне карты» |
| PD-41 | bug | info | internal/login/login.go, internal/httpapi/ |
Поверхность /auth/* отвечала stdlib-телами text/plain на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — закрыто: метод проверяется в обёртке с problem+json, добавлены login succeeded, sign-in rate limit engaged, provider_unreachable, стек паники, login_start_id для склейки двух половин входа |
fixed(P1, дерево сессии) | ревью логов (исполнением) |
| PD-42 | hardening | info | internal/login/login.go |
Лимит /auth/login глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge |
accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) |
| PD-43 | bug | info | internal/pgstore/credits.go |
Денежный контур не имеет ни одного вызывающего вне тестов: Hold/Settle/Release не зовутся, Sink не реализован, TypeSpend не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом |
open | ревью «вне карты» |
| PD-44 | hardening | info | internal/pgstore/ |
sqlc не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx. Нужна ратификация: адаптировать денежный пакет под sqlc в следующей сессии либо поправить направление |
open | ревью «вне карты» |
| PD-45 | hardening | info | internal/ingest/procgroup_unix.go |
syscall.Kill(-pid, SIGINT) идёт мимо os.Process, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал Wait; переписывать на pidfd-путь — отдельная работа |
open | ревью «вне карты» |
| PD-46 | hardening | minor | internal/httpapi/serve.go:33-40 |
Запинена ПРОВОДКА ReadTimeout, но не ЗНАЧЕНИЕ, с которым едет демон. Тесты строят свой Timeouts (fastTimeouts), поэтому посадка «DefaultTimeouts().Read = 0» проходит ВСЮ батарею зелёной — а main.go:121 берёт именно DefaultTimeouts(). Посадка «убрать ReadTimeout из NewServer» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. Фикс — тест на сами значения DefaultTimeouts — закрыто: httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet утверждает не литералы, а сам *http.Server, который строит NewServer(…, DefaultTimeouts()): каждый дедлайн >0, WriteTimeout ОБЯЗАН быть нулём (иначе резал бы SSE), ReadHeaderTimeout <= ReadTimeout, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: DefaultTimeouts().Read=0, снятие ReadTimeout из NewServer, снятие IdleTimeout, добавление WriteTimeout «для симметрии», снятие Unwrap |
fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) |
| PD-47 | bug | minor | internal/login/login_test.go:266 |
Закрытие PD-37 заявлено неверно: «в тесты добавлены процент-кодированные входы» — их там нет (список: //evil.example/, https://…, http:/…, /\evil.example, /\/evil.example, /\tevil, evil.example, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из ContainsAny» тоже переживает — на тестовых входах её дублирует проверка s[1]. Эксплуатируемого редиректа нет; не запинена именно та защита, ради которой заведён PD-37 — закрыто: в таблицу добавлены процент-кодированные входы (/%5c/, /%5C/, /%09, /%00, /%0d%0a) — их ловит ТОЛЬКО второй декод — и /%2f/evil.example, который ловит ТОЛЬКО проверка s[1] на декодированной форме; плюс FuzzSafeReturnTo, который пинит СВОЙСТВО независимым оракулом (url.URL.ResolveReference после браузерной нормализации \→/), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия u.Scheme/u.Host/u.Opaque НЕДОСТИЖИМЫ как отказ (при raw[0]=='/' схемы и Opaque не бывает, Host требует //), пин на них невозможен — оставлены бэкстопом, это названо в коде |
fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) |
| PD-48 | hardening | minor | internal/login/login.go:268-271 |
Правило PD-30 «грант только подтверждённой личности» не запинено ничем: удаление if !claims.EmailVerified { grant = 0 } проходит все тесты internal/login. pgstore.TestUnverifiedAddressStaysOffTheAccount пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — закрыто: login.TestSignupGrantGoesOnlyToAVerifiedIdentity гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (memStore теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие EmailVerified» падает |
fixed(P2, дерево сессии) | приёмка P1 (посадка M14) |
| PD-49 | hardening | minor | internal/login/login.go:239-242 |
Вторая половина PD-32 не запинена: удаление сверки st.Provider != h.cfg.Provider проходит все тесты. Сегодня провайдер один, поэтому свойство латентное — но заведено оно ровно под появление второго (IdP mix-up) — закрыто: login.TestStateFromAnotherProviderIsRefused подменяет провайдера в сохранённой строке состояния — форма, которую даёт появление второго провайдера, — и требует 400, отсутствия сессии, причины state_from_another_provider в журнале и НУЛЯ обращений к token endpoint. Посадка «убрать сверку» падает. Норму при этом закрывает не она, а PD-57 |
fixed(P2, дерево сессии) | приёмка P1 (посадка M19) |
| PD-50 | hardening | info | internal/auth/csrf.go:55-60 |
Закрытие PD-33 сформулировано сильнее кода: «снимает только ВАЛИДНЫЙ Bearer» — на деле Present только ПАРСИТ, поэтому Authorization: Bearer <мусор> требование X-TM-Client снимает. Привилегии это не даёт, проверено живой пробой (кука + мусорный Bearer + без заголовка → 401, не хендлер): безопасность держит правило «Bearer побеждает куку» в Present, а не «валидность». Посадка «снимать любым непустым Authorization» батарею переживает. Фикс — либо тест, либо честная формулировка доккоммента — закрыто формулировкой + пином: доккоммент cookieUnsafe переписан на то, что верно (Present ПАРСИТ, не валидирует; безопасность держит правило «есть Authorization ⇒ кука не участвует», а не валидность). auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay пинит именно это на пяти формах заголовка; посадка «падать обратно на куку при неразобранном заголовке» падает |
fixed(P2, дерево сессии) | приёмка P1 (посадка M30 + живая проба) |
| PD-51 | bug | minor | internal/httpapi/serve.go:118-127, STACK_DECISIONS §12 |
Механизм заявлен неверно. Утверждение «ReadTimeout убил бы и поток, поэтому стриминговый хендлер ОБЯЗАН снять read-дедлайн» на Go 1.26.5 не подтверждается: connReader.startBackgroundRead сам делает SetReadDeadline(time.Time{}) (net/http/server.go:687-698) и для запроса без тела вызывается ДО хендлера (:2062). Проверено исполнением на трёх формах запроса (GET без тела · POST с непрочитанным телом · POST с вычитанным телом) — поздний кадр доезжает во всех шести комбинациях, звали ClearReadDeadline или нет. Следствие: TestStreamOutlivesReadTimeout НЕ МОЖЕТ упасть от выхолащивания ClearReadDeadline (проверено); он пинит только Unwrap (эта посадка ловится). Код безвреден, ложны обоснование и строка в таблице пинов — закрыто, и вывод приёмки уточнён исполнением: механизм подтверждён (startBackgroundRead снимает дедлайн сам, server.go:687-698, для запроса без остатка тела — до хендлера, :2059-2062; по ходу хендлера не перевзводится — проверено по всем call sites). Но «код безвреден» неверно: см. PD-63. ClearReadDeadline УДАЛЁН, STACK_DECISIONS §12 переписан, TestStreamOutlivesReadTimeout переписан на настоящее свойство (поток переживает Read БЕЗ действий хендлера) и пинит Unwrap через ошибку Flush |
fixed(P2, дерево сессии) | приёмка P1 (посадка M24/M42 + отдельная проба) |
| PD-52 | hardening | minor | internal/pgstore/credits.go:233-243 |
Порядок блокировок (PD-26) не запинен ни одним тестом — снятие lockBalance из closeReservation батарею переживает. Дефект воспроизведён приёмкой НЕЗАВИСИМО, в форме, которая действительно даёт цикл: конкурентные Settle(run-1) и повторный Hold(run-1) — 2 взаимоблокировки на 150 раундов с инверсией, 0 с фиксом. Регрессионный тест написан приёмкой и лежит готовым к вставке в docs/platform-PROGRESS.md, раздел «Ратификация приёмкой P1». ⚠ Замер сессии «41 на 300» воспроизвести не удалось — их нагрузка не описана; принимается СО СЛОВ — закрыто: тест приёмки вставлен как pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock. ⚠ Замер приёмки не копировался, а ПЕРЕПРОВЕРЕН на своём стенде (PostgreSQL 18.4): с инверсией падает 5 прогонов из 5, 5–10 взаимоблокировок на 150 раундов; с фиксом 5 прогонов из 5 зелёные. Замер сессии P1 «41 на 300» так и не воспроизведён и остаётся СО СЛОВ |
fixed(P2, дерево сессии) | приёмка P1 (посадка M07 + собственная репродукция) |
| PD-53 | hardening | info | internal/httpapi/server.go:73-75 |
DefaultMaxBody не запинен: поднятие лимита поддерева до 1 ГиБ батарею переживает. Пер-маршрутность лимита (PD-35) — тоже только на ревью — закрыто: httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens фиксирует исполнением ПРИЧИНУ пер-маршрутности — вложенный БОЛЬШИЙ лимит не поднимает внешний, — поэтому возврат общего слоя молча урезал бы аплоуд-маршрут; TestDefaultBodyCapStaysAContractSizedNumber держит дефолт в полосе контрактного размера (посадка «1 ГиБ» падает), не превращаясь в change-detector на точное число |
fixed(P2, дерево сессии) | приёмка P1 (посадка M26) |
| PD-54 | bug | minor | docs/platform-PROGRESS.md:329-353 |
В журнале ДВЕ несовместимые формы GET /v0/usage: новая кредитная (строка 172) и старая подписочная (строка 329) с resets_at, windows[{period}] и хранением в usage_windows — таблице, которую снесла миграция 00006. Секция P0-эры не помечена superseded, а S3 идёт читать журнал именно за формой ручки — закрыто: подписочное тело ответа УДАЛЕНО из журнала, а не помечено баннером: S3 идёт туда за формой ручки и скопировал бы тело. Осталась одна форма — кредитная, в разделе «Что предлагаем в спеку (S3)»; из П-5 сохранены абзацы, не зависящие от модели денег, ссылка на хранение переведена на credit_ledger |
fixed(P2, дерево сессии) | приёмка P1 (свип доков) |
| PD-55 | bug | info | deploy/tmplatformd.service |
MemoryMax=2G объявлен как «bounds the control plane», но ограничивает cgroup ЮНИТА — а по собственному аргументу этого же файла (закрытие PD-13) в этом cgroup живёт каждый ребёнок-tmctl. Значит потолок общий на платформу и все идущие прогоны, и OOM-killer выберет самый жирный процесс — движок, который держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта: ровно тот исход, ради которого запрещён SIGKILL. То же про TasksMax=512. Латентно до появления воркера. ⚠ Под systemd не проверялось (нет sudo) — вывод из семантики MemoryMax=, не из замера — закрыто: семантика сверена по man 5 systemd.resource-control («absolute limit on memory usage of the executed processes in this unit… out-of-memory killer is invoked inside the unit»). MemoryMax=2G заменён на MemoryMax=80% — потолок машины, а не сервиса, как «last line of defense» и без знания о железе; TasksMax=512 оставлен с честным комментарием, что покрывает платформу и прогоны вместе; ограничение ОДНОГО прогона названо работой воркера (transient scope). Побочно найдено и закрыто следствие, которого в этой строке не было, — PD-64. ⚠ Под systemd не запускалось (нет sudo); systemd-analyze verify (systemd 259) — exit 0 |
fixed(P2, дерево сессии) | приёмка P1 (ревью деплой-юнита) |
| PD-56 | bug | info | internal/pgstore/credits.go:35-63 |
Grant/Adjust на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (credit_ledger_user_id_fkey), тогда как Balance на том же входе отдаёт ErrNoAccount. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — закрыто: appendLedger мапит нарушение credit_ledger_user_id_fkey в ErrNoAccount; pgstore.TestMoneyOperationsAgreeOnAMissingAccount требует одного ответа от Grant/Adjust/Balance/ReadAccount. Посадка «убрать сверку констрейнта» падает |
fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) |
| PD-57 | hardening | minor | internal/login/login.go:239-242 |
Защита от IdP mix-up не та, что требует действующая норма. RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр iss из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ iss; MAY — различные redirect URI на провайдера. Реализована собственная сверка st.Provider с h.cfg.Provider, а внутри одного хендлера это сравнение конфигурации с самой собой: start пишет туда то же значение. iss авторизационного ответа не читается вообще (iss ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (iss или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — закрыто реализацией нормы, а не обещанием. Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «iss из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран iss авторизационного ответа: Google его шлёт (authorization_response_iss_parameter_supported: true, сверено живьём). Сделано: auth_states.issuer (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — login.TestAuthorizationResponseIssuerIsChecked (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают |
fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) |
| PD-58 | hardening | minor | internal/config/config.go:60-61 |
Несоответствие собственной объявленной базовой линии. ENGINEERING_STANDARDS §2 берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским revoke; 7.5.2 (пользователь видит свои сессии) — работа П-1 — закрыто, и значение выровнено вместо сочинения оправдания. Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до 30 суток; бездействие 14 суток остаётся и строже нормы. Документ — STACK_DECISIONS §13: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — config.TestSessionClocksStayWithinTheDeclaredBaseline |
fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) |
| PD-59 | bug | info | docs/platform-PROGRESS.md, вопрос оркестратору №4 |
Канал не меняем — но решает это не тот довод, который обсуждали. Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. (1) SIGPIPE зависит от НОМЕРА дескриптора (os/signal: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает EPIPE). Замерено: поток на fd 1 → ребёнок УБИТ broken pipe; на fd 3 → write вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. (2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора в main движка: dup(1) в приватный fd, затем dup3(2,1,0). Он герметичен там, где предложенный приёмкой os.Stdout = os.Stderr дыряв: переживает var out = os.Stdout в зависимости, cgo и унаследованный fd 1 у внуков. (3) Дискриминатор, при котором переезд был бы прав — «ребёнок исполняет чужой код, наследующий stdio». Проверено: у нас нет — grep по backend/ не находит ни одного exec.Command вне тестов и ни одного cgo. Плюс сверено: ловушка bufio.Scanner, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — Buffer поднят до 1 МиБ и sc.Err() проверяется (decoder.go:45,115) |
закрыт ратификацией, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) |
| PD-60 | bug | minor | internal/ingest/supervisor.go, шов |
⚠ ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела. Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл events.jsonl с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). Строка живёт, но переформулируется вместе со строкой 103 — не «блокировать движок или ронять события», а «что делает движок, когда журнал не пишется». Обратное давление не спроектировано, и канал тут ни при чём. Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в write(2) — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: Ingest кормит Sink синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером events_dropped (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет |
open | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) |
| PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. (а) Сброс буфера на выходе: bufio.Writer вокруг потока плюс os.Exit/log.Fatal пропускает defer и теряет последние события — ровно те, что сообщают об окончании прогона. (б) Хвост при падении платформы: содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в <jobdir>/events.ndjson, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) |
open | приёмка P1 (абстрактный разбор) |
| PD-62 | bug | minor | internal/pgstore/identity.go:26-35, миграция 00005 |
login.State.StartID не персистился: колонки под него не было. Поле заведено в P1 и логируется колбэком как login_start_id — то есть ОБЕ строки лога, которые должны сшивать две половины входа, в проде пустые. Батарея этого не видела, потому что тесты internal/login ходят в in-memory стор, который хранит структуру целиком: свойство проверялось не на том объекте, который едет (тот же класс, что PD-46). Воспроизведено против живой БД раунд-трипом PutLoginState→TakeLoginState: положили REQ-ABC123, получили "" — закрыто: колонки start_id и issuer добавлены миграцией 00008, Put/Take их несут; пин — pgstore.TestLoginStateIsSingleUseAndExpires сравнивает структуру ЦЕЛИКОМ (reflect.DeepEqual), поэтому следующее поле без колонки упадёт здесь же. Посадки «потерять start_id» и «потерять issuer» падают |
fixed(P2, дерево сессии) | сессия P2 (найдено при правке PD-57) |
| PD-63 | vuln | minor | internal/httpapi/serve.go:118-127 (удалён) |
ClearReadDeadline воспроизводил PD-2 — тем самым вызовом, который был заведён как его исправление. Доккоммент объявлял его ОБЯЗАТЕЛЬНЫМ для стримингового хендлера. На полу-кормленном запросе (тело анонсировано, не дослано) дренаж внутри записи заголовка ответа — единственная граница соединения, и ограничен он ReadTimeout; снятие дедлайна ДО записи заголовка эту границу убирает. Замерено: хендлер остаётся внутри WriteHeader и через 4 с после ухода клиента, соединение держится. Вызов после флаша бесполезен — контекст уже отменён дренажем. Приёмка (PD-51) заключила «код безвреден», проверив только корректные запросы; случая, где функция помогает, нет вовсе — закрыто: функция УДАЛЕНА, §12 переписан, пин — httpapi.TestHalfFedStreamingRequestIsCutLoose (контекст стримингового хендлера отменяется в пределах Read) |
fixed(P2, дерево сессии) | сессия P2 (собственный замер при верификации PD-51) |
| PD-64 | bug | minor | deploy/tmplatformd.service |
Дефолтный OOMPolicy=stop уронил бы платформу из-за одного прожорливого прогона. Следствие того же факта, что PD-55 (дети-tmctl живут в cgroup юнита), но в той строке не названо: по man 5 systemd.service дефолт берётся из DefaultOOMPolicy= (системный — stop), а stop означает «the unit's processes are terminated cleanly by the service manager» — то есть OOM-килл ОДНОГО tmctl останавливает контрол-плейн и все остальные прогоны, после чего юнит уходит в oom-kill failed и его подхватывает Restart=on-failure — закрыто: OOMPolicy=continue проставлен явно с обоснованием; платформа переживает килл ребёнка и штатно закрывает его резервацию. ⚠ Под systemd не проверялось (нет sudo) — вывод из доки; systemd-analyze verify (systemd 259) — exit 0 |
fixed(P2, дерево сессии) | сессия P2 (ревью деплой-юнита при PD-55) |
| PD-65 | vuln | minor | internal/login/login.go:367-382 |
Обмен кода и загрузка JWKS шли БЕЗ дедлайна, тогда как discovery на том же пути ограничивает себя пятью секундами и называет причину («http.DefaultClient не имеет собственного таймаута, а вызов делается, пока человек ждёт»). В проде httpClient равен nil, поэтому обмен идёт на http.DefaultClient, а go-oidc строит набор ключей от context.Background(); WriteTimeout у сервера нет по проекту — значит издатель, который принял соединение и не отвечает, держит хендлер, пока клиент сам не уйдёт. Хуже того, набор ключей ОБЩИЙ: одна зависшая загрузка паркует ВСЕ параллельные входы (замерено ревью: два независимых входа ждали 12 с за одной загрузкой) — закрыто: identify ограничен providerTimeout 10 с; пин — login.TestAStalledProviderDoesNotHoldTheCallback на обеих ногах (token и keys), посадка «убрать дедлайн» падает |
fixed(P2, дерево сессии) | ревью P2 (линза oidc-security, подтверждено верификатором на боевой проводке) |
| PD-66 | bug | minor | internal/httpapi/serve_test.go, cmd/tmplatformd/main.go:121 |
Мой собственный фикс PD-46 закрывал только половину и утверждал, что обе. Тест строил свой сервер NewServer(…, DefaultTimeouts()) и на него же смотрел; проводка демона осталась ненаблюдаемой, а cmd/tmplatformd тестов не имеет. Замерено ревью: замена аргумента на Timeouts{Shutdown: 15s} оставляет make check зелёным (0 issues) и бинарь снова пиннит соединения — PD-2 в полном объёме. То есть ровно та форма, которую PD-46 и называл: свойство проверено не на том объекте — закрыто устранением КЛАССА, а не тестом: NewServer больше не принимает Timeouts и берёт DefaultTimeouts() сам, передавать нечего; коротким дедлайнам тестов служит неэкспортируемый serverWithTimeouts |
fixed(P2, дерево сессии) | ревью P2 (линза net-http) |
| PD-67 | vuln | minor | internal/pgstore/credits.go:236 |
FOR UPDATE не был запинен ничем, а комментарий теста утверждал обратное («Mutation caught: … or the FOR UPDATE that serialises it»). Последовательный тест лока не видит по построению, а инвариант «кэш = леджер» его тоже не ловит: без лока кэш и леджер уезжают ВМЕСТЕ, оба в минус. Лок — единственное, что мешает двум прогонам потратить один и тот же кредит — закрыто: pgstore.TestConcurrentHoldsCannotOvercommitAnAccount — 60 раундов по два конкурентных холда, каждый по отдельности посильный, вместе нет; посадка «убрать for update» падает 3 прогона из 3, баланс уходит в −$2. Комментарий последовательного теста исправлен |
fixed(P2, дерево сессии) | ревью P2 (линза sql-money) |
| PD-68 | bug | minor | internal/httpapi/server.go:111 |
/readyz рапортовал «готов» на базе БЕЗ схемы. Готовность доказывалась одним Ping, который успешен на любом достижимом Postgres, включая пустой. Migrate выключен по умолчанию, а deploy-инструкция делает миграцию отдельным шагом — значит «процесс поднят, схема не накачена» это НОРМАЛЬНАЯ середина выката, и инстанс в этом окне отвечал 200 ready, проваливая каждый запрос, который затем обслуживал — закрыто: Store.Ready сверяет goose_db_version с максимальным номером миграции, вшитой в бинарь; схема ВПЕРЕДИ бинаря готовности не отменяет (иначе выкат ронял бы старый инстанс). Пин — pgstore.TestReadinessRefusesADatabaseWithoutTheSchema, посадка «свести готовность к Ping» падает. ⚠ Первая редакция фикса печатала причину В ТЕЛО ответа и ради этого тащила pgstore в httpapi — и слой, и утечка состояния выката на НЕаутентифицированной ручке; снято при самопроверке, причина уходит в ERROR-лог |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-69 | bug | minor | internal/pgstore/store.go:42 |
Явный pool_max_conns из DSN молча отбрасывался. Проверка «MaxConns равен дефолту pgxpool» не отличает «оператор не выбирал» от «оператор выбрал ровно это число»: pgxpool кладёт свой дефолт в то же поле, что ParseConfig заполняет из pool_max_conns. Оператор, порезавший реплику под бюджет max_connections, получал наш 16 вместо своих 8 — и наоборот на 32-ядерной машине. С pool_min_conns хуже: дефолт pgx равен 0, поэтому явный 0 не мог пережить проверку НИКОГДА — закрыто: вопрос «упоминает ли DSN этот ключ» задан ПАРСЕРУ pgx, а не значению: pgxpool достаёт pool_* из RuntimeParams и удаляет их, поэтому второй pgx.ParseConfig их ещё видит — обе формы DSN, кавычки и service-файлы бесплатно. ⚠ Первая редакция фикса разбирала DSN РУКАМИ (33 строки собственного парсера) — велосипед, найден при самопроверке и снят; наши дефолты применяются только там, где оператор промолчал. Пин — pgstore.TestExplicitPoolSizesInTheDSNSurvive, включая случай, сломавший ПРЕДЫДУЩУЮ эвристику: пароль, содержащий имя ключа |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-70 | bug | major | internal/auth/middleware.go:57, internal/auth/cookie.go:62 |
Скользящее окно бездействия для БРАУЗЕРА не работало: Max-Age куки пишется один раз, на входе, и больше никем. Серверная строка скользила (Touch), кука — нет, а SetSession зовётся ровно из одного места — колбэка входа. Следствие: для куки — единственной презентации, которую код вообще умеет выдавать, — срок жизни сессии был ФИКСИРОВАННЫЕ 14 суток от входа независимо от активности; человек, заходящий каждый день, выкидывался на 14-е сутки при живой серверной сессии, а абсолютный срок не мог наступить никогда. Это же делало ложным §13 — документ соответствия ASVS 7.1.1, который зона только что написала — закрыто: при скольжении окна кука переиздаётся с тем же токеном (ротация — акт границы входа, не скольжения); пин — auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie (четыре случая: кука во второй половине окна, свежая кука, Bearer, упор в абсолютный срок). ⚠ Первая редакция фикса выдавала Max-Age равный idle-TTL безусловно — то есть кука могла пережить абсолютный срок и превратить каждый следующий запрос в 401 вместо чистого «вы вышли»; поймано самопроверкой, срок теперь берётся как min(idle, остаток абсолютного) |
fixed(P2, дерево сессии) | ревью P2 (линза doc-vs-code) |
| PD-71 | bug | info | internal/pgstore/migrations/00005_identity_oauth.sql:72 |
Down-путь 00005 не исполним на данных, которые его же up-путь делает законными, поэтому откат ниже версии 5 недоступен. Он восстанавливает users_email_key и email NOT NULL, а боевой код пишет email = NULL у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). Перепроверено моим прогоном, не принято со слов ревью: три реальных аккаунта (один с email = NULL, два с общим подтверждённым адресом) — DownTo(5) проходит, DownTo(4) падает с could not create unique index "users_email_key" (SQLSTATE 23505); первым срабатывает индекс, до NOT NULL выполнение не доходит. Данные целы — down транзакционный, Up() вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править 00005 запрещает append-only, а чужой down-текст новая миграция не заменяет — принято как ЦЕНА ПРАВИЛА: записано в STACK_DECISIONS §8 и в deploy/README.md разделом «Откат релиза: не ниже версии 5», чтобы оператор не узнал это в момент отката |
accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) |
| PD-72 | hardening | info | internal/httpapi/server.go:88 |
Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем. Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный MaxBytesReader только УЖЕСТОЧАЕТ: это запинено TestBodyCapIsPerRouteBecauseNestingOnlyTightens. Но возврат внешнего слоя в New батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. Строка заведена, чтобы это не выяснилось молча: тест обязан приехать ВМЕСТЕ с маршрутом загрузки |
open | ревью P2 (линза doc-vs-code) |
| PD-73 | vuln | minor | internal/login/login.go:67-74, :126 |
Дедлайн identify ограничивал ОЖИДАЮЩЕГО, а не саму загрузку ключей — то есть мой фикс PD-65 был неполон. Provider.Verifier берёт набор ключей, построенный на discovery, а go-oidc хранит его через context.WithoutCancel и ходит за ключами на http.DefaultClient, у которого таймаута нет. Загрузка, которая зависла, продолжает висеть после того, как ожидающий сдался, и все последующие входы встают на тот же inflight — то есть вход не поднимается и после того, как эндпоинт выздоровел, вплоть до перезапуска процесса. Воспроизведено ревью на боевой проводке — закрыто: New ВСЕГДА ставит httpClient с таймаутом providerTimeout, клиент передаётся NewProvider безусловно (oidc.ClientContext), и его подхватывает набор ключей; nil-случая больше нет — класс устранён, а не покрыт тестом. Пины — TestTheDefaultProviderClientIsBounded (посадка «клиент без таймаута» падает) и TestAHungKeyFetchDoesNotPoisonLaterSignIns (вход ПОСЛЕ выздоровления эндпоинта обязан пройти) |
fixed(P2, дерево сессии) | ревью P2 (линза the-fixes) |
| PD-74 | bug | minor | internal/auth/middleware.go:57 |
Скольжение окна залипало на последней четверти жизни сессии: каждый запрос становился записью. Touch прижимает новый дедлайн через least(now+IdleTTL, absolute_expires_at), поэтому как только now+IdleTTL перевалил за абсолютный потолок, idle_expires_at больше не двигается — а условие «осталось меньше половины окна» с этого момента истинно ВСЕГДА. На горячем пути это UPDATE по первичному ключу таблицы сессий и Set-Cookie на каждом аутентифицированном запросе (после PD-70 — ещё и кука). Найдено двумя линзами независимо — закрыто: скольжение выполняется только пока IdleExpiresAt строго меньше AbsoluteExpiresAt; пин — auth.TestTheSlideStopsOnceItCannotMoveTheDeadline (пять чтений дают ноль записей, а сессия с запасом по-прежнему скользит) |
fixed(P2, дерево сессии) | ревью P2 (линзы session-security и вне карты, независимо) |
| PD-75 | bug | minor | cmd/tmplatformctl/main.go:151 |
CLI сообщал о ПРИМЕНЁННОМ начислении как о провале, а повтор начислял второй раз. write выполняет денежную операцию, затем отдельным запросом читает баланс, и ошибку ЧТЕНИЯ возвращает как результат команды. Оператор видит ошибку, повторяет — а --key необязателен, и без него newKey чеканит новый ключ идемпотентности, поэтому второй прогон начисляет ещё раз. Достаточно обрыва соединения между двумя запросами — закрыто: после коммита команда не может отчитаться провалом; баланс читается как любезность, его отказ печатается предупреждением на той же строке |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-76 | bug | minor | internal/login/login_test.go |
Определяющее свойство пакета — «ничего выданного провайдером не персистится» — проверялось утверждением, которое не могло упасть. memStore.notes объявлено и не заполнялось ни одним методом, поэтому strings.Join(notes) всегда пусто, а Contains всегда ложно. Свойство названо в доккомменте пакета первой строкой — закрыто: мок пишет в saw КАЖДУЮ строку, которую поток ему передал, а утверждение проверяет и непустоту записи, и отсутствие среди неё и access-токена, и любого JWT-образного значения. Посадка «положить в стор сырой id-токен» падает |
fixed(P2, дерево сессии) | ревью P2 (линза water) |
| PD-77 | bug | info | internal/ingest/supervisor.go:104 |
Штатная остановка живого прогона поднимала тревогу о сломанном синке. Ingest проверяет ctx.Err() в начале цикла и возвращает context.Canceled как СВОЮ ошибку; Run отличить это от отказавшего синка не мог и на обычном SIGTERM писал ERROR «stream could not be materialized», который по замыслу означает «платформа ослепла, пока тратятся деньги», плюс звал stop() на уже останавливающемся прогоне — закрыто: отменённый runCtx больше не считается отказом синка |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-78 | hardening | info | internal/login/login.go (было), internal/httpapi/problem.go (было), internal/pgstore/identity.go, internal/httpapi/server.go |
Свод воды и дублей, найденный линзой лаконичности; каждый пункт проверен удалением. (а) login.Routes мемоизировал mux через sync.Once — при этом ВТОРОЙ и последующие guard молча игнорировались, то есть это была не оптимизация, а ловушка; снято. (б) login.Fail носил *http.Request, который никто не читал, и ради несовпадения сигнатур существовал шим httpapi.Fail; параметр и шим удалены, WriteProblem подключён напрямую. (в) upsertIdentityOnce держал собственный begin/rollback/commit при наличии inTx — второй экземпляр того же кода. (г) Deps.APIPrefix — ручка, которую не выставлял ни один вызыватель; заменена константой. (д) Ready делал Ping и следом запрос — два round trip на пробу каждые несколько секунд. (е) пять полей тестовых двойников, которые писались и не читались; blockingSink не блокировал. (ж) money.USD считал руками с комментарием про переполнение MinInt64 — заменён на big.Rat.FloatString(6), проверено побайтовое совпадение на всём диапазоне |
fixed(P2, дерево сессии) | ревью P2 (линза water) + самопроверка |
| PD-79 | bug | minor | internal/money/money.go:33-36 |
Строковый "null" читается как НОЛЬ денег. Кавычки снимаются strings.Trim ДО проверки s == "null", поэтому "committed_usd":"null" даёт настоящий 0 и НЕПУСТОЙ указатель, тогда как доккоммент поля обещает отказ на «absent, null and empty». Замерено приёмкой на живом декодере: голый null и отсутствие поля дают nil (защита работает), "" даёт ошибку, а "null" — Spend = 0 micro-USD, NON-NIL. На пути расчёта это «попытка стоила ничего»: холд освобождается, списания нет. Латентно до воркера; чинится перестановкой проверки перед Trim |
open | приёмка P2 (замер оркестратора №15 + панель) |
| PD-80 | vuln | major | internal/login/login.go:158,217-226 |
Вход выключается тремя запросами в секунду, и 429 колбэка ДОБИВАЕТ начатые входы. Ведро rate.NewLimiter(2, 20) одно на /auth/login И /auth/callback (login.go:122, единственный лимитер в зоне), а колбэк стирает login-куку ПЕРВОЙ строкой — до своей проверки лимитера. Следствие: анонимный поток на /auth/login не только закрывает вход всем (это PD-42, принято риском в форме «глобальный, не пер-адресный»), но и делает начатый вход невосстановимым: 429 приходит уже с Set-Cookie: __Host-tm_login=; Max-Age=0, поэтому повтор того же колбэка не пройдёт и после наполнения ведра. Воспроизведено приёмкой на боевом бинаре: 19 из 40 /auth/login прошли, дальше 429; честный колбэк с живым state получил 429 и стёртую куку. Независимо измерено панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра для начала и конца входа; пер-адресный лимит остаётся вопросом edge (PD-42) |
open | приёмка P2 (живая проба + панель, две независимые линзы) |
| PD-81 | standards | minor | internal/pgstore/credits.go:169-178 |
Заявленный ErrDuplicateHold на реальном пути недостижим: при ЖИВОЙ резервации повторный Hold падает на первичном ключе reservations_pkey (00007_credits.sql:66) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (balance == SUM(ledger), транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки |
open | приёмка P2 (замер оркестратора №15 + панель) |
| PD-82 | bug | info | internal/pgstore/credits.go:236-239 |
Hold на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт ErrInsufficientCredit (в lockBalance ErrNoRows трактуется как «нет кредита»), а не ErrNoAccount: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает Grant/Adjust/Balance/ReadAccount и на Hold не распространяется. Замерено приёмкой |
open | приёмка P2 (замер оркестратора №15) |
| PD-83 | hardening | minor | internal/httpapi/middleware.go:64 |
Фикс PD-3 не запинен в собственном месте: посадка «Recover логирует r.URL.Path вместо routeOf(r)» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в AccessLog ловится поимённо (TestAccessLogNamesTheRouteNotThePath). По правилу шапки этого файла половина PD-3 закрытой не считается |
open | приёмка P2 (посадка мутации) |
| PD-84 | hardening | minor | internal/login/login.go:222 |
Лимитер колбэка (фикс PD-29) не запинен: удаление всей проверки h.limiter.Allow() из callback оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала |
open | приёмка P2 (посадка мутации) |
| PD-85 | hardening | minor | internal/pgstore/identity.go:126-131 |
«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности: снятие условия in.EmailVerified в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление users.email) проходит батарею — TestUnverifiedAddressStaysOffTheAccount покрывает первый вход и переход в verified, но не обратный случай |
open | приёмка P2 (посадка мутации) |
| PD-86 | hardening | info | internal/pgstore/sessions.go:23,50 |
Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО: снятие absolute_expires_at > $2 из Lookup батарею переживает, потому что потолок навязывается через least($3, absolute_expires_at) в Touch (это запинено — TestSessionLifecycle). Снятие revoked_at is null из Touch тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии |
open | приёмка P2 (посадки мутаций) |
| PD-87 | hardening | info | internal/httpapi/server.go:82, internal/login/login.go:31 |
Ещё два незапиненных: снятие LimitBody с поддерева /auth и stateTTL 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса |
open | приёмка P2 (посадки мутаций) |
| PD-88 | bug | info | internal/auth/cookie.go:62-66 |
TTL меньше секунды выпускает куку БЕЗ атрибута Max-Age: int(ttl.Seconds()) даёт 0, а Go при MaxAge == 0 атрибут опускает ⇒ кука становится браузер-сессионной. Достижимо в последнюю секунду абсолютного срока (скольжение выдаёт min(idle, остаток абсолютного) при гарде ttl > 0) — то есть ровно тот исход, который самопроверка P2 называла нежелательным: кука переживает сессию, и следующий запрос даёт 401 вместо чистого «вы вышли». Подтверждено исполнением (ttl 500 мс/999 мс) |
open | приёмка P2 (панель ×2, подтверждено исполнением) |
| PD-89 | hardening | minor | cmd/tmplatformctl/main.go:143-150 |
Сминченный ключ идемпотентности не печатается при ошибке записи: PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без --key, newKey() чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же --key |
open | приёмка P2 (панель) |
| PD-90 | bug | info | cmd/tmplatformctl/main.go:112,134 |
grant и adjust делят пространство ключей source="admin": --key, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции |
open | приёмка P2 (панель) |
| PD-91 | doc | minor | deploy/README.md:33-42, deploy/tmplatformd.service:43,48 |
Установка, исполненная дословно, даёт нестартующий юнит: /srv/textmachine не создаётся ни одной командой наброска, а ReadWritePaths= без префикса - на несуществующем пути валит сборку mount-namespace при ProtectSystem=strict. Заодно ProtectHome=yes против решения владельца «книги живут в ~/books»: детям-tmctl домашние каталоги под этим юнитом недоступны — либо книги переезжают в /srv/textmachine, либо юнит получает BindPaths=. ⚠ Вывод из systemd.exec(5), под systemd не исполнялось (sudo нет) |
open | приёмка P2 (панель, сверено с докой) |
| PD-92 | bug | info | internal/ingest/supervisor.go:118-121 |
Дренаж стоит ДО cmd.Wait(), поэтому WaitDelay его не размораживает: io.Copy(io.Discard, stdout) ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает Run навсегда — backstop WaitDelay действует внутри Wait, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в backend/ вне тестов нет ни одного exec.Command и нет cgo. ⚠ Пере-диспозиция (эррата №15): вес понижен до info — весь пайп-путь supervisor.go объявлен ДЕВ-РЕЖИМОМ (D39.106 п.3), в проде родителя у движка нет и cmd.StdoutPipe() не существует; чинить только если дев-путь остаётся |
open | приёмка P2 (панель, граница зоны пере-проверена) |
| PD-93 | bug | info | internal/ingest/supervisor.go:109 |
Фикс PD-77 («наша остановка — не сломанный синк») сверяет только context.Canceled и пропускает context.DeadlineExceeded: как только у runCtx появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» |
open | приёмка P2 (панель) |
| PD-94 | bug | info | internal/httpapi/middleware.go:61-70 |
Recover глотает http.ErrAbortHandler — sentinel, которым хендлер намеренно обрывает соединение (net/http его не логирует и рвёт коннект). Замерено приёмкой: паника ErrAbortHandler превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь |
open | приёмка P2 (замер оркестратора №15) |
| PD-95 | doc | major (для промта эмиттера) | internal/ingest/events.go:6-12, docs/platform-PROGRESS.md §«Транспорт потока событий» |
Транспортная история зоны устарела против D39.106 в ДВУХ местах, и обе версии не совпадают с ратифицированной формой. Ратифицировано (D39.106 п.2 + research/25 §Форма): движок — транзиентный systemd-юнит на прогон, платформа ему НЕ родитель; события — events.jsonl в каталоге книги, append-only, как outbox-проекция уже закоммиченных строк SQLite (та же транзакция, что чекпойнт); платформа тейлит журнал, курсор (engine_run_id, seq) коммитится в одной Postgres-транзакции с эффектом. В research/25 вариант «платформа — родитель + пайп (stdout/fd)» получил 0 голосов из 15 («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон»), выделенный fd 3 — 0, stdout/journald как источник событий — 0. Что в зоне: (а) доккоммент events.go предлагает переезд на fd/сокет — отклонённая форма; (б) журнал зоны длинно доказывает «канал остаётся stdout» и объявляет переезд отклонённым, ссылаясь на PD-59, который сам superseded тем же D39.106 п.3 («PD-59 superseded; пайп-путь supervisor.go P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона»). Оба текста прочтёт эмиттер-сессия как задание. ⚠ Приёмка №15 это пропустила и в первой редакции строки сама сослалась на снятый PD-59 — исправлено здесь же |
open | приёмка P2 (эррата оркестратора №15, 07.08) |
| PD-96 | hardening | info | internal/httpapi/server.go:39-43, internal/auth/csrf.go:28 |
TrustedOrigins обещает отдельно развёрнутый фронт, но CORS-слоя нет вовсе. Живая проба: preflight OPTIONS с Origin: https://app.example.org получает 401 от гарда (браузерный preflight креденшелов не носит и не должен), заголовков Access-Control-* нет ни на одном ответе. Сценарий «фронт на другом origin» браузером сегодня неисполним: либо CORS приезжает вместе с контрактными ручками (П-1), либо фронт живёт на том же origin, и тогда TrustedOrigins — мёртвая ручка |
open | приёмка P2 (панель + живая проба) |
| PD-97 | hardening | info | internal/pgstore/credits.go:212-216 |
Settle/Release отбрасывают флаг applied у hold_release: если ключ ("run_release", engineRunID) уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же engine_run_id), но ровно на такой последовательности стоит ErrDuplicateHold |
open | приёмка P2 (панель) |
| PD-98 | doc | info | internal/pgstore/store.go:75-79 |
Случай «схема НОВЕЕ бинаря» в Ready беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит |
open | приёмка P2 (панель) |
| PD-99 | hardening | info | internal/ingest/supervisor.go:102 |
INFO-лог «engine started» пишет args целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут»). Закрыть вместе с воркером: логировать имя команды, не argv |
open | приёмка P2 (панель) |
| PD-100 | bug | minor | internal/login/login.go:245-261 |
Класс PD-5 закрыт в auth/, но не в login/: колбэк глотает ошибку стора (TakeLoginState) и ошибку discovery, репортя их как обычный отказ (unknown_state / discovery_failed) — сама ошибка не доезжает ни до одной строки лога, хотя pgstore/identity.go намеренно отличает «состояния нет» от инфраструктурного сбоя. Аутентификационный DB-outage снова выглядит штормом обычных отказов |
open | приёмка P2 (панель) |
| PD-101 | bug | minor | internal/login/login.go:507 |
login_events.ip_prefix берётся из r.RemoteAddr, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. X-Forwarded-For/Forwarded нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе |
open | приёмка P2 (панель) |
| PD-102 | doc | minor | internal/httpapi/serve.go:36-38 |
Доккоммент DefaultTimeouts утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: ReadTimeout в net/http (Go 1.26.5, server.go:990 wholeReqDeadline = t0.Add(ReadTimeout)) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему Read короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через ResponseController, а не «прогресс продлевает» |
open | приёмка P2 (панель, сверено с исходником Go) |
| PD-103 | hardening | minor | internal/auth/middleware.go:43,66 |
У обращений к БД на аутентифицированном пути (Lookup/Touch) нет собственного дедлайна — только голый r.Context(), а WriteTimeout у сервера отсутствует по проекту (SSE) и TimeoutHandler в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. readyz свой таймаут получил (PD-14) — горячий путь нет |
open | приёмка P2 (панель) |
| PD-104 | hardening | minor | internal/login/login.go:285-288 |
Фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком без агрегатного потолка: $5 за каждую новую пару (provider, subject) с подтверждённой почтой, единственный ограничитель — тот же лимитер входа. Агрегатного лимита грантов, счётчика аномалий и алерта нет нигде. PD-30 закрыл половину («только подтверждённой личности»); вторая половина — суточный потолок и наблюдаемость — вопрос владельцу (вынесен приёмкой) |
open | приёмка P2 (панель) |
| PD-105 | standards | minor | internal/ingest/decoder.go:96 |
Декодер и норматив зоны расходятся на дубле seq: декодер объявляет его фатальным ErrStreamGap, а ENGINEERING_STANDARDS §2 ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — status --json. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча. ⚠ Пере-диспозиция (эррата №15): вес ПОВЫШЕН до major-для-эмиттера — при ратифицированном транспорте (тейл events.jsonl с курсором, D39.106) повторное чтение строк после краша читателя — НОРМА, а не аномалия пайпа, поэтому норматив «at-least-once, дубль не ошибка» буквально верен, и фатальный отказ декодера прямо ему противоречит |
open | приёмка P2 (панель) |
| PD-106 | standards | minor | cmd/tmplatformctl/ |
Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста. В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе ([no test files]) |
open | приёмка P2 (панель) |
| PD-107 | hardening | info | internal/pgstore/migrations/00007_credits.sql:85, 00002_readmodel.sql:13 |
Удаление аккаунта обходит защиту PD-25: составной FK reservations → books(id, owner_id) on delete restrict блокирует DeleteBook, но users каскадит в reservations НАПРЯМУЮ, поэтому delete from users уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё |
open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) |