Skip to content

fix: критические цепочки из аудита — byreal-парсинг, rate-limiter, атомарность тика, sweep - #54

Open
loficoded wants to merge 4 commits into
mainfrom
fix/audit-critical-chains
Open

fix: критические цепочки из аудита — byreal-парсинг, rate-limiter, атомарность тика, sweep#54
loficoded wants to merge 4 commits into
mainfrom
fix/audit-critical-chains

Conversation

@loficoded

Copy link
Copy Markdown
Owner

Что это

Результат глубокого ревью шестью независимыми ревьюерами по зонам (intent/подписи, referee/router, chain/контракт, scoring/детерминизм, byreal-rail/парсинг, replay/DB). Итог ревью: 1 CRITICAL, 9 HIGH, 14 MEDIUM, 16 LOW. Этот PR вносит ту часть исправлений, которая проверяема локально юнит-тестами и не меняет детерминированную демо-арку и не требует редеплоя контракта. Остальное — осознанно отложено и перечислено ниже с готовыми решениями.

Исправления

1. lib/rail/byreal/parse.ts — инвертированный знак position_delta при закрытии шорта (HIGH)

close-интент не несёт side (closeShape), и buildOutcome безусловно негировал filledSize: закрытие шорта (покупка обратно) записывалось на верифицируемую credibility-поверхность как отрицательная дельта. Теперь знак выводится из остаточной позиции после филла: отрицательный residual (шорт) ⇒ положительная дельта. Полное закрытие (позиция flat — у венью нечего читать) сохраняет историческое поведение и задокументировано как ограничение. Скоринг не затронут — byreal-ауткамы в него не входят.

2. lib/rail/byreal/envelope.ts — инъекция конверта через stdout CLI (MEDIUM) + утечка top-level ключей (LOW)

parseEnvelope брал первый сбалансированный JSON-объект из stdout. Скомпрометированный CLI (или его зависимость) мог напечатать фейковый конверт перед настоящим и подменить fill. Теперь:

  • собираются все top-level объекты; не-конвертный JSON-баннер ({"debug":true}) пропускается, а не валит парс;
  • больше одного валидного конверта ⇒ ByrealParseError (fail-closed, рейл деградирует к seed-фоллбеку);
  • passthrough → strip: неизвестные top-level ключи конверта (например, эхо-credential) не попадают в executions.response_json.

3. lib/api/rate-limit.ts — неограниченный рост памяти (MEDIUM)

check() никогда не удалял записи: атакующий, варьируя ключ спуфингом X-Forwarded-For, наращивал по записи на запрос до OOM. Теперь раз в окно выполняется амортизированная зачистка; map ограничен числом уникальных ключей, активных в пределах одного окна.

4. lib/replay/orchestrator.ts — torn writes в пути тика (HIGH) ⚠️ требует integration-прогона

Тик писал intent (с резервацией nonce), policy events, execution и outcome отдельными autocommit-стейтментами. Падение между резервацией и insertOutcome навсегда сжигало nonce: повторный прогон молча скипал тик (анти-replay читал собственную рваную запись), и fill терялся. Тело тика обёрнуто в BEGIN…COMMIT на выделенном клиенте (тот же паттерн, что settleRound); ROLLBACK откатывает и резервацию. settleCredibility вынесен за транзакцию: он best-effort и глотает свои ошибки, но проглоченная ошибка БД внутри открытой транзакции Postgres абортила бы её и откатила seed-строки. Happy-path байт-в-байт идентичен — детерминизм арки сохранён.

Перед мержем прогнать bun run test:integration и bun run test:e2e с DATABASE_URL — в среде, где готовился PR, базы не было.

5. scripts/sweep-attestations.tshttps://localhost как feedbackURI навсегда on-chain (MEDIUM)

feedbackURI пишется в чейн без возможности обновления (канонический ERC-8004 не имеет updateFeedback). Дефолт зашивал бы вечно недостижимый URI и ломал проверку целостности у любого верификатора. Теперь без явного PUBLIC_BASE_URL свип падает сразу.

