feat(verify): the report gate — prove-or-suppress (/verify) [Verification Spine 2/3] - #156
Conversation
Part 2 of the Verification Spine. An agent or a noisy scanner can *claim* a bug that isn't real; the most reputation-defining thing a security tool can do is refuse to report anything it can't independently prove. /verify enforces exactly that policy. The Spine (all three linked): /poc (awarexone#144, merged) produces PROOF — the reproducible evidence bundle /verify (this) the GATE — re-derives; PROVEN -> reportable, else suppress /bench (PR-1) MEASURES the gate — proves its false-accept rate is ~0 report path (PR-3) ENFORCES it — nothing unproven ships Policy (decide(): pure, total, exhaustively tested): PROVEN bundle exists AND re-derivation confirms the marker -> reportable REFUTED re-derived but marker gone / endpoint safe -> suppress UNPROVEN no bundle, or no confirmable marker at all -> suppress INCONCLUSIVE unreachable / missing secret / mutating method held -> suppress Only PROVEN ships; a test enumerates all input combinations to guarantee that. "Can't confirm" is never "probably fine" — the burden of proof is on the finding, which is what keeps false accepts near zero (and is what /bench can measure). Re-derivation reuses ONLY merged code (poc_bundler.capture / parse_raw_request), so this stands alone on main — it does not import the still-open /replay branch. It composes with /replay but does a different job: replay monitors a known bug over time; verify makes the one-shot "may this be reported?" decision and writes verification.json for the report step to consume. Safety: SSRF-guarded re-send; PUT/DELETE/PATCH never re-fired without --confirm-unsafe (a verification must not re-trigger a destructive action); secrets read from env only and never written into the record. 17 tests (tests/test_verifier.py): every policy branch, the "only PROVEN ships" guardrail, header/secret resolution, all four verdicts end-to-end offline, no secret leakage, and the --require-proof gate exit codes. Verified live against demo/app.py (PROVEN up, UNPROVEN without marker, INCONCLUSIVE on a dead port). /verify command + CLAUDE.md entries. Additive: 4 files, +~430. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
shuvonsec
left a comment
There was a problem hiding this comment.
Strong foundation - pure/total decide(), safe json-only deserialization, SSRF-guarded reuse of poc_bundler, and a clean stable interface for #157. Verified 17 tests pass. Two hardening asks on the false-verdict edges (the whole point of the gate), plus test gaps:
- (false-PROVEN) A marker that merely appears in the fresh response mints PROVEN, with no cross-check against the original bundle response and status ignored - a server banner or a 500 page that reflects input would count as "proof." Please require the marker to also be present in the stored original response before treating fresh presence as proof, and record the fresh response's own sha (evidence_sha256 currently only stores the original's hash, so the record doesn't bind to what was re-fetched).
- (false-SUPPRESS) The marker is matched in the response BODY only. Header-based classes (open-redirect Location, CORS Access-Control-Allow-Origin, Set-Cookie, cache poisoning) will be wrongly REFUTED and their real findings suppressed.
pocalready exposes response_headers - please also search headers, or explicitly document the body-only limitation so #157 doesn't assume every class is gate-able. - (tests) Add: the
--confirm-unsafepositive path (an unsafe method actually re-fires when confirmed),load_markerprecedence (override > manifest > sidecar), andfind_bundlesschema filtering.
Also: sweep --require-proof exits 1 if ANY bundle isn't reportable - confirm that blunt behavior is intended before it's wired into CI.
# Conflicts: # CLAUDE.md
|
@shuvonsec thanks, these were the right things to push on — the whole point of the gate is the false edges. all fixed.
tests: added the ones you asked for — --confirm-unsafe positive path (it actually re-fires now, asserted), load_marker precedence (override > manifest > sidecar), and find_bundles schema filtering — plus the false-PROVEN and header-marker cases. 26 pass. on sweep --require-proof exiting 1 if any bundle isn't reportable: yeah that's intentional, it's meant as an all-or-nothing gate, but it's not wired into CI (unlike bench) — documented that in verify.md so it's a conscious choice if anyone adds it. rebased on main + fixed the CLAUDE.md conflict too. and agreed on merge order — this (#156) should land before #157. |
…tial host Addresses @shuvonsec's requested changes on /verify. 1. false-PROVEN: a marker appearing only in the fresh response no longer mints PROVEN. decide() now requires the marker to be present in the ORIGINAL recorded response (headers+body) before its presence now counts as proof — so a server banner or attacker input reflected on a 500 can't be mistaken for the finding. The record also now stores rederived_sha256 + rederived_status (what was actually re-fetched), alongside the original evidence_sha256. 2. false-SUPPRESS: the marker is now matched across the fresh response's HEADERS and body, so header-based classes (open-redirect Location, CORS ACAO, Set-Cookie, cache poisoning) are no longer wrongly REFUTED. Baseline check is header-aware too (reads response.http). 3. SSRF: added an initial-host guard (same blocklist safe_http uses on redirects) before the first request — the bundle URL is untrusted, so a tampered bundle can't make verification hit 169.254.169.254/localhost; it holds INCONCLUSIVE. Tests: +9 in tests/test_verifier.py — marker-only-in-fresh → UNPROVEN, record binds to the re-fetched response, header-based marker → PROVEN, initial-host guard holds INCONCLUSIVE without sending, the --confirm-unsafe positive re-fire path, load_marker precedence (override>manifest>sidecar), and find_bundles schema filtering. Reworked the pure decide() combinatorial guard for the new marker_in_baseline dimension. 26 pass. sweep --require-proof exiting 1 if ANY bundle isn't reportable is intentional (all-or-nothing gate); documented in commands/verify.md. Also merges main and resolves the CLAUDE.md conflict (keeps both /dashboard and /verify). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The claim this unlocks
That single guarantee is the difference between a trusted security tool and AI slop. This PR is the gate that enforces it.
Where it sits — the Verification Spine (three linked PRs)
/pocgives us proof,/verifydecides on it,/benchmeasures how trustworthy that decision is, and the report wiring (next PR) makes it mandatory. Each is useful alone; together they're a verification system.The policy (this is the whole product)
decide()is a pure, total function — every input path returns a verdict:PROVENREFUTEDUNPROVENINCONCLUSIVEOnly
PROVENships. A test (test_only_proven_is_ever_reportable) enumerates every combination of inputs and asserts that no other verdict is ever reportable — so if someone adds a verdict later, they're forced to consciously decide its reportability. "Can't confirm" is deliberately not "probably fine": the burden of proof is on the finding. That stance is what drives false accepts toward zero — and it's exactly what/benchcan measure.Merge-safety: depends only on merged code
Re-derivation reuses only
poc_bundler(already inmain) —capture(SSRF-guarded) +parse_raw_request+sha256_hex. It deliberately does not import the still-open/replaybranch, so this PR stands alone and passes CI onmaintoday. It composes with/replaybut does a different job: replay monitors a known bug over time; verify makes the one-shot "may this be reported?" decision and emitsverification.jsonfor the report step.Safety
tools/safe_http.py).PUT/DELETE/PATCHare never re-fired without--confirm-unsafe— a verification must not re-trigger a destructive action; it holds asINCONCLUSIVE.verification.json(asserted by a test).Quality
tests/test_verifier.py): every policy branch, the "only PROVEN ships" guardrail, secret resolution, all four verdicts end-to-end offline (via monkeypatched capture), no-secret-leak, and--require-proofgate exit codes.demo/app.py:PROVENwhile up,UNPROVENwithout a marker,INCONCLUSIVEagainst a dead port.4 files changed, 599 insertions(+), nothing removed. Pure policy core, thin network layer.Try it
Next PR (PR-3) wires this gate into the report path so an unproven finding can never be written into a report — completing the Spine.
🤖 Generated with Claude Code