Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion POST_V01.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 `<bearer:identity>`
placeholders by default; `--repro-creds` emits live tokens.

---

## Item 5 — Automated marker harvesting from owner baselines (Priority: MEDIUM) — ✅ IMPLEMENTED (r9)
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <bearer:bob>\n…",
"curl": "curl -X POST -H 'Authorization: <bearer:bob>' 'https://…'",
"differential": "baseline 200 → variant 200 · similarity 0.97 · Δsize 0"
}
```

Credential values are **redacted to identity-tagged placeholders** by
default (`<bearer:bob>`); 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
Expand Down
31 changes: 20 additions & 11 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -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.**
Expand All @@ -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.**

Expand Down
11 changes: 11 additions & 0 deletions internal/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
29 changes: 29 additions & 0 deletions internal/model/finding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bearer:identity> 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.
Expand All @@ -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
// <bearer:identity> 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
Expand Down
111 changes: 111 additions & 0 deletions internal/report/reporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <bearer:bob>\n\n{\"note\":\"test\"}",
Curl: "curl -X POST -H 'Authorization: <bearer:bob>' '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, "<bearer:bob>") {
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")
}
}