6. lib/db/repos/index.tsoperator-actions не реэкспортировался из barrel (LOW)

Единственный репозиторий вне индекса; коллизий имён нет.

Верификация

  • bun run typecheck — 0 ошибок
  • bun run lint — 0 ошибок
  • bun run test:unit988 pass / 0 fail (+6 новых регрессионных тестов: знак закрытия шорта/лонга, JSON-баннер, двойной конверт, strip ключей, зачистка rate-limiter)
  • bun run test:fuzz — 77 pass / 0 fail
  • test:integration / test:e2eне прогонялись (нет DATABASE_URL в среде) — см. п. 4

Осознанно отложено (нужны продуктовые решения)

Эти находки подтверждены, но их исправление меняет записанную демо-арку (видео + on-chain аттестации уже опубликованы), требует редеплоя верифицированного в Sourcify контракта или знания семантики venue:

Находка Серьёзность Почему отложено
Drawdown-брейкер мёртв: drawdown: '0' захардкожен во всех трёх местах сборки RefereeState (orchestrator, inject-attack, parse) — правило R3 не может сработать никогда CRITICAL Активация требует трекинга equity и меняет арку ⇒ расходится с видео и аттестациями
Мгновенное восстановление score после crash (α-сглаживание не удерживает штраф) HIGH Изменение скоринга ⇒ меняет арку
encodeScoreValue: потеря точности при кодировании score on-chain HIGH Меняет кодировку уже записанных аттестаций
Spend-cap не кумулятивен: remaining_budget = allocation каждый тик HIGH Меняет арку
Kill-switch TOCTOU: состояние читается раз на раунд, не перечитывается на тике HIGH Меняет арку
VectorMeritRegistry: нет transferOwnership-override, attestScore не валидирует identity, нет cooldown HIGH Редеплой + повторная верификация Sourcify; решение уровня сабмишена
Idempotency write-ahead в byreal-адаптере (повторная отправка при падении между fill и записью) HIGH Нужна семантика идемпотентности venue
Kill-switch fail-open при недоступности БД MEDIUM Задокументированный демо-выбор
max_slippage не пробрасывается в CLI MEDIUM Нужна поддержка флага в CLI
CHECK на raw_r, UNIQUE на executions MEDIUM Миграции; требуют БД для проверки

Каждую из них можно вносить отдельным PR после хакатона — детальные патчи готовы.

…от инъекции конвертов

parse.ts: close-интент не несёт side (closeShape), и buildOutcome безусловно
негировал filledSize — закрытие шорта записывалось на credibility-поверхность
с инвертированным знаком. Теперь знак выводится из остаточной позиции:
отрицательный residual (шорт) => положительная дельта. Полное закрытие
(позиция flat, residual нет) сохраняет историческое поведение — задокументировано.

envelope.ts: parseEnvelope брал первый сбалансированный JSON-объект из stdout.
Теперь собираются все top-level объекты: не-конвертный JSON-баннер
пропускается, а >1 валидного конверта — fail-closed (ByrealParseError),
чтобы подставленный фейковый конверт не мог заменить реальный fill.
Заодно passthrough -> strip: неизвестные top-level ключи конверта не
просачиваются в executions.response_json.

Регрессионные тесты: закрытие шорта/лонга с residual, JSON-баннер,
двойной конверт (инъекция), strip лишних ключей.
check() никогда не удалял записи из map — атакующий, варьирующий ключ
(например, спуфингом X-Forwarded-For), наращивал по записи на запрос до OOM.
Теперь раз в окно выполняется полная зачистка устаревших записей
(амортизированный O(1) на вызов); map ограничен числом уникальных ключей,
активных в пределах одного окна. prune() переиспользует ту же логику.
Регрессионный тест: 50 устаревших ключей удаляются следующим check().
…й транзакции

