cyber: single-pass Aho-Corasick leak scan for many secrets (#262)#361
cyber: single-pass Aho-Corasick leak scan for many secrets (#262)#361larstalian wants to merge 2 commits into
Conversation
Leak detection scanned each response once per secret (times a few encodings), so cost grew O(secrets) per body. Replace the per-secret substring loop with one pure-Python Aho-Corasick automaton built from all guarded values' variants and scanned once per body — O(patterns + text), with no third-party dependency. An Aho-Corasick hit is exactly `pattern in body`, so results are identical: the `_MIN_GUARDED_LEN` floor, offline `_drop_contained` containment de-dup, empty-guarded early return, the live scanner's bytes->utf-8 decode, and its sorted() ordering are all preserved. Both mirror sites change and stay behavior-identical: the offline verifier (consequence.detect_leak, via new cyber_webapp/_ahocorasick.py) and the live runtime scanner (codegen/templates/app.py.j2::_scan_leaks, which inlines the matcher because it renders into a standalone container). Tests: fuzz equivalence of detect_leak and the matcher against the old `any(var in body)` reference (identical node-id sets), plus a many-secrets (500) case exercising the multi-pattern path. Closes #262 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review — solid core, but the live scanner regresses in the exact regime #262 targetsThanks for this. The algorithm is correct and the equivalence testing is exactly the right safety net — I independently confirmed the full picture below. What's good (verified locally on the PR head)
Blocker — the live scanner rebuilds the automaton on every response
Because CPython's substring
So in the many-secrets regime this issue is explicitly about, the live path gets ~20×+ slower per response — the opposite of the goal. (The offline path is fine because build amortizes across bodies.) FixBuild the automaton once per guarded set, not per response. Cleanest: build it when Everything else is ready to merge once the live-path build is hoisted. Nice work on the equivalence harness. |
The rendered runtime's _scan_leaks built a fresh _AhoCorasick from the guarded set on every call, and respond() calls it on every HTTP response. Since guarded is lifetime-constant server state, the pure-Python build was paid per response and never amortized — and because CPython's substring `in` is C-optimized, the build dominated, making the live many-secrets path ~20x+ SLOWER than the old per-secret loop (2000 secrets: ~5.6ms -> ~115ms; 10000: ~8.4ms -> ~1187ms). That is the opposite of #262's goal. Hoist the build: _build_leak_matcher(guarded) constructs the automaton once in _load_seed_and_init_state (startup) and stashes it on server state; _scan_leaks now only decodes and scans (O(text) per response). This mirrors the offline consequence.detect_leak, which already builds once and scans all bodies. Behavior is identical: same node-id results, sorted output, empty- value guard, bytes->utf-8 decode all preserved; a None matcher scans to []. Measured after the fix: per-response scan is ~0.011ms flat regardless of secret count (2000: 585x, 10000: 3000x+ faster than the per-response build). Add a regression test asserting _AhoCorasick.build() fires exactly once across 100 scans, and mirror the build-once note in _ahocorasick.py. Offline verifier untouched. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Fixed the live-path regression in f56c4a6 (pushed to this branch — no new PR). Root cause was exactly as called out: Fix — build once per guarded set, at startup:
Behavior identical: same node-id results, Verification:
|
Review — approve, ready to merge ✅Re-reviewed at f56c4a6. The live-path rebuild I blocked on is fixed correctly, and I independently reproduced everything on this HEAD. The fix (verified in the diff): Behavior-parity — holds. Scale — now delivers #262's goal on the live path. Per-response cost is the number that matters; build is one-time at startup, amortized over server lifetime. Measured (body ~1800 chars, miss path):
New per-response scan is flat in secret count (×3.9 across a 10,000× secret increase) vs old linear (×6540). At 2000 secrets: 1.29ms → 0.11ms/response (~11×); at 10k: ~40×, widening with N. Before this commit the live path paid build+scan per response (~97ms @2000 vs old 1.3ms — ~75× slower); it's now faster than the old loop, which was the whole point of #262. Correct, behavior-identical, and fast in the target regime. Approving — merge when ready. Nice work on both the algorithm and the clean build/scan split. |
Summary
Leak detection scanned each HTTP response once per secret (times a few encodings), so cost grew
O(secrets)per body. This replaces the per-secret substring loop with a single pure-Python Aho-Corasick automaton built from all guarded values' variants and scanned once per body —O(patterns + text), and no new third-party dependency (the pack is deliberately stdlib-only; the generated app renders into an isolated container).An Aho-Corasick match is exactly
pattern in body, so the result is identical, just faster.What changed
Both mirror sites change and stay behavior-identical:
consequence.detect_leak()now builds one automaton (newcyber_webapp/_ahocorasick.py) and scans each body once.codegen/templates/app.py.j2::_scan_leaksinlines the same matcher (the template can't import the pack — it renders standalone).Preserved exactly: the
_MIN_GUARDED_LENfloor, offline_drop_containedcontainment de-dup, empty-guarded early return, the live scanner's bytes→utf-8("replace") decode, and itssorted()output ordering. Empty/duplicate patterns are handled (empty ignored; shared patterns/payloads accumulate).Tests
detect_leakand the matcher against the oldany(var in body)reference (identical node-id sets), a shared/empty-pattern unit test, and a 500-secret case exercising the multi-pattern path.uv run ruff check .,uv run mypy src tests examples main.py, and the fulltests/suite (987 passed, 20 skipped) are green.Closes #262
🤖 Generated with Claude Code