diff --git a/POST_V01.md b/POST_V01.md index f0e3fec..bee8d52 100644 --- a/POST_V01.md +++ b/POST_V01.md @@ -86,7 +86,7 @@ The 2026 BOLA workflow is API-first, and the bottleneck is *coverage* — you ca --- -## Item 4 — Per-finding HTTP/curl reproduction in reporters (Priority: HIGH) +## Item 4 — Per-finding HTTP/curl reproduction in reporters (Priority: HIGH) — ✅ IMPLEMENTED (r36) ### What For every finding, emit a ready-to-paste reproduction: the exact request that triggered the bypass (method, URL, headers with credentials redacted or templated) as both a raw HTTP block and a `curl` one-liner, plus the differential (owner baseline vs. variant response: status, size, marker hit). @@ -102,6 +102,16 @@ Low-Medium (120–180K). No new analysis; it's a presentation-layer feature over ### Rationale A finding a hunter can't reproduce is a finding they can't submit. The most-repeated reporting lesson in the surveyed workflows is "impact-first, copy-paste PoC." Burp gives you the request inline; possession currently makes you reconstruct it from JSON. This is high value at low cost — pure leverage on existing data — and the Markdown reporter directly serves the PR-comment / report-writing use case the README already flags as backlog. Just below the detection items because it amplifies findings rather than producing new ones. +Shipped in two parts: `--report markdown` (PR-ready GFM, shipped r33) and `--report html` +(self-contained interactive report, shipped r34). Per r36 this item is fully complete: the +JSON reporter now embeds a `repro` object on every finding (`model.Finding.Repro`, populated +from `report.BuildRepro` while the in-memory `Variant` is live). The three repro fields — +`http` (raw HTTP/1.1 request block), `curl` (single-line shell command), and `differential` +(`baseline N → variant N · similarity S · ΔsizeD`) — appear in the `--report json` output +so downstream consumers and triage tooling can copy-paste reproductions without requiring a +markdown or HTML render pass. Credential values are redacted to `` +placeholders by default; `--repro-creds` emits live tokens. + --- ## Item 5 — Automated marker harvesting from owner baselines (Priority: MEDIUM) — ✅ IMPLEMENTED (r9) diff --git a/README.md b/README.md index 553618e..bdaea20 100755 --- a/README.md +++ b/README.md @@ -1250,6 +1250,22 @@ runs on the same input — safe for diffing and hashing. The shape is the `model.RunResult` aggregate (see [`internal/model/run.go`](internal/model/run.go)). +Each finding now includes a `repro` object with the copy-paste +reproduction rendered while the in-memory variant is live: + +```json +"repro": { + "http": "POST /users/1001 HTTP/1.1\nAuthorization: \n…", + "curl": "curl -X POST -H 'Authorization: ' 'https://…'", + "differential": "baseline 200 → variant 200 · similarity 0.97 · Δsize 0" +} +``` + +Credential values are **redacted to identity-tagged placeholders** by +default (``); add `--repro-creds` to emit live tokens. +Findings without a recoverable in-memory request (e.g. deserialized +results from `--replay`) omit the `repro` key. + ### `--report sarif` SARIF 2.1.0, suitable for GitHub Code Scanning. One rule per finding diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 10920b8..1fb164a 100755 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,8 +47,10 @@ Items deliberately left out of v1.0 to keep the scope bounded: ### Detection / evaluator - AuthMatrix-style declarative evaluator (the `Evaluator` interface seam in `internal/detect/evaluator.go` is ready for it). -- Activate `idor-cross-tenant` (D31): add a per-identity `tenant` - field to the role-matrix schema so the dormant code path can fire. +- ~~Activate `idor-cross-tenant` (D31): add a per-identity `tenant` + field to the role-matrix schema so the dormant code path can fire.~~ + **Shipped** (P8): `Identity.Tenant` field in role-matrix schema; cross-tenant + swaps automatically emit `idor-cross-tenant` class (critical severity). - Distinguish "denied" from "different resource" at the low-similarity end of the ladder (current v1.0 limitation, see branch 10 of `internal/detect/evaluate.go`). @@ -60,14 +62,18 @@ Items deliberately left out of v1.0 to keep the scope bounded: Off by default, rate-sensitive; mutually exclusive with `--replay`. ### JWT (deeper attacks) -- RS256→HS256 alg-confusion (sign attacker key with server's public - key as HMAC secret). -- `kid` injection (path traversal, SQL injection, command injection - via the `kid` header). -- JKU / x5u / JWK spoofing (point the verifier at attacker-controlled - JWKS). -- HMAC secret cracking against captured tokens (offline dictionary + - rule-based mutation). +- ~~RS256→HS256 alg-confusion (sign attacker key with server's public + key as HMAC secret).~~ **Shipped** (`--jwt-alg-confusion`). +- ~~`kid` injection (path traversal, SQL injection, command injection + via the `kid` header).~~ **Shipped** (`jwt-kid-injection`, always-on + when a JWT is detected; 6 payloads across path-traversal and sqli classes). +- ~~JKU / x5u / JWK spoofing (point the verifier at attacker-controlled + JWKS).~~ **Shipped** (`jwt-jwks-spoof`): inline `jwk` header embed and + `jku` redirect-to-attacker-URL variants per token location. +- ~~HMAC secret cracking against captured tokens (offline dictionary + + rule-based mutation).~~ **Shipped** (`jwt-hmac-crack`): 16-entry default + wordlist, cracked secret re-signs with role=admin escalation, capped at + 500 attempts per token location. ### Input formats - ~~Postman collection v2 parser.~~ **Shipped.** @@ -88,7 +94,10 @@ Items deliberately left out of v1.0 to keep the scope bounded: traces).~~ **Shipped** (`--report html`): single self-contained document, severity-grouped findings, collapsible repro blocks, progressive-enhancement severity filter. -- Markdown reporter for PR comments. +- ~~Markdown reporter for PR comments.~~ **Shipped** (`--report markdown`): + GitHub-flavored Markdown, impact-first, per-finding collapsible repro + blocks (raw HTTP + curl), severity-grouped, credentials redacted by + default (`--repro-creds` to show live tokens). - ~~Suppression / baseline file (`possession.allowlist`) so re-runs only surface new findings.~~ **Shipped in v1.2.** diff --git a/internal/cli/scan.go b/internal/cli/scan.go index 67e1bf4..2ce4681 100755 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -537,11 +537,22 @@ func runScan(cmd *cobra.Command, args []string) error { // in verdictCounts). filteredFindings := []model.Finding{} omittedByMinConf := 0 + reproOpts := report.ReproOptions{ShowCreds: scanReproCreds} for _, f := range res.Findings { if f.Confidence < scanMinConfidence { omittedByMinConf++ continue } + // Pre-render the copy-paste reproduction while f.Variant is still + // live (Variant is json:"-" and will not survive serialization). + // Populate model.Finding.Repro so it appears in the JSON report. + if rp, ok := report.BuildRepro(f, reproOpts); ok { + f.Repro = &model.FindingRepro{ + HTTP: rp.HTTP, + Curl: rp.Curl, + Differential: rp.Differential, + } + } filteredFindings = append(filteredFindings, f) } allFindings = append(allFindings, filteredFindings...) diff --git a/internal/model/finding.go b/internal/model/finding.go index 5567481..c264b88 100644 --- a/internal/model/finding.go +++ b/internal/model/finding.go @@ -25,6 +25,16 @@ type Finding struct { ASVS []string `json:"asvs"` // e.g. ["v5.0.0-8.2.2"] Evidence Evidence `json:"evidence"` + // Repro is the pre-rendered copy-paste reproduction for this finding: + // the exact mutated HTTP request (raw block + curl one-liner) plus a + // one-line differential of the owner-baseline vs. variant response. + // Populated by the scan pipeline from the in-memory Variant before + // serialization (Variant is not serialized). Nil when the finding + // carries no recoverable request (e.g. a deserialized JSON finding). + // Credential values are redacted to placeholders by + // default; set --repro-creds to emit live tokens. + Repro *FindingRepro `json:"repro,omitempty"` + // Convenience fields for serialization — fully derivable from // Endpoint+Variant but flattened so JSON consumers don't need to // cross-reference. @@ -34,6 +44,25 @@ type Finding struct { Identity string `json:"identity,omitempty"` } +// FindingRepro is the pre-rendered reproduction payload embedded in a Finding. +// Fields mirror report.Repro but live in the model package so they can be +// serialized by the JSON reporter without an import cycle (report → model is +// fine; model → report would cycle). +type FindingRepro struct { + // HTTP is the raw HTTP/1.1 request block: request line + sorted headers + + // Cookie + blank line + body. Credential header values are redacted to + // placeholders unless --repro-creds was set. + HTTP string `json:"http"` + + // Curl is the equivalent single-line curl command, shell-quoted for direct + // paste into a terminal. + Curl string `json:"curl"` + + // Differential is the compact baseline→variant summary: + // "baseline 200 → variant 200 · similarity 0.97 · Δsize 0". + Differential string `json:"differential"` +} + // Evidence captures the observed signals that justified a Finding. // // Notes is a human-readable list of which signals fired ("similarity 0.94 diff --git a/internal/report/reporter_test.go b/internal/report/reporter_test.go index a03dcc9..ba7a0ed 100644 --- a/internal/report/reporter_test.go +++ b/internal/report/reporter_test.go @@ -271,3 +271,114 @@ func TestSARIF_RoundTripContent(t *testing.T) { t.Fatalf("re-parse: %v", err) } } + +// ─── JSON repro block (POST_V01 Item 4, r36) ────────────────────────────── + +// mkRunWithReproBlock returns a RunResult with one finding that carries a +// populated model.FindingRepro block (simulating what scan.go populates from +// report.BuildRepro before the Variant pointer is dropped). +func mkRunWithReproBlock() *model.RunResult { + run := mkRun() + // Inject a Repro block into the first finding to mirror what scan.go sets. + run.Findings[0].Repro = &model.FindingRepro{ + HTTP: "POST /users/1001 HTTP/1.1\nHost: api.example.com\nAuthorization: \n\n{\"note\":\"test\"}", + Curl: "curl -X POST -H 'Authorization: ' 'https://api.example.com/users/1001'", + Differential: "baseline 200 → variant 200 · similarity 0.97 · Δsize 0", + } + return run +} + +func TestJSON_FindingWithRepro_ContainsReproBlock(t *testing.T) { + // A finding with Repro set must produce a "repro" object in JSON output. + var buf bytes.Buffer + if err := (JSONReporter{}).Render(mkRunWithReproBlock(), &buf); err != nil { + t.Fatalf("render: %v", err) + } + out := buf.String() + + // Top-level repro key must be present. + if !strings.Contains(out, `"repro"`) { + t.Errorf("JSON output missing top-level repro key:\n%s", out) + } + // All three sub-fields must appear. + for _, field := range []string{`"http"`, `"curl"`, `"differential"`} { + if !strings.Contains(out, field) { + t.Errorf("JSON output missing repro field %s:\n%s", field, out) + } + } + // The redacted placeholder must appear in the HTTP block. + if !strings.Contains(out, "") { + t.Errorf("repro HTTP block missing identity placeholder:\n%s", out) + } +} + +func TestJSON_FindingWithoutRepro_NoReproKey(t *testing.T) { + // A finding whose Repro is nil (no recoverable request) must NOT emit a + // "repro" key in JSON — the omitempty tag should suppress it. + var buf bytes.Buffer + if err := (JSONReporter{}).Render(mkRun(), &buf); err != nil { + t.Fatalf("render: %v", err) + } + // mkRun() findings have no Repro set. + if strings.Contains(buf.String(), `"repro"`) { + t.Errorf("JSON output should not include repro for findings without Repro set:\n%s", buf.String()) + } +} + +func TestJSON_ReproStable_ByteIdentical(t *testing.T) { + // Repro-containing output must be byte-identical across two renders + // (D26 determinism extends to the new field). + r := JSONReporter{} + run := mkRunWithReproBlock() + var a, b bytes.Buffer + if err := r.Render(run, &a); err != nil { + t.Fatalf("first render: %v", err) + } + if err := r.Render(run, &b); err != nil { + t.Fatalf("second render: %v", err) + } + if a.String() != b.String() { + t.Errorf("JSON repro output not stable across two renders") + } +} + +func TestJSON_Repro_ValidJSON(t *testing.T) { + // The full JSON output with a repro block must parse as valid JSON and + // the repro sub-object must have the expected shape. + var buf bytes.Buffer + if err := (JSONReporter{}).Render(mkRunWithReproBlock(), &buf); err != nil { + t.Fatalf("render: %v", err) + } + var parsed struct { + Findings []struct { + Repro *struct { + HTTP string `json:"http"` + Curl string `json:"curl"` + Differential string `json:"differential"` + } `json:"repro"` + } `json:"findings"` + } + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Findings) == 0 { + t.Fatal("no findings in parsed output") + } + f0 := parsed.Findings[0] + if f0.Repro == nil { + t.Fatal("first finding has nil repro; expected populated repro block") + } + if f0.Repro.HTTP == "" { + t.Error("repro.http is empty") + } + if f0.Repro.Curl == "" { + t.Error("repro.curl is empty") + } + if f0.Repro.Differential == "" { + t.Error("repro.differential is empty") + } + // Second finding (no Repro) must parse without a repro key (nil pointer). + if len(parsed.Findings) > 1 && parsed.Findings[1].Repro != nil { + t.Error("second finding unexpectedly has a repro block; expected nil") + } +}