processAgentTick писал intent (с резервацией nonce), policy events, execution
и outcome отдельными autocommit-стейтментами. Падение между резервацией и
insertOutcome навсегда сжигало nonce: повторный прогон молча скипал тик
(анти-replay читал собственную рваную запись), fill терялся.

Тело тика обёрнуто в BEGIN..COMMIT на выделенном клиенте (тот же паттерн,
что settleRound); при ошибке ROLLBACK откатывает и резервацию — повторный
прогон дообрабатывает тик. settleCredibility вынесен ЗА транзакцию: он
best-effort и глотает свои ошибки, но проглоченная ошибка БД внутри открытой
транзакции Postgres абортила бы её и откатила seed-строки.

Happy-path байт-в-байт идентичен (те же строки, тот же порядок) — детерминизм
арки сохранён. ТРЕБУЕТ прогона integration-сьюта с DATABASE_URL.
…rator-actions

sweep-attestations.ts: feedbackURI пишется on-chain навсегда (канонический
ERC-8004 не имеет updateFeedback); дефолт https://localhost зашивал бы в чейн
вечно недостижимый URI и ломал проверку целостности у любого верификатора.
Теперь без явного PUBLIC_BASE_URL свип падает сразу.

lib/db/repos/index.ts: operator-actions не реэкспортировался из barrel —
единственный репозиторий вне индекса (коллизий имён нет).
Comment thread lib/api/rate-limit.ts
Comment on lines +46 to 54
// F-01 hardening: an attacker who can vary the key (e.g. by spoofing
// X-Forwarded-For) would otherwise add an entry per request that `check`
// never reclaimed, growing the map without bound until OOM. Sweep stale
// entries at most once per window so the map stays bounded by the number of
// *distinct keys seen within a single window* at amortized O(1) per call.
if (now - this.lastPrune >= this.windowMs) {
this.pruneBefore(cutoff);
this.lastPrune = now;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Security: Rate-limiter map still unbounded within a single window

The amortized sweep in check() only runs once every windowMs (gated by now - this.lastPrune >= this.windowMs). Between sweeps, every distinct key adds an entry that is not reclaimed until the next window boundary. An attacker spoofing X-Forwarded-For at high rate can therefore still inflate the map to the number of distinct keys observed within one window before any prune occurs — a much smaller but still real memory-pressure vector, not a hard bound. This is acknowledged in the PR description as the intended tradeoff, so it is informational: if windowMs is large (e.g. 60s) the in-window peak can be very large. Consider also pruning opportunistically when map.size crosses a hard cap, or shortening the prune cadence under load.

Was this helpful? React with 👍 / 👎

Comment on lines +106 to +120
// B-06 (banner injection): collect *all* well-formed envelopes, not just the
// first balanced object. A non-envelope banner object (`{"debug":true}`) is
// skipped instead of aborting the parse. But the genuine CLI prints exactly
// one envelope, so if more than one envelope-shaped object appears we refuse
// to guess which is authoritative — a prepended/appended fake envelope must
// not be able to substitute a forged fill. Fail closed: the caller degrades to
// the deterministic seed fallback.
const envelopes: ByrealEnvelope[] = [];
for (const json of candidates) {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch {
continue; // Not valid JSON (e.g. `{ not: json }`) — ignore this candidate.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Benign CLI JSON status line can disable the byreal rail

parseEnvelope now treats any top-level JSON object with a boolean success field as an envelope, and fails closed (ByrealParseError) when more than one envelope-shaped object is present. This is the correct anti-injection posture, but it also means a genuine CLI (or a dependency) that prints an incidental JSON status/log line containing a success boolean before/after the real envelope (e.g. {"success":true,"level":"info"}) will be misclassified as a second envelope and permanently degrade the rail to the seed fallback. Because settleCredibility swallows the error, this failure is silent except for one console.error. Worth confirming the real CLI never emits a success-bearing banner, or tightening the envelope discriminator (e.g. require data/meta/error presence) so non-envelope JSON is reliably skipped rather than counted.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 0 resolved / 2 findings

Addresses critical audit findings including short-position delta sign errors, rate-limiter OOM risks, and transactional atomic ticks. Ensure the rate-limiter map bounds are strictly enforced and refine envelope detection to prevent benign CLI output from triggering rail failures.

💡 Security: Rate-limiter map still unbounded within a single window

📄 lib/api/rate-limit.ts:46-54 📄 lib/api/rate-limit.ts:69-76

The amortized sweep in check() only runs once every windowMs (gated by now - this.lastPrune >= this.windowMs). Between sweeps, every distinct key adds an entry that is not reclaimed until the next window boundary. An attacker spoofing X-Forwarded-For at high rate can therefore still inflate the map to the number of distinct keys observed within one window before any prune occurs — a much smaller but still real memory-pressure vector, not a hard bound. This is acknowledged in the PR description as the intended tradeoff, so it is informational: if windowMs is large (e.g. 60s) the in-window peak can be very large. Consider also pruning opportunistically when map.size crosses a hard cap, or shortening the prune cadence under load.

💡 Edge Case: Benign CLI JSON status line can disable the byreal rail

📄 lib/rail/byreal/envelope.ts:106-120 📄 lib/replay/orchestrator.ts:479-488

parseEnvelope now treats any top-level JSON object with a boolean success field as an envelope, and fails closed (ByrealParseError) when more than one envelope-shaped object is present. This is the correct anti-injection posture, but it also means a genuine CLI (or a dependency) that prints an incidental JSON status/log line containing a success boolean before/after the real envelope (e.g. {"success":true,"level":"info"}) will be misclassified as a second envelope and permanently degrade the rail to the seed fallback. Because settleCredibility swallows the error, this failure is silent except for one console.error. Worth confirming the real CLI never emits a success-bearing banner, or tightening the envelope discriminator (e.g. require data/meta/error presence) so non-envelope JSON is reliably skipped rather than counted.

🤖 Prompt for agents
Code Review: Addresses critical audit findings including short-position delta sign errors, rate-limiter OOM risks, and transactional atomic ticks. Ensure the rate-limiter map bounds are strictly enforced and refine envelope detection to prevent benign CLI output from triggering rail failures.

1. 💡 Security: Rate-limiter map still unbounded within a single window
   Files: lib/api/rate-limit.ts:46-54, lib/api/rate-limit.ts:69-76

   The amortized sweep in `check()` only runs once every `windowMs` (gated by `now - this.lastPrune >= this.windowMs`). Between sweeps, every distinct key adds an entry that is not reclaimed until the next window boundary. An attacker spoofing `X-Forwarded-For` at high rate can therefore still inflate the map to the number of distinct keys observed *within one window* before any prune occurs — a much smaller but still real memory-pressure vector, not a hard bound. This is acknowledged in the PR description as the intended tradeoff, so it is informational: if `windowMs` is large (e.g. 60s) the in-window peak can be very large. Consider also pruning opportunistically when `map.size` crosses a hard cap, or shortening the prune cadence under load.

2. 💡 Edge Case: Benign CLI JSON status line can disable the byreal rail
   Files: lib/rail/byreal/envelope.ts:106-120, lib/replay/orchestrator.ts:479-488

   `parseEnvelope` now treats any top-level JSON object with a boolean `success` field as an envelope, and fails closed (`ByrealParseError`) when more than one envelope-shaped object is present. This is the correct anti-injection posture, but it also means a genuine CLI (or a dependency) that prints an incidental JSON status/log line containing a `success` boolean before/after the real envelope (e.g. `{"success":true,"level":"info"}`) will be misclassified as a second envelope and permanently degrade the rail to the seed fallback. Because `settleCredibility` swallows the error, this failure is silent except for one `console.error`. Worth confirming the real CLI never emits a `success`-bearing banner, or tightening the envelope discriminator (e.g. require `data`/`meta`/`error` presence) so non-envelope JSON is reliably skipped rather than counted.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Important

Your trial ends in 6 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant