Skip to content

feat(replay): finding replay / regression (/replay) — re-verify past findings - #152

Open
shivsin25 wants to merge 2 commits into
awarexone:mainfrom
shivsin25:feat/finding-replay
Open

shivsin25 wants to merge 2 commits into
awarexone:mainfrom
shivsin25:feat/finding-replay

Conversation

@shivsin25

Copy link
Copy Markdown
Contributor

What & why

docs/CAPABILITY-GAPS.md (agent runtime) lists: "No finding-replay/regression to re-verify past bugs on a target." This closes it — and it's the natural second half of the PoC bundler (#144): once /poc has captured a finding, /replay re-sends that exact request and tells you whether the bug is still live. Retests pay again and programs increasingly reward regression reports; this turns a one-shot PoC into a monitorable asset.

Two modes

  • run <bundle-dir> [--marker "signal"] — replay one bundle.
  • all [root] — sweep every poc-bundle/1 under root (default findings/) into a ranked regression-report.md + regression.json. This is "retest day" in one command.

Verdicts

🔴 STILL_VULNERABLE the bug's signal is still present
🟢 FIXED the signal is gone
🟡 CHANGED response differs from the PoC — review
⚫ UNREACHABLE endpoint no longer answers
⚪ INDETERMINATE no marker + no baseline, or a missing secret
⏭ SKIPPED_UNSAFE mutating method — needs --confirm-unsafe

How it decides (and where it's honest about limits)

  • Marker (confidence: confirmed) — a string that proves the bug (leaked email, reflected payload, error text). Present → still vulnerable; absent → fixed. Markers persist as marker.txt so later sweeps reuse them automatically.
  • Heuristic (confidence: heuristic) — with no marker, it compares status + response SHA-256. Byte-identical → still vulnerable (unchanged); different → CHANGED. It never claims FIXED from a hash alone (dynamic content also changes) — deliberately conservative.

Reuses existing infra (no new surface)

  • Re-sends through the repo's SSRF-guarded safe_http.safe_urlopen (via poc_bundler.capture).
  • Parses the bundle with poc_bundler.parse_raw_request, hashes with the same sha256_hex.
  • Trusts the manifest URL for scheme/host, so an http:// finding isn't replayed over https (avoids a false UNREACHABLE — caught and fixed during testing, with a regression test).

Safety

  • PUT/DELETE/PATCH skipped without --confirm-unsafe — replaying a mutating request could re-trigger the action.
  • Secrets are read from env vars only (like the bundle's repro.sh) and never written into replay.json or the sweep report (asserted by a test).
  • --fail-if-vulnerable exits 3 when anything is still vulnerable — for scheduled monitoring / CI.
  • Console output reconfigures to UTF-8 so verdict icons don't crash a non-UTF-8 terminal.

Testing

  • 25 tests in tests/test_replay.py — every verdict branch, header/secret resolution, bundle discovery + I/O, offline replay (via monkeypatched capture), and both CLI modes.
  • Pure core (decide_verdict, resolve_headers, render_report) tested without a socket; verified live end-to-end against the repo's demo/app.py (STILL_VULNERABLE while up, UNREACHABLE while down).
  • Additive only: 4 files changed, 758 insertions(+).

Try it

# after /poc has captured a finding:
tools/replay.py run findings/target.com-idor/evidence/F-12 --marker "victim@corp.com"
tools/replay.py all          # sweep everything, write a regression report

🤖 Generated with Claude Code

@shuvonsec shuvonsec left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Solid, well-scoped - pure core (decide_verdict/resolve_headers/render_report) is separated and genuinely tested, secrets are env-only with a leak test, unsafe methods gated. Verified 25 tests pass. Two asks (both about re-issuing live traffic):

  1. (scope) replay_one and especially replay all re-attack every stored target with no scope check, which contradicts Critical Rule #1 (READ FULL SCOPE before touching any asset). The repo already ships scope_checker.py - please gate the send through it, or at minimum warn/confirm per target, especially for all.
  2. (SSRF) The initial hop is unguarded: safe_urlopen only validates redirect hops, and load_bundle trusts bundle.json's exchange.url verbatim. A tampered/untrusted bundle makes replay all hit 169.254.169.254 / localhost on the first request. Validate the initial host before calling capture.
  3. (minor) Guard/redirect URLErrors are caught as verdict UNREACHABLE, which reads like "endpoint gone / fixed" - a bug that now 302s to an internal host gets masked. Worth distinguishing guard/redirect errors from real unreachability.

@shivsin25

shivsin25 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Great review, @shuvonsec — all three are about re-issuing live traffic responsibly, and you're right on each. Fixed in 73fd55d:

1. Scope (Critical Rule #1). replay now gates every send through scope_checker.py. Pass --scope '*.target.com' (repeatable) or --scope-file <file>; out-of-scope targets get an OUT_OF_SCOPE verdict and are never sent. replay all now refuses to run with no scope unless you pass --no-scope to explicitly assert every stored target is in scope; run warns when no scope is supplied. (Live: all with no scope → exit 2; a bundle for api.acme.com under --scope other-company.com → 🚫 OUT_OF_SCOPE, no request issued.)

2. SSRF on the initial hop. You nailed it — load_bundle trusts bundle.json's exchange.url and safe_urlopen only guards redirects. Added initial_host_blocked(), which validates the initial host with the same blocklist safe_http uses on redirects (metadata / private / loopback / link-local) before any request. A tampered bundle pointing at 169.254.169.254 now returns 🛑 BLOCKED and nothing is sent. (Verified live with a bundle whose URL is http://169.254.169.254/latest/meta-data.)

3. Guard/redirect errors masked as UNREACHABLE. Fixed: a new BLOCKED verdict is returned for SSRF-guard / too-many-redirects URLErrors (distinct from UNREACHABLE), so a bug that now 302s to an internal host reads as a live signal to investigate, not "endpoint gone / fixed." Real connection errors still map to UNREACHABLE.

+9 tests (34 total pass), covering scope in/out + all refusal, the initial-host guard on literals (169.254 / localhost / 127.0.0.1 blocked, public IP allowed), a tampered-bundle replay that returns BLOCKED without sending, and redirect-to-internal → BLOCKED vs a real connection error → UNREACHABLE. Ready for another look

@shuvonsec
shuvonsec force-pushed the main branch 3 times, most recently from 9efc2d0 to fb51a54 Compare October 8, 2026 10:46
@shivsin25
shivsin25 force-pushed the feat/finding-replay branch from 47ab19a to a7773d8 Compare October 12, 2026 13:06
Shivendra-Coherent and others added 2 commits October 12, 2026 18:48
Closes the docs/CAPABILITY-GAPS.md gap "No finding-replay/regression to
re-verify past bugs on a target". Builds directly on the merged PoC bundler
(awarexone#144): once /poc has captured a finding, /replay re-sends that exact request
and tells you whether the bug is STILL_VULNERABLE, FIXED, CHANGED, or
UNREACHABLE — turning a one-shot PoC into a monitorable, retest-ready asset.

tools/replay.py — two modes:
- run <bundle-dir> [--marker "signal"]: replay one bundle.
- all [root]: sweep every poc-bundle/1 under root (default findings/) into a
  ranked regression-report.md + regression.json ("retest day").

Verdict engine (pure, exhaustively unit-tested):
- Marker-based is the reliable signal (confidence: confirmed) — the string that
  proves the bug present -> STILL_VULNERABLE, absent -> FIXED. Markers persist as
  marker.txt so later sweeps reuse them.
- Without a marker, a status+SHA-256 integrity check is used and honestly
  labelled "heuristic"; it never claims FIXED from a hash diff alone.

Reuses existing infra: poc_bundler.capture (SSRF-guarded safe_http),
parse_raw_request, and sha256. The manifest URL is trusted for scheme/host so an
http:// finding isn't replayed over https (avoids a false UNREACHABLE).

Safety: PUT/DELETE/PATCH skipped without --confirm-unsafe (replaying a mutating
request could re-trigger it); secrets read from env only (like repro.sh) and
never written into replay.json or the sweep report; --fail-if-vulnerable exits 3
for scheduled monitoring/CI. Console output reconfigures to UTF-8 so verdict
icons don't crash a non-UTF-8 terminal.

25 tests in tests/test_replay.py (all verdict branches, header resolution,
bundle I/O, offline replay via monkeypatch, CLI). /replay command + CLAUDE.md
command-table and tools-list entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…CKED verdict

Resolves the change requests from @shuvonsec on the /replay PR. All three are
about safely re-issuing live traffic.

1. (scope) replay now gates every send through scope_checker.py (Critical Rule
   awarexone#1: read full scope before touching any asset). Pass --scope '*.target.com'
   (repeatable) / --scope-file <file>; out-of-scope targets are OUT_OF_SCOPE and
   never sent. `all` REFUSES to run with no scope unless --no-scope is passed
   (explicitly asserting every stored target is in scope). `run` warns when no
   scope is given.

2. (SSRF) the initial hop is now validated. load_bundle trusts the bundle URL,
   and safe_urlopen only guards redirects — so a tampered bundle could make the
   FIRST request hit 169.254.169.254 / localhost. initial_host_blocked() checks
   the initial host with the exact same blocklist safe_http uses on redirects,
   before any request; a blocked host returns BLOCKED and nothing is sent.

3. (verdict) guard/redirect URLErrors are no longer masked as UNREACHABLE. A new
   BLOCKED verdict is returned for SSRF-guard / too-many-redirects errors, so a
   bug that now 302s to an internal host reads as a live signal to investigate,
   not "endpoint gone / fixed".

Tests: +9 in tests/test_replay.py — scope in/out + `all` refusal, the initial-host
guard on literals (169.254/localhost/127.0.0.1 blocked; public IP allowed), a
tampered-bundle replay that returns BLOCKED without sending, and redirect-to-
internal classified BLOCKED vs a real connection error UNREACHABLE. 34 pass.
Verified live via the CLI. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

3 participants