From 722a90ee03e15fdb2b728edc30eee797ebbdb41e Mon Sep 17 00:00:00 2001 From: "Tom D. Snyder" Date: Sat, 8 Aug 2026 16:08:17 -0400 Subject: [PATCH] R.1: the record contract, and the six enums the review gates froze R.1 is the packet everything in areas A, B, D, O and X is written against. Its scope grew after it was written: IMPLEMENTATION-PLAN.md section 6 makes it the single point where all shared cross-area vocabulary is fixed, because ten confirmed defects came from four areas each defining that vocabulary from their own side. Every one was a produce/consume break -- one area writing literals another area's NOT NULL column could not accept, with `dast_status` sharing ZERO values between two areas that each claimed authority over it. What landed: internal/record/contract.go (the Go contract and its validators), CONTRACT.md (the human-readable version other areas get pointed at, naming a producer and a consumer for every field), and schemas/anvil-record-v1.schema.json (SARIF 2.1.0 plus the anvil/* extension, Draft 2020-12). Four distinctions carry comments explaining why, because they look like redundancy and someone will otherwise simplify them away: - dast_sealed and both_sealed exist because S1 requires two INDEPENDENTLY sealed halves. A machine that can only seal SAST-then-DAST cannot express a DAST-first seal, which is what area O's four-state version got wrong. - skipped_no_manifest is deliberately distinct from not_run: no manifest declared, versus the DAST tier not installed. S6 requires a target that failed to be distinguishable from one scanned clean. - target.provenance (boot outcome) and target.provisioning (which path) were one field with two meanings in two areas. dastStatus derives from the former; merging them loses information S6 requires. - insufficient_context is a verdict, not a low confidence score. Tests added by the orchestrator, because R.1 shipped without any The packet validated itself through a scratchpad harness -- 17 negative Go fixtures and 20 negative JSON-schema fixtures, all correctly rejected -- but none of it landed in the repository, so nothing would ever run it again. That is the same defect fixed for the experiment register two commits ago: a schema nobody runs is a comment. contract_test.go now pins, in-tree: - all six frozen enums as literal strings, compared against the accessors other packages consume. Not circular: the table is what the ruling says, the accessors are what the code does, and a "tidy" edit has to defeat both. - every pre-ruling literal the four areas were actually using -- `open`, `complete`, `clean`, `partial`, `failed_to_boot`, `EXHIBITS`, and the rest -- asserted to be REJECTED. If one starts validating, the ruling has been quietly undone. - the four handoff dispositions that existed only in area 60's deleted anvil_ledger table, without which area X's exit criterion 14 is unsatisfiable. - that a repo source snippet is `untrusted` and not `anvil_generated`. Area B was found stamping the latter on a struct whose field is verbatim target-repo source, which would disable area X's containment check on exactly the string that most needs it. - that a CWE match alone never qualifies a correlation as verified (S7). - that the empty string is rejected everywhere, so a zero-valued Go string cannot pass as "unset but fine". Evidence: gofmt clean; go vet clean; go build clean; go test -count=1 ./... passes; schema self-checks as valid Draft 2020-12. R.2 is NOT in this commit. It wrote fingerprint.go and fingerprint_test.go before the session cap killed it, but the artifact is not structurally complete: it had a compile error (string concatenation with the + leading the continuation line, which semicolon insertion breaks), it never wrote its testdata/fingerprint_corpus/ fixtures, and two of its own tests disagree with its implementation. Fingerprint drift fails silently and permanently, so it gets a clean re-run rather than a repair by guesswork. The work-in-progress is preserved outside the tree. --- internal/record/CONTRACT.md | 475 ++++++ internal/record/contract.go | 2430 +++++++++++++++++++++++++++ internal/record/contract_test.go | 221 +++ schemas/anvil-record-v1.schema.json | 682 ++++++++ 4 files changed, 3808 insertions(+) create mode 100644 internal/record/CONTRACT.md create mode 100644 internal/record/contract.go create mode 100644 internal/record/contract_test.go create mode 100644 schemas/anvil-record-v1.schema.json diff --git a/internal/record/CONTRACT.md b/internal/record/CONTRACT.md new file mode 100644 index 0000000..592b144 --- /dev/null +++ b/internal/record/CONTRACT.md @@ -0,0 +1,475 @@ +# The Anvil Record Field Contract + +**Status: frozen interface.** Everything Anvil produces or consumes crosses this boundary. Once R.17's +exit gate passes, no area may add, rename, or re-type a field here without amending +`plan/40-record-and-storage.md` and `plan/IMPLEMENTATION-PLAN.md` §6. + +| | | +|---|---| +| Wire format | SARIF 2.1.0 Plus Errata 01, pinned exactly. Not the 2.2 draft. | +| Extension | `anvil/*` property bags, versioned once at `sarifLog.properties["anvil/schemaVersion"]` = `1.0.0` | +| Go source of truth | `internal/record/contract.go` | +| JSON Schema | `schemas/anvil-record-v1.schema.json` | +| Ground truth this file matches | `plan/40-record-and-storage.md` § "Record Field Contract"; `plan/00-SPINE.md` S1/S6/S7; `plan/IMPLEMENTATION-PLAN.md` §6 | + +--- + +## 0. Read this first if you own another area + +`plan/IMPLEMENTATION-PLAN.md` §6 ran two owed review gates on 2026-08-07 and confirmed ten defects. +**Nine of the ten were the same structural error:** eight agents who could not see each other each +declared the shared vocabulary from their own side, and no step had been assigned to reconcile them. +Every one was a produce/consume break — one area wrote literals another area's `NOT NULL` column could +not accept. `dast_status` and `target_provenance` were each found independently by two different critics. + +The ruling: **area 40 owns every shared enum, and no other area may declare one.** This file is where +they live. + +If you produce one of these values, **emit these literals directly**. If your area has its own +in-process vocabulary, you may map onto these literals only at a named, tested boundary step; the +permitted mappings are listed in §3 and are also machine-readable as `record.AreaMappingOwners`. +Lowercase `snake_case` is the record's convention throughout — any area emitting `SCREAMING_CASE` maps at +its own boundary. + +Two habits that will save you a rerouted packet: + +* Enumerate with the constants, never with string literals: `record.DastStatusCompletedClean`, not + `"completed_clean"`. +* Validate before you write: every enum has a `Valid()` method and a `ValidateX(string) error` that names + every legal value in its error message, and `(*SARIFLog).Validate()` checks a whole record. + +--- + +## 1. The six frozen enums + +Frozen verbatim by `plan/IMPLEMENTATION-PLAN.md` §6. Declared once in `internal/record/contract.go`. + +### 1.1 `anvil/state` — audit lifecycle (ruling G2) + +`collecting | sast_sealed | dast_sealed | both_sealed | consumed | expired` + +**Producer:** the scan controller (O.2). **Consumer:** the handoff consumer, the store +(`audit_record.state`), the report. + +**Why `dast_sealed` and `both_sealed` both exist — do not collapse them into one `sealed`.** +`plan/00-SPINE.md` S1 requires "one audit identity, two **independently**-sealed halves, a re-entrant +consumer." A state machine whose only sealing path is SAST-then-DAST cannot express a DAST-first seal at +all, and a DAST-first seal is reachable in practice: the SAST half can be slow or can fail while the DAST +half completes. Collapsing also makes `sealed` terminal, which makes `consumed` unreachable, which +silently disables the re-entrant consumer. Area O's earlier four-state machine +(`open → sast_sealed → sealed → expired`) had exactly these two failures and is struck. + +R.6 additionally requires a DAST-disabled audit to reach `both_sealed` — a value area O was previously +*forbidden* from producing. + +### 1.2 `anvil/status` — per-half run status (ruling G5) + +`running | sealed | failed | timed_out | skipped` + +**Producer:** the SAST/DAST worker, at seal time. **Consumer:** the re-entrant consumer's read gate +(R.6), the report. + +`sealed` is load-bearing, not cosmetic. R.6 makes this exact token the hard read gate: *a consumer may +not read a half's results before that half's status equals `sealed`*. Area O keyed its transitions on +`complete`, which means the gate never opens and the consumer never runs. `timed_out` is adopted from +area O — it distinguishes "it broke" from "it ran out of clock". + +`anvil/sealedAt` is required exactly when status is `sealed`, and is explicitly `null` otherwise. A +missing key and an unsealed half must not be the same observation. + +### 1.3 `anvil/dastStatus` — audit-level DAST outcome (rulings G3 + G6, found twice) + +`not_run | skipped_no_manifest | running | completed_clean | completed_findings | completed_partial | +target_boot_failed | target_unreachable | timed_out` + +**Producer:** the scan controller, **derived from** the DAST half's `anvil/status` and from +`anvil/target.provenance`. Emitted by D.26. **Consumer:** the coding agent (which must not treat an +absent DAST half as "scanned clean"), the report, `audit_record.dast_status` (`NOT NULL`, default +`not_run`). + +Area 40 had seven values and area D had five, with **zero literal overlap** — D could not have written a +single row into 40's `NOT NULL` column. The frozen set is the union plus D's `partial`, renamed +`completed_partial`. + +**Why `skipped_no_manifest` is distinct from `not_run` — do not merge them.** + +* `not_run` — the DAST tier is not installed at all. Under `plan/00-SPINE.md` S9-AMENDED, DAST ships as + a separate distribution artifact, so this is the common case and it says nothing about the target. +* `skipped_no_manifest` — the DAST tier *is* installed and ran, and no target manifest was declared, so + there was nothing to scan. That is a configuration gap in the target, and it is actionable. + +`plan/00-SPINE.md` S6 requires that a target which failed to boot be distinguishable from one scanned +clean; the same argument applies one level up. `research/23-dast-signal-sources.md` Risk #1: "Anvil must +never report '0 DAST findings' as 'no dynamic vulnerabilities'." Merging these makes that mistake +unfixable at the schema level. + +**Only `completed_clean` may be read as "dynamically scanned, nothing found."** Use +`DastStatus.MeansDynamicallyScannedClean()` rather than `!= "completed_findings"`. + +### 1.4 `anvil/target.provenance` — boot / reachability outcome (rulings G4 + G7, found twice) + +`booted_clean | boot_failed | build_failed | no_target_declared | unreachable_at_scan_time` + +**Producer:** the target lifecycle harness (area D). This area reserves the field and owns the +vocabulary. **Consumer:** the coding agent, the `dast_status` derivation, the report, +`audit_record.target_provenance` (`NOT NULL`). + +### 1.5 `anvil/target.provisioning` — which provisioning path (NEW required field) + +`ephemeral_manifest | live_url_authorized` + +**Producer:** the target lifecycle harness (D.26). **Consumer:** the authorization audit trail, the +report. + +**Why 1.4 and 1.5 are two fields — do not merge them back into one.** They were previously one field +name carrying two meanings, which is how the defect happened: area D wrote its provisioning-path literals +into a field area 40 defined as a boot outcome. They are genuinely different measurements and both are +required: + +* `provenance` answers *what happened when we tried to run the target*. **`dastStatus` is derived from + this one** (`booted_clean` → the DAST half's own outcome; `boot_failed`/`build_failed` → + `target_boot_failed`; `unreachable_at_scan_time` → `target_unreachable`; `no_target_declared` → + `skipped_no_manifest`). A merged field cannot support that derivation, and `plan/00-SPINE.md` S6's + requirement that a failed-to-boot target be distinguishable from a clean scan is information a merged + field loses. +* `provisioning` answers *which path did we take to get a target*. A live third-party URL and a + throwaway container Anvil built itself are not the same authorization question, and `plan/00-SPINE.md` + S7 makes the authorization kernel a pure function of `(target, scope, attestation, clock)`. The record + must state which one was scanned. Never infer `live_url_authorized` from reachability, and never from + `security.txt` — S7: "`security.txt` resolves a reporting channel and never grants permission." + +### 1.6 `anvil/verdict` — triage judgment about the finding (ruling G8) + +`true_positive | false_positive | insufficient_context` + +**Producer:** the detector model / triage gate, via **B.12's** named mapping. **Consumer:** the +coding-agent consumption pipeline — it drops `false_positive` and demotes `insufficient_context` to +report-only — plus the report and `finding.verdict`. + +**Why `insufficient_context` is a verdict and not a low confidence score — do not replace it with a +threshold on `anvil/confidence`.** `plan/00-SPINE.md` S6 is explicit: "`INSUFFICIENT_CONTEXT` as a valid +detector verdict, not just a confidence float." A low confidence score means *this is probably not a real +defect*. `insufficient_context` means *this may well be a real defect and the detector could not see +enough to tell* — typically because the sink sits behind a dynamic dispatch, a framework boundary, or a +file the scan did not have. Those two demand opposite handling: the first is dropped, the second is +escalated to a human or to the DAST half. A confidence threshold silently discards exactly the second +population, and a float cannot express the difference. + +Lane B keeps its own in-process `Verdict.Result` (`EXHIBITS|…`), which is a judgment about the **code**; +`anvil/verdict` is a judgment about the **finding**. Collapsing them would lose that distinction, so both +stand and **B.12 owns the mapping, including case normalisation**. A mapping with an owner and a test is +not the same thing as two vocabularies drifting. + +--- + +## 2. `anvil/trust` — required on every string originating outside Anvil + +`untrusted | anvil_generated | verified` + +**Producer:** whichever component ingests the external string (advisory text, DAST response bodies, +repo source snippets, third-party SARIF imports). **Consumer:** the prompt builder, which must never +treat `untrusted` text as instructions (`plan/00-SPINE.md` S7 prompt-injection containment), and the +report. + +> **A repo source snippet is `untrusted` even though Anvil is what put it in the struct.** +> +> Area B was found stamping `anvil_generated` on a struct whose `Snippet` field is verbatim target-repo +> source. That would have disabled area X's containment check on the exact string that most needs it: an +> attacker who can commit to the scanned repository can write agent instructions into a comment, and that +> comment lands in `region.snippet.text`. The question `anvil/trust` answers is **who wrote these bytes**, +> never **who assigned this field**. + +`anvil_generated` means Anvil *produced* the bytes — detector reasoning, derived summaries, computed +digests. It never means "Anvil assembled the containing object." `verified` means the bytes came from +outside **and** passed an explicit validation step named in the record; it is never a default. + +`Trust.LegalForExternalString()` encodes the rule: an external string may be `untrusted` or `verified`, +never `anvil_generated`. + +### How trust is carried + +`plan/00-SPINE.md` S6 says *every* external string, and one result carries several strings of different +provenance at once — a repo snippet, a model-generated explanation, an attacker-controlled response body. +So: + +* **SARIF-native strings** cannot change shape without breaking SARIF, so they are classified out of + band. `result.properties["anvil/trust"]` is `{ "default": , "fields": { : } }`, + where each pointer is RFC 6901 **relative to the result object**. +* **`anvil/*` extension strings** that carry external text use the inline `{ "text", "trust" }` shape + (`record.TrustedString`) — currently `anvil/advisory.excerpt` and `anvil/repro.observedSignal.match`. + +`record.ValidateResultTrust` walks `Result.ExternalStringPointers()` — region and context-region snippets +in `locations`, `relatedLocations` and `codeFlows`, plus `webResponse.body.text` and +`webResponse.headers` — and rejects any that is classified `anvil_generated`. A result carrying a +`webResponse` must additionally set `default` to `untrusted`: `plan/00-SPINE.md` S7 names the DAST +response body the highest-risk field in the system, "up to 32 KB of attacker-controlled bytes fed to a +repo-credentialed agent." + +--- + +## 3. Vocabularies that are **not** among the six, and who owns them + +| Vocabulary | Values | Owner / status | +|---|---|---| +| `handoff.state` | `ready, leased, validated, failed_validation, failed_format, skipped_budget, false_positive, regression_introduced, fixed_incidentally, split_required, withdrawn, superseded, expired` | Literals frozen here (§6's enum block, rulings G9+G10); the **table and DDL are R.4's**. X.8/X.9 read and write it. Area X's `anvil_ledger` is deleted — a second durable copy is a direct S1 violation, and the concrete failure traced was X.9 writing `SKIPPED_BUDGET` to the ledger while 40's ready-set index still saw the row as `ready`, so it was re-leased forever. | +| `handoff.consumption_class` | `static_only, requires_dynamic_confirmation` | Column merged into `handoff` by ruling G9 (came from O.3). Nothing else in the schema expresses the static-only vs. requires-dynamic-confirmation gate. | +| `anvil/half` | `sast, dast` | Area 40. Which **half of the audit** produced the run/result. | +| `anvil/evidenceClass` | `dast_confirmed, sast_reachable, sast_static_only, sca, host` | Area 40. `research/24`: "this is the field that makes tier-0 ordering possible." Consumed by R.11's queue re-cut and R.13's read order. | +| `finding.detector` | `sast, dast, sca, host` | Area 40. Deliberately **not** the same enum as `evidenceClass`: `sast_reachable` and `sast_static_only` are both produced by the `sast` detector and must hash under the same fingerprint tier. | +| `anvil/repro.injectionPoint.kind` | `query, body, header, cookie, path` | Area 40 (the Fingerprint Specification hashes it). | +| `anvil/repro.observedSignal.kind` | `responseStackTrace, statusCodeFlip, dbErrorString, timingSideChannel, reflectedPayload, other` | Area 40 (hashed as a separate field from `injectionPoint`: *where the payload went in* and *how the defect showed up* are independent facts). | +| `anvil/correlation.signals[].name` | `responseStackTrace, routeTable, callGraphReach, parameterName, cweMatch, rerunFlip` | Area 40; consumed by R.12. | +| `finding.state` | `open, resolved, suppressed, regressed` | Area 40 (store). | +| `scan_run.status` | `running, ok, failed, partial` | Area 40 (store); **written by area O**. | +| `anvil/dastCoverage.inventoryProvenanceMix` keys | `runtime_spec, repo_spec, static_extraction, crawl` | **Produced by area D (D.18–D.25)**, mirrored here so a naming drift is caught at this file rather than at integration. Flagged to the orchestrator as a candidate seventh frozen enum. | +| `anvil/locus.proximityClass` | *unenumerated* | Owned by the coding-agent consumption area (`research/24`'s Hunk4J citation). **Register it here before a second area consumes it**, or it becomes the eleventh defect of this exact shape. | + +`record.AreaMappingOwners` carries the same ownership statements in code, so they survive independently +of this document. + +--- + +## 4. Audit envelope — `sarifLog` and `sarifLog.properties` + +Every row names its producer and its consumer. **NEW** marks a field `plan/00-SPINE.md` S6 flagged as +absent from branch 18's original design. + +| Field | Native / ext | Required? | Producer | Consumer | +|---|---|---|---|---| +| `$schema` / `version` | SARIF-native | required, pinned to `2.1.0` exactly | record assembler | any SARIF consumer, GitHub, DefectDojo | +| `anvil/schemaVersion` | ext | required | record assembler | store, migrations, coding agent | +| `anvil/auditId` | ext | required | scan controller, at scan start | store (PK), handoff, coding agent, report | +| `anvil/state` **NEW** | ext | required, §1.1 enum | scan controller (O.2) | handoff consumer, store, report | +| `anvil/version` **NEW** | ext | required, monotonic int ≥ 1, bumped on every re-scan of the same audit | scan controller | queue re-cut (R.11) | +| `anvil/createdAt` | ext | required | scan controller | store, reaper | +| `anvil/target.{repoUrl,ref,commit,subpath}` | ext | required | scan controller | coding agent, correlation, report | +| `anvil/target.runtimeBaseUrl` | ext | required only when DAST is enabled | scan controller | DAST worker, repro replay | +| `anvil/target.provenance` **NEW** | ext | required, §1.4 enum | target lifecycle harness (area D) | coding agent, `dastStatus` derivation, report | +| `anvil/target.provisioning` **NEW** | ext | required, §1.5 enum | target lifecycle harness (D.26) | authorization audit trail, report | +| `anvil/trigger.{kind,policyId,policyRef,configSource,actor,resolvedAt}` | ext | required | scan controller | report, audit trail | +| `anvil/deadline.deadlineAt` | ext | required, `= scan_run.started_at + claimTimeoutSeconds`, computed **once at scan START** and never recomputed | scan controller | reaper, handoff, coding agent | +| `anvil/deadline.claimTimeoutSeconds` | ext | required, default `28800` (8 h), config-driven | config loader | reaper | +| `anvil/deadline.dastDeadlineSeconds` **NEW** | ext | required key; `null` when DAST is disabled. An **independent clock** from `claimTimeoutSeconds` | config loader | DAST worker, target lifecycle harness | +| `anvil/db.recordId` / `.writtenAt` | ext | required after the DB commit; absent before | store writer | audit trail | +| `anvil/index.*` (Tier-0 manifest: `counts`, `readOrder`, `byCluster`, `byCwe`, `byPath`, `taskCards`, `blobs`) | ext | required, ≤ 8 KB | record assembler | coding agent (Tier-0 read) | +| `anvil/dastStatus` **NEW** | ext | required, **never null**, §1.3 enum | scan controller (derived), emitted by D.26 | coding agent, report | + +`anvil/deadline` replaces branch 18's `anvil/buffer` per the `plan/00-SPINE.md` S1 correction: the eight +hours is a **claim timeout**, not a deletion policy and not a confidentiality control. See +`internal/record/SECRETS.md` (R.9). + +`anvil/deadline.deadlineAt` and `anvil/sealedAt` are **independent clocks with independent semantics** +and must never be conflated: `sealedAt` records per-half completion, `deadlineAt` records when an +unclaimed finding stops being eligible. `(*SARIFLog).Validate()` rejects a `deadlineAt` that is not +exactly `createdAt + claimTimeoutSeconds`, which is what makes "anchored to scan start, never to the last +write" checkable rather than aspirational. + +`anvil/trigger` **references** the policy that fired; no trigger condition is ever encoded in the record. + +--- + +## 5. Per-half run — `run.automationDetails`, `run.properties` + +| Field | Native / ext | Required? | Producer | Consumer | +|---|---|---|---|---| +| `run.automationDetails.correlationGuid` | SARIF-native §3.17.5 | required; **identical in both runs and equal to `anvil/auditId`** | record assembler | correlation / cluster logic | +| `run.properties["anvil/half"]` | ext | required, `sast` or `dast` | SAST/DAST worker | routing | +| `run.properties["anvil/status"]` **NEW** | ext | required, §1.2 enum | SAST/DAST worker at seal time | re-entrant consumer read gate, report | +| `run.properties["anvil/sealedAt"]` **NEW** | ext | required key; a timestamp iff status is `sealed`, otherwise `null` | SAST/DAST worker | re-entrant consumer read gate, deadline math | +| `run.properties["anvil/dastCoverage"]` **NEW** | ext | required on the DAST run | attack-surface discovery (D.26) | coding agent (confidence weighting), report | +| `run.properties["anvil/routeTableDigest"]` | ext | required on the DAST run | DAST worker | audit trail, correlation replay | +| `run.properties["anvil/advisorySnapshot"]` | ext | required on the SAST run | ingestion subsystem (area A) | coding agent (staleness), report | +| `run.properties["anvil/runtimeTarget"]` | ext | required on the DAST run | DAST worker | correlation, repro replay | + +`anvil/runtimeTarget.authProfileRef` is a **config file path and revision**. The record never carries +credentials. + +### `anvil/dastCoverage` — a numerator, a denominator and a provenance mix, never a bare ratio + +| Sub-field | Meaning | +|---|---| +| `probedCount` | Confirmed-probed endpoints. **Never a request count.** | +| `inventoryUnionCount` | The union of the Tier 0–2 inventory — the required denominator. | +| `endpointCoverage` | S6's `endpoint_coverage`: `probedCount / inventoryUnionCount`, in `[0,1]`. Validated against the two counts, so it cannot drift into a hand-written percentage. | +| `serverLineCoverage` | `null` — **not `0`** — on incremental scans. Zero would read as "we ran and covered nothing." Populated on scheduled full scans only. | +| `inventoryProvenanceMix` | S6's `inventory_provenance`, aggregated: endpoint count per provenance literal. This is what makes the SAST→DAST handoff auditable. | +| `confirmedCount` / `candidateCount` | Inventory split by whether the endpoint was confirmed to exist or only inferred. | + +A bare "62 % covered" is unfalsifiable. `probedCount=31` of `inventoryUnionCount=50`, of which 40 came +from a runtime spec and 10 from a crawl, is not (`research/14` critique m6). + +This field consolidates S6's `dast_coverage`, `endpoint_coverage` and `inventory_provenance` into one +place. `plan/40-record-and-storage.md` Open Question 5 asks the attack-surface area to confirm nothing is +lost by that consolidation; nothing here forbids splitting it later. + +--- + +## 6. Per-finding result — `result.*` + +SARIF-native slots are used wherever they exist. An `anvil/*` key that duplicates a native mechanism is a +defect, not a convenience. + +| Field | Native / ext | Required? | Producer | Consumer | +|---|---|---|---|---| +| `result.correlationGuid` | SARIF-native §3.27.4 | required for clustered findings only; assigned **per cluster**, not per finding | correlation engine (R.12) | consumer clustering | +| `physicalLocation.region` + `.contextRegion` + `region.snippet` | SARIF-native | required for SAST, absent for pure-DAST | detector | coding agent (Tier-1 card) | +| `logicalLocations[]` | SARIF-native §3.33 | required when a symbol resolves | detector | coding agent | +| `codeFlows[].threadFlows[]` | SARIF-native §3.36/§3.37 | required when a taint path is known | detector | coding agent (where the value enters) | +| `result.taxa[]` / `run.taxonomies[]` (CWE) | SARIF-native §3.8.2 — preferred over tags | required | detector | coding agent, report | +| `result.webRequest` / `.webResponse` | SARIF-native §3.27.14/15 | required for DAST findings; **masked by R.8 before storage** | DAST worker → masking pipeline | coding agent, verification replay | +| `result.partialFingerprints["anvilFindingId/v1"]` | native mechanism, Anvil-defined value | required, 64 lowercase hex, **never truncated** | fingerprint engine (R.2) | store identity join, regression engine | +| `result.partialFingerprints["primaryLocationLineHash"]` | native mechanism | required when a physical location exists | fingerprint engine | **GitHub upload path only** (R.14) — the only partial fingerprint GitHub reads | +| `result.partialFingerprints["regionSha256"]` | native mechanism | optional, reserved — see deviation 2 | fingerprint engine (R.2) | coding-agent handoff | +| `result.provenance.*` | SARIF-native §3.48 | required | store, on read-back | regression history, report | +| `result.fixes[]` | SARIF-native §3.27.30 | written only after a coding-agent proposal | coding agent (area X) | PR generator, verification. **Never auto-merged** (S7) | +| `result.rank` | SARIF-native §3.27.11 | optional | ranking | queue order. **Priority, not confidence**; ingested third-party `rank` is untrusted and re-derived (`research/18` Risk #8) | +| `result.properties["anvil/findingId"]` | ext | required | record assembler | cross-reference (task cards, DB) | +| `result.properties["anvil/half"]` | ext | required, must equal the run's half | detector | routing | +| `result.properties["anvil/confidence"]` | ext | required, `[0,1]` | detector model | ranking, report | +| `result.properties["anvil/verdict"]` **NEW** | ext | required, §1.6 enum | detector / triage gate via B.12 | consumption pipeline, report | +| `result.properties["anvil/remediableByAgent"]` **NEW** | ext | required, boolean; **host findings are always `false`** | record assembler, derived from `detector` | coding agent (never attempts host fixes — S7 read-only host agent) | +| `result.properties["anvil/reasoning"]` | ext | required | detector model | report, coding-agent context | +| `result.properties["anvil/detector"]` (`.kind`, `.model`, `.revision`, `.promptDigest`) | ext | required | detector model | audit trail, prompt-digest replay, fingerprint tier selection | +| `result.properties["anvil/evidenceClass"]` | ext | required | record assembler, derived from detector + correlation state | ranking (R.11 re-cut), coding agent (R.13 read order) | +| `result.properties["anvil/trust"]` **NEW** | ext | required — see §2 | whichever component ingests the external string | prompt builder (S7 containment), report | +| `result.properties["anvil/advisory"]` (`.ids`, `.cveIds`, `.sourceFeed`, `.snapshotDigest`, `.licenseSpdx`, `.asOf` **NEW**, `.stalenessSeconds` **NEW**, `.parseDegraded` **NEW**, `.excerpt`) | ext | required when an advisory is linked | ingestion subsystem at record-assembly time | coding agent (down-weight stale/degraded context), report; `.licenseSpdx` → `plan/80-compliance.md` | +| `result.properties["anvil/risk"]` | ext | optional — see deviation 1 | Lane A ingestion | ranking (R.11, R.13), report | +| `result.properties["anvil/patchContext"]` | ext | required for remediable findings | record assembler | coding agent | +| `result.properties["anvil/correlation"]` | ext | required for clustered findings only | correlation engine (R.12) | coding agent (peer lookup), report | +| `result.properties["anvil/repro"]` (+ `.env.sanitizers[]` **NEW**, `.env.aslrEnabled` **NEW**) | ext | required on any reproducer | DAST worker / dynamic-analysis harness | verification pipeline (S7) | +| `result.properties["anvil/locus"].proximityClass` | ext | required for SAST findings | record assembler | fix-grouping (coding-agent area) | +| `result.properties["anvil/chunkRef"]` | ext | required | task-card generator (R.13) | coding agent (Tier-1 pointer) | +| `result.properties["anvil/groupId"]` | ext | key **reserved here**; assigned by the coding-agent consumption pipeline, not by this area | coding agent | coding agent (self-consumed) | +| `location.properties["anvil/locationKind"]`, `["anvil/routeTemplate"]` | ext | required on DAST endpoint locations | DAST worker | correlation, report | + +### Notes that have bitten someone already + +* **`anvil/half` vs `anvil/detector.kind`.** SCA and host findings are static, so they live in the + **SAST run** with `half = "sast"` and `detector.kind = "sca"` or `"host"`. `half` is which half of the + audit; `detector.kind` is which detector. They are not the same question and they do not have the same + cardinality. +* **`anvil/confidence` is not `rank` and not `level`.** SARIF has no confidence field, so tools stuff + either priority or confidence into `rank`. `level` is severity (a four-value enum), `rank` is priority + (0–100), `anvil/confidence` is detector certainty (`[0,1]`). A consumer that reads `rank` as certainty + cannot tell "high severity" from "high confidence". +* **`anvil/locus` carries only `proximityClass`.** Path, start line, end line and enclosing symbol are + SARIF-native (`physicalLocation.region`, `logicalLocations`); duplicating them into the property bag + would create two sources of truth for the same fact. `research/24` lists them as `locus.*` because it + was writing against a bespoke schema, not SARIF. +* **`anvil/repro.env` is not bookkeeping.** A crash that reproduces only under ASan is a different claim + from one that reproduces on a stock build, and a use-after-free that reproduces only with ASLR disabled + may not be exploitable as shipped. `plan/00-SPINE.md` S7 lets only a reproduction that now *fails* earn + "verified fixed" — a verification re-run under a different sanitizer or ASLR setting is not the same + experiment, and without these fields nothing can detect that. `sanitizers` is an empty array for a + stock build, **never null**: null cannot be distinguished from "nobody recorded it." +* **`anvil/correlation` links, never merges.** Both findings always survive independently: the SAST + finding owns the file and line, the DAST finding owns the proof, and merging destroys exactly what the + other contributes. `merged` is unconditionally `false`. At least two independent signals are required, + a CWE-only match is banned as a sole signal, and `verified: true` requires a `responseStackTrace` or + `rerunFlip` signal specifically — confidence alone never qualifies (S7). The correlation mechanism + carries an **unresolved patent question** (US10043004B2); see `plan/40-record-and-storage.md` Open + Question 1. R.12 flags it in code and R.15 verifies the flag exists; **neither resolves it, and it must + be escalated to the owner before R.12's output ships in a release.** + +--- + +## 7. Size and read path + +| Tier | Budget | Contents | +|---|---|---| +| Tier 0 — manifest | ≤ 8 KB | `sarifLog` with tools, rules, taxonomies, `automationDetails`, the `anvil/*` envelope and `anvil/index`; results externalised via SARIF's native `externalPropertyFileReferences` (§3.15) | +| Tier 1 — task cards | ~1,500–2,500 tokens each | One self-contained derived JSON per finding. The SARIF stays authoritative. | +| Tier 2 — blobs | content-addressed | Full response bodies, long thread flows, whole files. Referenced by `sha256:` digest. | + +Inline caps: **8 KB request / 32 KB response**, the same thresholds ZAP's SARIF reporter uses. The +remainder **spills to a Tier-2 blob, never dropped**. Advisory excerpts are ≤ 800 tokens, pre-trimmed by +ingestion, never a whole advisory. + +Default agent read order is deterministic and not model-chosen: **correlated clusters → SAST-only by rank +→ DAST-only** (`record.DefaultReadOrder()`). + +The GitHub upload is a **projection, not the record** (R.14): only results with a physical code location +and a populated `primaryLocationLineHash`, sharded under 25,000 results/run, 20 runs/file and 10 MB +gzip. `webRequest`, `webResponse`, `taxonomies`-as-relationships, `provenance` and every `anvil/*` bag +are stripped explicitly rather than left for GitHub to ignore silently. + +--- + +## 8. How to validate + +Two gates. Both are required, and they are deliberately separate. + +1. **Stock SARIF conformance** — validate against `sarif-schema-2.1.0.json`. This is what GitHub and + DefectDojo need in order to accept the file. +2. **The Anvil extension** — validate against `schemas/anvil-record-v1.schema.json`. This checks the + `anvil/*` bags, the frozen enums and the S6-required fields. + +The Anvil schema does **not** `$ref` the SARIF base schema, because that reference is not resolvable in +Anvil's offline CI, and because keeping the gates separate makes a SARIF-conformance failure +distinguishable from an `anvil/*` failure. The base schema is declared in the non-validating +`x-anvil-baseSchema` annotation. + +In Go, `(*record.SARIFLog).Validate()` is the in-process gate — a producer fails at assembly time rather +than at the store boundary. It is not a JSON Schema replacement; it additionally checks the cross-field +invariants a schema cannot express (deadline anchoring, state-vs-halves agreement, coverage arithmetic, +trust classification of external strings). + +--- + +## 9. Logged deviations from `plan/40-record-and-storage.md`'s Record Field Contract table + +R.1's contract is required to match that table row for row, or log the deviation. Three deviations, all +additive, none renaming or re-typing an existing row. + +1. **`result.properties["anvil/risk"]` added.** No row exists in the plan's table, but + `research/24-coding-agent-consumption.md` lists `risk.{cvss_v4_base, epss_score, epss_percentile, + epss_model_date, kev_member, kev_ransomware_use}` among its **non-negotiable** handoff fields, + "because there is no orchestrator to compute them later," and R.11/R.13 rank on it. It has no + SARIF-native slot. Optional on the wire. +2. **`partialFingerprints["regionSha256"]` reserved.** `research/24` names + `fingerprint.region_sha256` as non-negotiable; the plan's table lists only `anvilFindingId/v1` and + `primaryLocationLineHash`. Reserved and optional; **R.2 decides whether to populate it**, and R.2 may + strike it if the algorithm has no use for it. +3. **`anvil/trust` is an object, not a bare enum.** The plan's table types it as a bare enum. One result + carries several strings of different provenance simultaneously, and a single enum per result collapses + to the most permissive value — which is precisely the failure mode §2 describes. **The three literals + are unchanged**; only the container is richer. See §2 for the shape. + +Two further reconciliations, recorded because a reader of `research/18`'s annotated example will notice +them: + +* Branch 18's example writes `injectionPoint.kind: "jsonBodyField"` and + `observedSignal.kind: "dbErrorInResponseBody"`. Both are ad-hoc labels that predate + `plan/40-record-and-storage.md`'s Fingerprint Specification, which froze `body` and `dbErrorString` + respectively. **The Fingerprint Specification wins**, because those tokens are hashed and a producer + using the older spelling would mint a different digest for the same defect. +* Branch 18's `anvil/buffer.{createdAt,expiresAt,retentionSeconds,deletePolicyRef}` is replaced by + `anvil/deadline` per the `plan/00-SPINE.md` S1 correction. `research/18`'s annotated example therefore + does **not** validate against this schema, and that is by design: it predates S6 and lacks every field + S6 added. See §10. + +--- + +## 10. Evidence + +`(*SARIFLog).Validate()` and `schemas/anvil-record-v1.schema.json` were both exercised against a +synthetic record carrying one SAST, one SCA, one host and one DAST finding with a correlated +SAST↔DAST cluster, produced by marshalling `internal/record`'s own Go structs. Result: **Go validation +passes; JSON Schema validation reports zero errors.** + +Seventeen Go-level and twenty schema-level negative fixtures were each confirmed rejected, including +every literal the §6 rulings struck: `dast_status: "clean"` (area D's old set), `status: "complete"` and +`state: "sealed"` (area O's old machine), a provisioning literal written into `target.provenance`, a repo +snippet stamped `anvil_generated`, a host finding marked remediable, a truncated 32-hex fingerprint, a +CWE-only correlation, `verified: true` without a stack-trace or re-run-flip signal, `merged: true`, a +recomputed deadline, a DAST run with no coverage block, and `version: "2.2"`. + +`research/18`'s annotated example (comments stripped) produces **30 validation errors**, all of them the +S6 additions it predates — `anvil/state`, `anvil/version`, `anvil/deadline`, `anvil/dastStatus`, +`target.provenance`, `target.provisioning`, per-half `status`/`sealedAt`, `anvil/dastCoverage`, +`anvil/verdict`, `anvil/remediableByAgent`, `anvil/evidenceClass`, `anvil/trust`, advisory +`asOf`/`stalenessSeconds`/`parseDegraded`, `repro.env`, `detector.kind`, correlation +`signals`/`verified` — plus the two renamed reproduction literals in §9. **That the pre-S6 example fails +is the schema working**, not a defect; the packet's original "validates the annotated example with zero +errors" criterion was written before §6's rulings and cannot be satisfied simultaneously with S6's +"all of these are required." diff --git a/internal/record/contract.go b/internal/record/contract.go new file mode 100644 index 0000000..cb3405b --- /dev/null +++ b/internal/record/contract.go @@ -0,0 +1,2430 @@ +// Package record defines Anvil's single frozen audit-record contract: the +// SARIF 2.1.0 wire shape Anvil produces, the typed `anvil/*` property-bag +// extension that carries what stock SARIF cannot, and — since the +// 2026-08-07 orchestrator ruling — every enum shared across Anvil's areas. +// +// # Authority +// +// This file is the single point where shared cross-area vocabulary is fixed. +// plan/IMPLEMENTATION-PLAN.md §6 (rulings G2–G10) found ten confirmed +// produce/consume defects whose common cause was that eight agents who could +// not see each other each declared the shared vocabulary from their own side: +// one area wrote literals another area's NOT NULL column could not accept. +// The ruling: "area 40 owns every shared enum, because it owns the record +// contract, and no other area may declare one." +// +// So: every enum below is declared here ONCE and consumed everywhere else. +// An area that produces a value emits these literals directly, or applies an +// explicitly named and tested mapping at a named step (see AreaMappingOwners). +// Adding a value to any enum here is an amendment to +// plan/40-record-and-storage.md and plan/IMPLEMENTATION-PLAN.md §6, not a +// local edit. +// +// # Conventions +// +// - Lowercase snake_case is the record's literal convention throughout. +// Any area whose in-process vocabulary is SCREAMING_CASE maps at its own +// boundary (plan/IMPLEMENTATION-PLAN.md §6; e.g. B.12 owns the mapping +// from Lane B's `Verdict.Result` onto Verdict below). +// - `anvil/*` keys are hierarchical camelCase, per SARIF §3.8's +// recommendation for property names (research/18-unified-audit-record.md, +// "The Anvil extension, normatively"). +// - A SARIF-native slot is never duplicated into an `anvil/*` key. Where a +// native mechanism exists (correlationGuid, partialFingerprints, +// provenance, fixes, region/contextRegion/snippet, logicalLocations, +// taxonomies/taxa, webRequest/webResponse, codeFlows) it is used, and the +// `anvil/*` bag carries only what SARIF has no slot for. +// +// # Scope of the Go types here +// +// These types cover the SARIF 2.1.0 subset Anvil produces and consumes, not +// all of SARIF. plan/40-record-and-storage.md's Pinned Versions table names +// `owenrumney/go-sarif` as the intended SARIF library, but that module is not +// in go.mod and adding a dependency is the orchestrator's licence decision, +// so the subset below is stdlib-only. A full third-party SARIF reader should +// still be used for INGESTING foreign SARIF; these types are for Anvil's own +// records. +// +// Sources: plan/00-SPINE.md S1, S6, S7, S10, S12; +// plan/IMPLEMENTATION-PLAN.md §6; plan/40-record-and-storage.md ("Record +// Field Contract", "Fingerprint Specification", "Store Schema"); +// research/18-unified-audit-record.md ("Recommendation For Anvil", the +// annotated record, Risks); research/24-coding-agent-consumption.md ("What +// the audit record must carry"). +package record + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Pinned wire identifiers +// --------------------------------------------------------------------------- + +const ( + // SARIFVersion is pinned to SARIF 2.1.0 exactly. Do not pin to the 2.2 + // draft: it is unratified, and research/18 Risk #9 records that no + // published 2.2 content or timeline could be verified. + SARIFVersion = "2.1.0" + + // SARIFSchemaURI is the value of `sarifLog.$schema`. It must match + // SARIFVersion; a record carrying one without the other is invalid. + SARIFSchemaURI = "https://json.schemastore.org/sarif-2.1.0.json" + + // AnvilSchemaURI identifies this contract's own JSON Schema, which + // constrains the `anvil/*` property bags on top of stock SARIF. + AnvilSchemaURI = "https://anvil.invalid/schemas/anvil-record-v1.schema.json" + + // SchemaVersion is the value of `anvil/schemaVersion`. Bump the minor + // component for an additive change, the major for a breaking one; the + // store's migration ledger keys off it. + SchemaVersion = "1.0.0" +) + +// Fingerprint identifiers. R.2 owns the algorithm; this file owns the key +// names it writes into, so that R.2, the store, and the GitHub projection +// cannot disagree about where a digest lives. +const ( + // FingerprintAlgV1 is the name of the one and only Anvil fingerprint + // algorithm. plan/00-SPINE.md S6: "One fingerprint algorithm, defined + // once, in the record. Two branches specified different /v1 algorithms + // under the same name; two producers emitting different hashes means + // regression matching silently fails forever." + FingerprintAlgV1 = "anvil-fp/v1" + + // PartialFingerprintAnvilFindingID is the `result.partialFingerprints` + // key carrying the full, never-truncated 64-hex-character anvil-fp/v1 + // digest. SARIF §3.5.4 versioned hierarchical string. + PartialFingerprintAnvilFindingID = "anvilFindingId/v1" + + // PartialFingerprintPrimaryLocationLineHash is the ONLY partial + // fingerprint GitHub code scanning reads (research/18, "What GitHub + // actually accepts"). Required on every result that has a physical + // location; consumed only by the GitHub projection (R.14). + PartialFingerprintPrimaryLocationLineHash = "primaryLocationLineHash" + + // PartialFingerprintRegionSHA256 carries research/24's + // `fingerprint.region_sha256`. DEVIATION: this key has no row in + // plan/40-record-and-storage.md's Record Field Contract table, but + // research/24 lists region_sha256 among its non-negotiable handoff + // fields. Reserved here, optional on the wire; R.2 decides whether to + // populate it. See CONTRACT.md "Logged deviations", item 2. + PartialFingerprintRegionSHA256 = "regionSha256" +) + +// Byte-level constants of the anvil-fp/v1 algorithm that other areas must not +// re-derive. The algorithm itself is R.2's (internal/record/fingerprint.go); +// these are the parts that are contract, not implementation. +const ( + // FingerprintFieldSeparator joins every hashed field. U+001F (ASCII Unit + // Separator) is chosen over any printable glyph because a printable + // separator can appear inside a snippet or symbol name and create a + // field-boundary collision; U+001F cannot appear in normalized source + // text. plan/40-record-and-storage.md, Fingerprint Specification. + FingerprintFieldSeparator = "\x1f" + + // FingerprintDigestHexLen is the length of a full SHA-256 digest in + // lowercase hex. The digest is NEVER truncated: truncating a + // cryptographic digest without a forcing constraint only adds collision + // risk for no benefit. + FingerprintDigestHexLen = 64 +) + +// Body caps, matching OWASP ZAP's SARIF reporter (research/18 [S8]). Enforced +// by the masking pipeline (R.8) and the read path (R.13); the remainder +// spills to a content-addressed Tier-2 blob rather than being dropped. +const ( + MaxInlineRequestBodyBytes = 8 * 1024 + MaxInlineResponseBodyBytes = 32 * 1024 + + // RedactedPlaceholder is the exact value a masked header is replaced + // with. Fixed here so a substring-absence test (Exit Criterion 8) and + // the masker agree on one token. + RedactedPlaceholder = "***REDACTED***" +) + +// --------------------------------------------------------------------------- +// Enum machinery +// --------------------------------------------------------------------------- + +// EnumError reports a value that is not a member of a frozen enum. It names +// the field and every legal literal, because the failure mode this contract +// exists to prevent is an area emitting a literal another area's NOT NULL +// column cannot accept — and a bare "invalid value" error does not tell the +// author which vocabulary they were supposed to use. +type EnumError struct { + Field string // the anvil/* key or column the value was destined for + Value string // the rejected literal + Allowed []string // every legal literal, in declaration order +} + +func (e *EnumError) Error() string { + return fmt.Sprintf("record: %q is not a legal %s; legal values are %s", + e.Value, e.Field, strings.Join(e.Allowed, "|")) +} + +func inEnum[T ~string](v T, allowed []T) bool { + for _, a := range allowed { + if v == a { + return true + } + } + return false +} + +func validateEnum[T ~string](field string, v T, allowed []T) error { + if inEnum(v, allowed) { + return nil + } + legal := make([]string, len(allowed)) + for i, a := range allowed { + legal[i] = string(a) + } + return &EnumError{Field: field, Value: string(v), Allowed: legal} +} + +// --------------------------------------------------------------------------- +// FROZEN ENUM 1 of 6 — anvil/state +// --------------------------------------------------------------------------- + +// State is the audit-level lifecycle state, `sarifLog.properties["anvil/state"]` +// and `audit_record.state`. +// +// FROZEN by plan/IMPLEMENTATION-PLAN.md §6 ruling G2. Area O previously +// declared a four-state machine (`open → sast_sealed → sealed → expired`); +// that is struck. O.2 emits these literals. +type State string + +// The six legal anvil/state literals. +// +// WHY StateDastSealed AND StateBothSealed EXIST — do not "simplify" this to a +// single `sealed`: +// +// plan/00-SPINE.md S1 requires "One audit identity, two INDEPENDENTLY-sealed +// halves, a re-entrant consumer." A state machine whose only sealing path is +// SAST-then-DAST cannot express a DAST-first seal at all — and a DAST-first +// seal is reachable in practice, because the SAST half can be slow or can +// fail while the DAST half completes. Collapsing dast_sealed and both_sealed +// into one `sealed` value also makes `sealed` terminal, which makes +// StateConsumed unreachable, which silently disables the re-entrant consumer +// read gate (R.6). Each of the six is reachable and each means something a +// consumer branches on. +const ( + // StateCollecting: neither half has sealed. No consumer may read either + // half's results (R.6's read gate). + StateCollecting State = "collecting" + // StateSastSealed: the SAST half has sealed; the DAST half has not. + StateSastSealed State = "sast_sealed" + // StateDastSealed: the DAST half has sealed; the SAST half has not. + // Reachable, and not a typo for sast_sealed — see the note above. + StateDastSealed State = "dast_sealed" + // StateBothSealed: both halves have sealed. A DAST-disabled audit + // reaches this state with DastStatusNotRun (R.6), never with a NULL or + // an invented "n/a" status. + StateBothSealed State = "both_sealed" + // StateConsumed: the coding-agent consumption pipeline has taken the + // record. Unreachable if `sealed` is made terminal. + StateConsumed State = "consumed" + // StateExpired: the claim timeout elapsed. The tmpfs packet and + // `audit_record.payload` are dropped; the DB row and the finding history + // are NOT deleted (plan/40-record-and-storage.md, "Two independent + // clocks"). + StateExpired State = "expired" +) + +// StateValues returns every legal anvil/state literal, in lifecycle order. +func StateValues() []State { + return []State{ + StateCollecting, StateSastSealed, StateDastSealed, + StateBothSealed, StateConsumed, StateExpired, + } +} + +// Valid reports whether s is one of the six legal anvil/state literals. +func (s State) Valid() bool { return inEnum(s, StateValues()) } + +// ValidateState reports whether v is a legal anvil/state literal, returning an +// *EnumError naming every legal value if it is not. +func ValidateState(v string) error { + return validateEnum("anvil/state", State(v), StateValues()) +} + +// --------------------------------------------------------------------------- +// FROZEN ENUM 2 of 6 — anvil/status (per half) +// --------------------------------------------------------------------------- + +// HalfStatus is the per-half run status, `run.properties["anvil/status"]` and +// `audit_record.sast_status` / `.dast_status`'s sealing counterpart. +// +// FROZEN by plan/IMPLEMENTATION-PLAN.md §6 ruling G5. Area O previously +// declared `complete|failed|timed_out`; `complete` is struck in favour of +// HalfStatusSealed, and `timed_out` is adopted from O. +type HalfStatus string + +// The five legal per-half anvil/status literals. +// +// HalfStatusSealed is load-bearing, not cosmetic: R.6 makes `sealed` the hard +// consumer read gate ("do not allow a consumer to read a half's results +// before that half's status equals sealed"). An area keying its transition on +// any other token means the gate never opens and the consumer never runs. +const ( + // HalfStatusRunning: the half is still producing results. + HalfStatusRunning HalfStatus = "running" + // HalfStatusSealed: the half is complete and readable. This exact token + // is R.6's read gate. + HalfStatusSealed HalfStatus = "sealed" + // HalfStatusFailed: the half terminated abnormally. Results, if any, are + // not readable — a failed half is not a clean half. + HalfStatusFailed HalfStatus = "failed" + // HalfStatusTimedOut: the half exceeded its own deadline + // (Deadline.DastDeadlineSeconds for the DAST half). Distinct from + // HalfStatusFailed so an operator can tell "it broke" from "it ran out + // of clock". + HalfStatusTimedOut HalfStatus = "timed_out" + // HalfStatusSkipped: the half was not run at all (e.g. the DAST tier is + // not installed — plan/00-SPINE.md S9-AMENDED ships DAST as a separate + // distribution artifact, so most halves will be skipped). + HalfStatusSkipped HalfStatus = "skipped" +) + +// HalfStatusValues returns every legal per-half anvil/status literal. +func HalfStatusValues() []HalfStatus { + return []HalfStatus{ + HalfStatusRunning, HalfStatusSealed, HalfStatusFailed, + HalfStatusTimedOut, HalfStatusSkipped, + } +} + +// Valid reports whether s is one of the five legal per-half status literals. +func (s HalfStatus) Valid() bool { return inEnum(s, HalfStatusValues()) } + +// ValidateHalfStatus reports whether v is a legal per-half anvil/status +// literal. +func ValidateHalfStatus(v string) error { + return validateEnum("anvil/status", HalfStatus(v), HalfStatusValues()) +} + +// --------------------------------------------------------------------------- +// FROZEN ENUM 3 of 6 — anvil/dastStatus +// --------------------------------------------------------------------------- + +// DastStatus is the audit-level DAST outcome, +// `sarifLog.properties["anvil/dastStatus"]` and `audit_record.dast_status`. +// It is NEVER null and never absent. +// +// FROZEN by plan/IMPLEMENTATION-PLAN.md §6 rulings G3+G6, found independently +// by two critics. Area 40 declared seven values and area D declared five with +// ZERO literal overlap — D could not have written a single row into 40's NOT +// NULL column. The frozen set is the union of both plus D's `partial` +// (renamed `completed_partial`), which is nine values. D.26 emits these. +// +// This value is DERIVED from the DAST half's HalfStatus and from +// TargetProvenance (the boot/reachability outcome), never from +// TargetProvisioning (which provisioning path was used). +type DastStatus string + +// The nine legal anvil/dastStatus literals. +// +// WHY DastStatusSkippedNoManifest IS DISTINCT FROM DastStatusNotRun — do not +// merge them: +// +// - DastStatusNotRun means the DAST tier is not installed at all. Under +// plan/00-SPINE.md S9-AMENDED, DAST ships as a separate distribution +// artifact, so this is the common case and it says nothing about the +// target. +// - DastStatusSkippedNoManifest means the DAST tier IS installed and ran, +// and no target manifest was declared, so there was nothing to scan. +// That is a configuration gap in the target, and it is actionable. +// +// plan/00-SPINE.md S6 requires that "a target that failed to boot must be +// distinguishable from 'scanned clean'"; the same argument applies one level +// up. research/23-dast-signal-sources.md Risk #1: "Anvil must never report +// '0 DAST findings' as 'no dynamic vulnerabilities'." Merging these two makes +// exactly that mistake unfixable at the schema level. +const ( + // DastStatusNotRun: the DAST tier is not installed. Default for the + // SAST-only distribution artifact. + DastStatusNotRun DastStatus = "not_run" + // DastStatusSkippedNoManifest: the DAST tier ran; no target manifest was + // declared. NOT the same as not_run — see above. + DastStatusSkippedNoManifest DastStatus = "skipped_no_manifest" + // DastStatusRunning: the DAST half has not reached a terminal state. + DastStatusRunning DastStatus = "running" + // DastStatusCompletedClean: the DAST half completed and found nothing. + // The ONLY value that may be read as "dynamically scanned, no findings". + DastStatusCompletedClean DastStatus = "completed_clean" + // DastStatusCompletedFindings: the DAST half completed with findings. + DastStatusCompletedFindings DastStatus = "completed_findings" + // DastStatusCompletedPartial: the DAST half completed against only part + // of the discovered attack surface. Adopted from area D's `partial`; + // coverage detail lives in DastCoverage, which is what makes this value + // interpretable rather than merely worrying. + DastStatusCompletedPartial DastStatus = "completed_partial" + // DastStatusTargetBootFailed: the target never booted, so nothing was + // scanned. Derived from TargetProvenanceBootFailed or + // TargetProvenanceBuildFailed. + DastStatusTargetBootFailed DastStatus = "target_boot_failed" + // DastStatusTargetUnreachable: the target booted but was not reachable + // at scan time. Derived from TargetProvenanceUnreachableAtScanTime. + DastStatusTargetUnreachable DastStatus = "target_unreachable" + // DastStatusTimedOut: the DAST half exceeded Deadline.DastDeadlineSeconds. + DastStatusTimedOut DastStatus = "timed_out" +) + +// DastStatusValues returns every legal anvil/dastStatus literal. +func DastStatusValues() []DastStatus { + return []DastStatus{ + DastStatusNotRun, DastStatusSkippedNoManifest, DastStatusRunning, + DastStatusCompletedClean, DastStatusCompletedFindings, + DastStatusCompletedPartial, DastStatusTargetBootFailed, + DastStatusTargetUnreachable, DastStatusTimedOut, + } +} + +// Valid reports whether s is one of the nine legal anvil/dastStatus literals. +func (s DastStatus) Valid() bool { return inEnum(s, DastStatusValues()) } + +// ValidateDastStatus reports whether v is a legal anvil/dastStatus literal. +func ValidateDastStatus(v string) error { + return validateEnum("anvil/dastStatus", DastStatus(v), DastStatusValues()) +} + +// MeansDynamicallyScannedClean reports whether s is the one value a consumer +// may treat as "this target was dynamically scanned and nothing was found". +// Every other value — including the three that merely mean "no findings were +// recorded" — must not be read that way. +// +// Provided so consumers do not write `if s != "completed_findings"`, which is +// the naive equality check D.26's own validation forbids. +func (s DastStatus) MeansDynamicallyScannedClean() bool { + return s == DastStatusCompletedClean +} + +// --------------------------------------------------------------------------- +// FROZEN ENUM 4 of 6 — anvil/target.provenance +// --------------------------------------------------------------------------- + +// TargetProvenance is the target's BOOT AND REACHABILITY OUTCOME, +// `sarifLog.properties["anvil/target"].provenance` and +// `audit_record.target_provenance`. +// +// FROZEN by plan/IMPLEMENTATION-PLAN.md §6 rulings G4+G7, found independently +// by two critics. Produced by the target lifecycle harness (area D); this +// area only reserves the field and owns the vocabulary. +// +// WHY THIS IS SEPARATE FROM TargetProvisioning — do not merge them back into +// one field: +// +// They are two different measurements that were previously one field name +// carrying two meanings. TargetProvenance answers "what happened when we +// tried to run the target"; TargetProvisioning answers "which provisioning +// path did we take to get one". DastStatus is derived from the FORMER +// (booted_clean → the DAST half's own outcome; boot_failed/build_failed → +// target_boot_failed; unreachable_at_scan_time → target_unreachable). A merged +// field cannot support that derivation, and plan/00-SPINE.md S6 requires that +// "a target that failed to boot must be distinguishable from scanned clean" — +// which is information a merged field loses. +type TargetProvenance string + +// The five legal anvil/target.provenance literals. +const ( + // TargetProvenanceBootedClean: the target came up and stayed up. + TargetProvenanceBootedClean TargetProvenance = "booted_clean" + // TargetProvenanceBootFailed: the target image built but would not start + // or never became healthy. + TargetProvenanceBootFailed TargetProvenance = "boot_failed" + // TargetProvenanceBuildFailed: the target image never built, so a boot + // was never attempted. Distinct from boot_failed because the remediation + // is different (a build break, not a runtime break). + TargetProvenanceBuildFailed TargetProvenance = "build_failed" + // TargetProvenanceNoTargetDeclared: no runtime target was declared for + // this scan. Maps to DastStatusSkippedNoManifest, not to a boot failure. + TargetProvenanceNoTargetDeclared TargetProvenance = "no_target_declared" + // TargetProvenanceUnreachableAtScanTime: the target was declared and + // believed healthy, but was not reachable when the DAST half ran. + TargetProvenanceUnreachableAtScanTime TargetProvenance = "unreachable_at_scan_time" +) + +// TargetProvenanceValues returns every legal anvil/target.provenance literal. +func TargetProvenanceValues() []TargetProvenance { + return []TargetProvenance{ + TargetProvenanceBootedClean, TargetProvenanceBootFailed, + TargetProvenanceBuildFailed, TargetProvenanceNoTargetDeclared, + TargetProvenanceUnreachableAtScanTime, + } +} + +// Valid reports whether p is one of the five legal provenance literals. +func (p TargetProvenance) Valid() bool { return inEnum(p, TargetProvenanceValues()) } + +// ValidateTargetProvenance reports whether v is a legal +// anvil/target.provenance literal. +func ValidateTargetProvenance(v string) error { + return validateEnum("anvil/target.provenance", TargetProvenance(v), TargetProvenanceValues()) +} + +// --------------------------------------------------------------------------- +// FROZEN ENUM 5 of 6 — anvil/target.provisioning (NEW required field) +// --------------------------------------------------------------------------- + +// TargetProvisioning is WHICH PROVISIONING PATH produced the runtime target, +// `sarifLog.properties["anvil/target"].provisioning`. +// +// NEW REQUIRED FIELD, created by plan/IMPLEMENTATION-PLAN.md §6 rulings +// G4+G7. Area D previously wrote these two literals into a field it called +// `target_provenance`, which collided with area 40's boot-outcome field of +// the same name; the ruling split them rather than picking one, because they +// are genuinely different measurements and both are required. D.26 writes +// this field. +// +// The authorization consequence is why this cannot be folded into a comment: +// plan/00-SPINE.md S7 makes the authorization kernel a pure function of +// (target, scope, attestation, clock). A live, third-party-owned URL and a +// throwaway container Anvil built itself are not the same authorization +// question, and the record must state which one was scanned. +type TargetProvisioning string + +// The two legal anvil/target.provisioning literals. +const ( + // TargetProvisioningEphemeralManifest: Anvil built and ran the target + // itself from a declared manifest, in its own sandbox. + TargetProvisioningEphemeralManifest TargetProvisioning = "ephemeral_manifest" + // TargetProvisioningLiveURLAuthorized: an already-running URL was + // scanned under an explicit authorization record. Never inferred from + // reachability, and never from security.txt — plan/00-SPINE.md S7: + // "security.txt resolves a reporting channel and never grants + // permission." + TargetProvisioningLiveURLAuthorized TargetProvisioning = "live_url_authorized" +) + +// TargetProvisioningValues returns every legal anvil/target.provisioning +// literal. +func TargetProvisioningValues() []TargetProvisioning { + return []TargetProvisioning{ + TargetProvisioningEphemeralManifest, + TargetProvisioningLiveURLAuthorized, + } +} + +// Valid reports whether p is one of the two legal provisioning literals. +func (p TargetProvisioning) Valid() bool { return inEnum(p, TargetProvisioningValues()) } + +// ValidateTargetProvisioning reports whether v is a legal +// anvil/target.provisioning literal. +func ValidateTargetProvisioning(v string) error { + return validateEnum("anvil/target.provisioning", TargetProvisioning(v), TargetProvisioningValues()) +} + +// --------------------------------------------------------------------------- +// FROZEN ENUM 6 of 6 — anvil/verdict +// --------------------------------------------------------------------------- + +// Verdict is the triage judgment about a FINDING, +// `result.properties["anvil/verdict"]` and `finding.verdict`. +// +// FROZEN by plan/IMPLEMENTATION-PLAN.md §6 ruling G8. Lane B keeps its own +// in-process `Verdict.Result` vocabulary (`EXHIBITS|…`), which is a judgment +// about the CODE; B.12 owns the explicit, tested mapping onto these literals, +// including the case normalisation, at the point it places findings on the +// record. A mapping with an owner and a test is not the same thing as two +// vocabularies drifting. +type Verdict string + +// The three legal anvil/verdict literals. +// +// WHY VerdictInsufficientContext IS A VERDICT AND NOT A LOW CONFIDENCE SCORE — +// do not replace it with a threshold on Confidence: +// +// plan/00-SPINE.md S6 is explicit: "INSUFFICIENT_CONTEXT as a valid detector +// verdict, not just a confidence float." A low confidence score means "this +// is probably not a real defect". `insufficient_context` means "this may well +// be a real defect and the detector could not see enough to tell" — usually +// because the sink is behind a dynamic dispatch, a framework boundary, or a +// file the scan did not have. Those two demand opposite handling: the first +// is dropped, the second is escalated to a human or to the DAST half, and is +// exactly the population a confidence threshold silently discards. The +// consumption pipeline drops VerdictFalsePositive and demotes +// VerdictInsufficientContext to report-only; it must be able to tell them +// apart, and a float cannot. +const ( + // VerdictTruePositive: the finding is a real defect. The only verdict + // the coding-agent consumption pipeline acts on. + VerdictTruePositive Verdict = "true_positive" + // VerdictFalsePositive: the finding is not a real defect. Dropped by the + // consumption pipeline. + VerdictFalsePositive Verdict = "false_positive" + // VerdictInsufficientContext: the detector could not decide with the + // context it had. Report-only; never silently dropped. See above. + VerdictInsufficientContext Verdict = "insufficient_context" +) + +// VerdictValues returns every legal anvil/verdict literal. +func VerdictValues() []Verdict { + return []Verdict{VerdictTruePositive, VerdictFalsePositive, VerdictInsufficientContext} +} + +// Valid reports whether v is one of the three legal anvil/verdict literals. +func (v Verdict) Valid() bool { return inEnum(v, VerdictValues()) } + +// ValidateVerdict reports whether v is a legal anvil/verdict literal. +func ValidateVerdict(v string) error { + return validateEnum("anvil/verdict", Verdict(v), VerdictValues()) +} + +// --------------------------------------------------------------------------- +// anvil/trust — required by plan/00-SPINE.md S6 on EVERY string originating +// outside Anvil. Frozen here for the same reason as the six above. +// --------------------------------------------------------------------------- + +// Trust classifies where a string came from. plan/00-SPINE.md S6 requires it +// "on every string originating outside Anvil"; plan/00-SPINE.md S7 makes it +// enforceable: the prompt builder must never treat untrusted text as +// instructions, and "the DAST response body is the highest-risk field — up to +// 32 KB of attacker-controlled bytes fed to a repo-credentialed agent." +type Trust string + +// The three legal anvil/trust literals. +const ( + // TrustUntrusted: the bytes originated outside Anvil and have not been + // validated. Target-repo source, advisory feed text, DAST response + // bodies, and imported third-party SARIF are all untrusted. + // + // THE MISTAKE THIS EXISTS TO PREVENT: a repo source snippet is + // `untrusted` even though Anvil is the component that put it in the + // struct. Area B was found stamping TrustAnvilGenerated on a struct + // whose Snippet field is verbatim target-repo source. That would have + // disabled the prompt-injection containment check on the exact string + // that most needs it — an attacker who can commit to the scanned repo + // can write agent instructions into a comment. The question TrustLevel + // answers is "who wrote these bytes", never "who assigned this field". + TrustUntrusted Trust = "untrusted" + // TrustAnvilGenerated: Anvil itself produced the bytes — detector + // reasoning, derived summaries, computed digests. Not merely + // "assembled by Anvil". + TrustAnvilGenerated Trust = "anvil_generated" + // TrustVerified: the bytes originated outside Anvil AND passed an + // explicit validation step that is named in the record (e.g. a + // signature-checked advisory snapshot). Never a default. + TrustVerified Trust = "verified" +) + +// TrustValues returns every legal anvil/trust literal. +func TrustValues() []Trust { + return []Trust{TrustUntrusted, TrustAnvilGenerated, TrustVerified} +} + +// Valid reports whether t is one of the three legal anvil/trust literals. +func (t Trust) Valid() bool { return inEnum(t, TrustValues()) } + +// ValidateTrust reports whether v is a legal anvil/trust literal. +func ValidateTrust(v string) error { + return validateEnum("anvil/trust", Trust(v), TrustValues()) +} + +// LegalForExternalString reports whether t may be applied to a string that +// originated outside Anvil. TrustAnvilGenerated may not: that is precisely +// the mislabelling ruled on above. +func (t Trust) LegalForExternalString() bool { + return t == TrustUntrusted || t == TrustVerified +} + +// --------------------------------------------------------------------------- +// handoff.state — frozen alongside the six, per plan/IMPLEMENTATION-PLAN.md +// §6's enum block. The TABLE is R.4's; the VOCABULARY is this file's. +// --------------------------------------------------------------------------- + +// HandoffState is the disposition of one finding in the handoff queue, +// `handoff.state`. +// +// FROZEN by plan/IMPLEMENTATION-PLAN.md §6 rulings G9+G10. Area X was +// building a second table (`anvil_ledger`) carrying the same dispositions; +// that is collapsed into `handoff`, because R.4's own Forbidden actions +// already say a second durable copy is a direct plan/00-SPINE.md S1 +// violation. The concrete failure the critic traced: X.9 wrote +// `SKIPPED_BUDGET` to `anvil_ledger` while 40's ready-set index still saw the +// finding as `ready`, so it was re-leased forever. +// +// R.4 owns the DDL; every area emits these literals. +type HandoffState string + +// The thirteen legal handoff.state literals: area 40's original nine plus the +// four dispositions only area X had. +const ( + HandoffStateReady HandoffState = "ready" + HandoffStateLeased HandoffState = "leased" + HandoffStateValidated HandoffState = "validated" + HandoffStateFailedValidation HandoffState = "failed_validation" + HandoffStateFailedFormat HandoffState = "failed_format" + HandoffStateSkippedBudget HandoffState = "skipped_budget" + HandoffStateFalsePositive HandoffState = "false_positive" + HandoffStateRegressionIntroduced HandoffState = "regression_introduced" + // The four from area X: + HandoffStateFixedIncidentally HandoffState = "fixed_incidentally" + HandoffStateSplitRequired HandoffState = "split_required" + HandoffStateWithdrawn HandoffState = "withdrawn" + HandoffStateSuperseded HandoffState = "superseded" + // HandoffStateExpired is set at claim-timeout expiry. Only the tmpfs + // packet is dropped; the row is kept. + HandoffStateExpired HandoffState = "expired" +) + +// HandoffStateValues returns every legal handoff.state literal. +func HandoffStateValues() []HandoffState { + return []HandoffState{ + HandoffStateReady, HandoffStateLeased, HandoffStateValidated, + HandoffStateFailedValidation, HandoffStateFailedFormat, + HandoffStateSkippedBudget, HandoffStateFalsePositive, + HandoffStateRegressionIntroduced, HandoffStateFixedIncidentally, + HandoffStateSplitRequired, HandoffStateWithdrawn, + HandoffStateSuperseded, HandoffStateExpired, + } +} + +// Valid reports whether s is one of the thirteen legal handoff.state literals. +func (s HandoffState) Valid() bool { return inEnum(s, HandoffStateValues()) } + +// ValidateHandoffState reports whether v is a legal handoff.state literal. +func ValidateHandoffState(v string) error { + return validateEnum("handoff.state", HandoffState(v), HandoffStateValues()) +} + +// ConsumptionClass gates whether a finding may be acted on from static +// evidence alone, `handoff.consumption_class`. Merged into the handoff table +// by plan/IMPLEMENTATION-PLAN.md §6 ruling G9 (it came from area O.3, and +// nothing else in the schema can express the gate). +type ConsumptionClass string + +const ( + // ConsumptionClassStaticOnly: static evidence is sufficient to propose a + // patch. + ConsumptionClassStaticOnly ConsumptionClass = "static_only" + // ConsumptionClassRequiresDynamicConfirmation: a DAST reproduction must + // exist before the coding agent acts. plan/00-SPINE.md S7: "Only a DAST + // reproduction that now fails earns 'verified fixed.'" + ConsumptionClassRequiresDynamicConfirmation ConsumptionClass = "requires_dynamic_confirmation" +) + +// ConsumptionClassValues returns every legal handoff.consumption_class +// literal. +func ConsumptionClassValues() []ConsumptionClass { + return []ConsumptionClass{ + ConsumptionClassStaticOnly, + ConsumptionClassRequiresDynamicConfirmation, + } +} + +// Valid reports whether c is a legal consumption_class literal. +func (c ConsumptionClass) Valid() bool { return inEnum(c, ConsumptionClassValues()) } + +// ValidateConsumptionClass reports whether v is a legal +// handoff.consumption_class literal. +func ValidateConsumptionClass(v string) error { + return validateEnum("handoff.consumption_class", ConsumptionClass(v), ConsumptionClassValues()) +} + +// --------------------------------------------------------------------------- +// Area-40-owned supporting vocabularies. Not among the six §6 named, but +// shared across areas and therefore declared here for the same reason. +// --------------------------------------------------------------------------- + +// Half names which of the two independently-sealed halves produced a run or a +// result: `run.properties["anvil/half"]`, `result.properties["anvil/half"]`. +type Half string + +const ( + HalfSast Half = "sast" + HalfDast Half = "dast" +) + +// HalfValues returns every legal anvil/half literal. +func HalfValues() []Half { return []Half{HalfSast, HalfDast} } + +// Valid reports whether h is a legal anvil/half literal. +func (h Half) Valid() bool { return inEnum(h, HalfValues()) } + +// ValidateHalf reports whether v is a legal anvil/half literal. +func ValidateHalf(v string) error { return validateEnum("anvil/half", Half(v), HalfValues()) } + +// EvidenceClass says HOW STRONG the evidence for a finding is, +// `result.properties["anvil/evidenceClass"]` and `finding.evidence_class`. +// research/24-coding-agent-consumption.md: "this is the field that makes +// tier-0 ordering possible". Consumed by the queue re-cut (R.11) and the read +// path (R.13). +type EvidenceClass string + +const ( + // EvidenceClassDastConfirmed: a runtime reproduction exists. The class + // R.11 reserves budget for. + EvidenceClassDastConfirmed EvidenceClass = "dast_confirmed" + // EvidenceClassSastReachable: static analysis proved a path from an + // untrusted source to the sink. + EvidenceClassSastReachable EvidenceClass = "sast_reachable" + // EvidenceClassSastStaticOnly: the sink matched, reachability was not + // established. + EvidenceClassSastStaticOnly EvidenceClass = "sast_static_only" + // EvidenceClassSCA: a dependency matched a vulnerable version range + // (Lane A, zero inference). + EvidenceClassSCA EvidenceClass = "sca" + // EvidenceClassHost: a host package matched. Always + // RemediableByAgent=false — plan/00-SPINE.md S7 makes the host agent + // read-only, "no package manager in a mutating mode, not behind a flag." + EvidenceClassHost EvidenceClass = "host" +) + +// EvidenceClassValues returns every legal anvil/evidenceClass literal, in +// descending evidence strength — which is also the default rank order. +func EvidenceClassValues() []EvidenceClass { + return []EvidenceClass{ + EvidenceClassDastConfirmed, EvidenceClassSastReachable, + EvidenceClassSastStaticOnly, EvidenceClassSCA, EvidenceClassHost, + } +} + +// Valid reports whether e is a legal anvil/evidenceClass literal. +func (e EvidenceClass) Valid() bool { return inEnum(e, EvidenceClassValues()) } + +// ValidateEvidenceClass reports whether v is a legal anvil/evidenceClass +// literal. +func ValidateEvidenceClass(v string) error { + return validateEnum("anvil/evidenceClass", EvidenceClass(v), EvidenceClassValues()) +} + +// DetectorKind selects the fingerprint tier (R.2) and populates +// `finding.detector`. It is deliberately NOT the same enum as EvidenceClass: +// `sast_reachable` and `sast_static_only` are both produced by the `sast` +// detector and must hash identically per tier. +type DetectorKind string + +const ( + DetectorKindSast DetectorKind = "sast" + DetectorKindDast DetectorKind = "dast" + DetectorKindSCA DetectorKind = "sca" + DetectorKindHost DetectorKind = "host" +) + +// DetectorKindValues returns every legal finding.detector literal. +func DetectorKindValues() []DetectorKind { + return []DetectorKind{DetectorKindSast, DetectorKindDast, DetectorKindSCA, DetectorKindHost} +} + +// Valid reports whether d is a legal finding.detector literal. +func (d DetectorKind) Valid() bool { return inEnum(d, DetectorKindValues()) } + +// ValidateDetectorKind reports whether v is a legal finding.detector literal. +func ValidateDetectorKind(v string) error { + return validateEnum("finding.detector", DetectorKind(v), DetectorKindValues()) +} + +// InjectionPoint is WHERE a DAST payload was injected. Hashed by the DAST +// fingerprint tier (R.2), so the vocabulary is contract, not implementation. +type InjectionPoint string + +const ( + InjectionPointQuery InjectionPoint = "query" + InjectionPointBody InjectionPoint = "body" + InjectionPointHeader InjectionPoint = "header" + InjectionPointCookie InjectionPoint = "cookie" + InjectionPointPath InjectionPoint = "path" +) + +// InjectionPointValues returns every legal injection-point literal. +func InjectionPointValues() []InjectionPoint { + return []InjectionPoint{ + InjectionPointQuery, InjectionPointBody, InjectionPointHeader, + InjectionPointCookie, InjectionPointPath, + } +} + +// Valid reports whether i is a legal injection-point literal. +func (i InjectionPoint) Valid() bool { return inEnum(i, InjectionPointValues()) } + +// ValidateInjectionPoint reports whether v is a legal injection-point literal. +func ValidateInjectionPoint(v string) error { + return validateEnum("anvil/repro.injectionPoint.kind", InjectionPoint(v), InjectionPointValues()) +} + +// EvidenceSignal is HOW a DAST vulnerability was observed. Independent of +// InjectionPoint — where the payload went in and how the defect showed up are +// two different facts — and hashed as a separate field by the DAST +// fingerprint tier (plan/40-record-and-storage.md, Fingerprint Specification). +type EvidenceSignal string + +const ( + EvidenceSignalResponseStackTrace EvidenceSignal = "responseStackTrace" + EvidenceSignalStatusCodeFlip EvidenceSignal = "statusCodeFlip" + EvidenceSignalDBErrorString EvidenceSignal = "dbErrorString" + EvidenceSignalTimingSideChannel EvidenceSignal = "timingSideChannel" + EvidenceSignalReflectedPayload EvidenceSignal = "reflectedPayload" + EvidenceSignalOther EvidenceSignal = "other" +) + +// EvidenceSignalValues returns every legal evidence-signal literal. +func EvidenceSignalValues() []EvidenceSignal { + return []EvidenceSignal{ + EvidenceSignalResponseStackTrace, EvidenceSignalStatusCodeFlip, + EvidenceSignalDBErrorString, EvidenceSignalTimingSideChannel, + EvidenceSignalReflectedPayload, EvidenceSignalOther, + } +} + +// Valid reports whether s is a legal evidence-signal literal. +func (s EvidenceSignal) Valid() bool { return inEnum(s, EvidenceSignalValues()) } + +// ValidateEvidenceSignal reports whether v is a legal evidence-signal literal. +func ValidateEvidenceSignal(v string) error { + return validateEnum("anvil/repro.observedSignal.kind", EvidenceSignal(v), EvidenceSignalValues()) +} + +// CorrelationSignal names one independent correlation signal. +// research/18-unified-audit-record.md Table 2 and its correlation policy: +// at least two independent signals are required before a link may be emitted, +// and CorrelationSignalCweMatch is BANNED as a sole signal. +type CorrelationSignal string + +const ( + CorrelationSignalResponseStackTrace CorrelationSignal = "responseStackTrace" + CorrelationSignalRouteTable CorrelationSignal = "routeTable" + CorrelationSignalCallGraphReach CorrelationSignal = "callGraphReach" + CorrelationSignalParameterName CorrelationSignal = "parameterName" + CorrelationSignalCweMatch CorrelationSignal = "cweMatch" + CorrelationSignalRerunFlip CorrelationSignal = "rerunFlip" +) + +// CorrelationSignalValues returns every legal correlation-signal literal. +func CorrelationSignalValues() []CorrelationSignal { + return []CorrelationSignal{ + CorrelationSignalResponseStackTrace, CorrelationSignalRouteTable, + CorrelationSignalCallGraphReach, CorrelationSignalParameterName, + CorrelationSignalCweMatch, CorrelationSignalRerunFlip, + } +} + +// Valid reports whether s is a legal correlation-signal literal. +func (s CorrelationSignal) Valid() bool { return inEnum(s, CorrelationSignalValues()) } + +// ValidateCorrelationSignal reports whether v is a legal correlation-signal +// literal. +func ValidateCorrelationSignal(v string) error { + return validateEnum("anvil/correlation.signals[].name", CorrelationSignal(v), CorrelationSignalValues()) +} + +// SufficientForVerified reports whether s is one of the two signals that may +// set Correlation.Verified. plan/00-SPINE.md S7: "Only a DAST reproduction +// that now fails earns 'verified fixed.' A clean SAST rescan does not." +// Confidence alone never qualifies. +func (s CorrelationSignal) SufficientForVerified() bool { + return s == CorrelationSignalResponseStackTrace || s == CorrelationSignalRerunFlip +} + +// FindingState is the durable lifecycle state of a finding, `finding.state`. +type FindingState string + +const ( + FindingStateOpen FindingState = "open" + FindingStateResolved FindingState = "resolved" + FindingStateSuppressed FindingState = "suppressed" + FindingStateRegressed FindingState = "regressed" +) + +// FindingStateValues returns every legal finding.state literal. +func FindingStateValues() []FindingState { + return []FindingState{ + FindingStateOpen, FindingStateResolved, + FindingStateSuppressed, FindingStateRegressed, + } +} + +// Valid reports whether s is a legal finding.state literal. +func (s FindingState) Valid() bool { return inEnum(s, FindingStateValues()) } + +// ValidateFindingState reports whether v is a legal finding.state literal. +func ValidateFindingState(v string) error { + return validateEnum("finding.state", FindingState(v), FindingStateValues()) +} + +// ScanRunStatus is the whole-scan status, `scan_run.status`. Written by the +// scan controller (area O), read by area 40's store. +type ScanRunStatus string + +const ( + ScanRunStatusRunning ScanRunStatus = "running" + ScanRunStatusOK ScanRunStatus = "ok" + ScanRunStatusFailed ScanRunStatus = "failed" + ScanRunStatusPartial ScanRunStatus = "partial" +) + +// ScanRunStatusValues returns every legal scan_run.status literal. +func ScanRunStatusValues() []ScanRunStatus { + return []ScanRunStatus{ + ScanRunStatusRunning, ScanRunStatusOK, + ScanRunStatusFailed, ScanRunStatusPartial, + } +} + +// Valid reports whether s is a legal scan_run.status literal. +func (s ScanRunStatus) Valid() bool { return inEnum(s, ScanRunStatusValues()) } + +// ValidateScanRunStatus reports whether v is a legal scan_run.status literal. +func ValidateScanRunStatus(v string) error { + return validateEnum("scan_run.status", ScanRunStatus(v), ScanRunStatusValues()) +} + +// InventoryProvenance says how an endpoint entered the DAST inventory. +// +// NOT one of the six frozen enums. Area D (D.18–D.25) produces it per route +// and aggregates it into DastCoverage.InventoryProvenanceMix. It is mirrored +// here so the record's shape is knowable and so a naming drift is caught at +// this file rather than at integration; if area D needs to change the +// vocabulary, it amends this file rather than diverging from it. Flagged to +// the orchestrator as a candidate seventh frozen enum. +type InventoryProvenance string + +const ( + // InventoryProvenanceRuntimeSpec: an OpenAPI/GraphQL spec served by the + // running target. + InventoryProvenanceRuntimeSpec InventoryProvenance = "runtime_spec" + // InventoryProvenanceRepoSpec: a spec file committed to the repo. + InventoryProvenanceRepoSpec InventoryProvenance = "repo_spec" + // InventoryProvenanceStaticExtraction: routes extracted from source. + InventoryProvenanceStaticExtraction InventoryProvenance = "static_extraction" + // InventoryProvenanceCrawl: routes found by crawling. Weakest evidence. + InventoryProvenanceCrawl InventoryProvenance = "crawl" +) + +// InventoryProvenanceValues returns every legal inventory-provenance literal, +// strongest evidence first. +func InventoryProvenanceValues() []InventoryProvenance { + return []InventoryProvenance{ + InventoryProvenanceRuntimeSpec, InventoryProvenanceRepoSpec, + InventoryProvenanceStaticExtraction, InventoryProvenanceCrawl, + } +} + +// Valid reports whether p is a legal inventory-provenance literal. +func (p InventoryProvenance) Valid() bool { return inEnum(p, InventoryProvenanceValues()) } + +// ValidateInventoryProvenance reports whether v is a legal +// inventory-provenance literal. +func ValidateInventoryProvenance(v string) error { + return validateEnum("anvil/dastCoverage.inventoryProvenanceMix", InventoryProvenance(v), InventoryProvenanceValues()) +} + +// SARIF-native enums. Listed for completeness and constant-safety; these are +// OASIS's vocabulary, not Anvil's, and must not be extended. +type ( + // Level is SARIF §3.27.10 `result.level` — severity, not confidence. + Level string + // Kind is SARIF §3.27.9 `result.kind`. + Kind string +) + +const ( + LevelNone Level = "none" + LevelNote Level = "note" + LevelWarning Level = "warning" + LevelError Level = "error" + + KindNotApplicable Kind = "notApplicable" + KindPass Kind = "pass" + KindFail Kind = "fail" + KindReview Kind = "review" + KindOpen Kind = "open" + KindInformational Kind = "informational" +) + +// LevelValues returns every legal SARIF result.level literal. +func LevelValues() []Level { return []Level{LevelNone, LevelNote, LevelWarning, LevelError} } + +// Valid reports whether l is a legal SARIF result.level literal. +func (l Level) Valid() bool { return inEnum(l, LevelValues()) } + +// KindValues returns every legal SARIF result.kind literal. +func KindValues() []Kind { + return []Kind{KindNotApplicable, KindPass, KindFail, KindReview, KindOpen, KindInformational} +} + +// Valid reports whether k is a legal SARIF result.kind literal. +func (k Kind) Valid() bool { return inEnum(k, KindValues()) } + +// --------------------------------------------------------------------------- +// anvil/* property-key constants +// +// Every extension key, in one place, so no area spells one by hand. Grouped +// by the SARIF object whose `properties` bag carries it. +// --------------------------------------------------------------------------- + +// Keys in `sarifLog.properties`. +const ( + PropAuditSchemaVersion = "anvil/schemaVersion" + PropAuditID = "anvil/auditId" + PropAuditState = "anvil/state" + PropAuditVersion = "anvil/version" + PropAuditCreatedAt = "anvil/createdAt" + PropAuditTarget = "anvil/target" + PropAuditTrigger = "anvil/trigger" + PropAuditDeadline = "anvil/deadline" + PropAuditDB = "anvil/db" + PropAuditIndex = "anvil/index" + PropAuditDastStatus = "anvil/dastStatus" +) + +// Keys in `run.properties`. +const ( + PropRunHalf = "anvil/half" + PropRunStatus = "anvil/status" + PropRunSealedAt = "anvil/sealedAt" + PropRunDastCoverage = "anvil/dastCoverage" + PropRunRouteTableDigest = "anvil/routeTableDigest" + PropRunAdvisorySnapshot = "anvil/advisorySnapshot" + PropRunRuntimeTarget = "anvil/runtimeTarget" +) + +// Keys in `result.properties`. +const ( + PropResultFindingID = "anvil/findingId" + PropResultHalf = "anvil/half" + PropResultConfidence = "anvil/confidence" + PropResultVerdict = "anvil/verdict" + PropResultRemediableByAgent = "anvil/remediableByAgent" + PropResultReasoning = "anvil/reasoning" + PropResultDetector = "anvil/detector" + PropResultAdvisory = "anvil/advisory" + PropResultTrust = "anvil/trust" + PropResultPatchContext = "anvil/patchContext" + PropResultCorrelation = "anvil/correlation" + PropResultRepro = "anvil/repro" + PropResultChunkRef = "anvil/chunkRef" + PropResultEvidenceClass = "anvil/evidenceClass" + PropResultLocus = "anvil/locus" + PropResultGroupID = "anvil/groupId" + PropResultRisk = "anvil/risk" +) + +// Keys in `location.properties` (SARIF §3.28.6). +const ( + PropLocationKind = "anvil/locationKind" + PropLocationRouteTemplate = "anvil/routeTemplate" +) + +// --------------------------------------------------------------------------- +// Audit envelope — sarifLog and its anvil/* bag +// --------------------------------------------------------------------------- + +// SARIFLog is the top-level SARIF 2.1.0 object Anvil produces: one audit +// identity carrying two independently-sealed halves as two runs +// (plan/00-SPINE.md S1). +type SARIFLog struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Runs []Run `json:"runs"` + Properties AuditProperties `json:"properties"` +} + +// AuditProperties is the typed `anvil/*` bag on `sarifLog.properties`. +type AuditProperties struct { + // SchemaVersion is this contract's version. Producer: record assembler. + // Consumer: store, migrations, coding agent. + SchemaVersion string `json:"anvil/schemaVersion"` + + // AuditID is the audit identity, assigned once at scan start. Producer: + // scan controller. Consumer: store (PK), handoff, coding agent, report. + // It is also the value of `run.automationDetails.correlationGuid` in + // BOTH runs — the SARIF-native "these runs are one audit" mechanism. + AuditID string `json:"anvil/auditId"` + + // State is the audit lifecycle state. Producer: scan controller (O.2). + // Consumer: handoff consumer, store, report. + State State `json:"anvil/state"` + + // Version is a monotonic integer, bumped on every re-scan of the same + // audit. Producer: scan controller. Consumer: the queue re-cut (R.11) — + // plan/00-SPINE.md S6 requires re-cutting the work queue on every bump, + // "otherwise incremental publication silently inverts the priority + // scheme." + Version int `json:"anvil/version"` + + // CreatedAt is scan start. Producer: scan controller. Consumer: store, + // reaper. + CreatedAt time.Time `json:"anvil/createdAt"` + + // Target identifies what was scanned. Producer: scan controller (repo + // fields) and the target lifecycle harness (Provenance, Provisioning). + // Consumer: coding agent, correlation, report. + Target Target `json:"anvil/target"` + + // Trigger REFERENCES the policy that fired; the condition itself is + // never encoded in the record (research/18: "No trigger condition is + // ever encoded in the schema itself"). Producer: scan controller. + // Consumer: report, audit trail. + Trigger Trigger `json:"anvil/trigger"` + + // Deadline replaces branch 18's `anvil/buffer`, per the plan/00-SPINE.md + // S1 correction: the 8 hours is a CLAIM TIMEOUT, not a deletion policy + // and not a confidentiality control. Producer: scan controller and the + // config loader. Consumer: reaper, handoff, coding agent. + Deadline Deadline `json:"anvil/deadline"` + + // DB is populated after the store commits. Producer: store writer. + // Consumer: audit trail. Null before commit. + DB *DBRef `json:"anvil/db,omitempty"` + + // Index is the Tier-0 manifest (target <= 8 KB). Producer: record + // assembler. Consumer: coding agent's first read. + Index Index `json:"anvil/index"` + + // DastStatus is the audit-level mirror of the DAST half's outcome. + // REQUIRED AND NEVER NULL. Producer: scan controller, derived from the + // DAST run's HalfStatus and from Target.Provenance. Consumer: coding + // agent, which must not treat an absent DAST half as "scanned clean". + DastStatus DastStatus `json:"anvil/dastStatus"` +} + +// Target identifies the scanned repository and, when DAST is enabled, the +// runtime target. `anvil/target`. +type Target struct { + RepoURL string `json:"repoUrl"` + Ref string `json:"ref"` + Commit string `json:"commit"` + Subpath string `json:"subpath"` + + // RuntimeBaseURL is required only when DAST is enabled. + RuntimeBaseURL string `json:"runtimeBaseUrl,omitempty"` + + // Provenance is the BOOT/REACHABILITY OUTCOME. DastStatus is derived + // from this field. Producer: target lifecycle harness (area D). + Provenance TargetProvenance `json:"provenance"` + + // Provisioning is WHICH PROVISIONING PATH was taken. A different + // measurement from Provenance; see TargetProvisioning's doc comment for + // why merging them loses information plan/00-SPINE.md S6 requires. + // Producer: target lifecycle harness (D.26). + Provisioning TargetProvisioning `json:"provisioning"` +} + +// Trigger names the configured policy that fired. `anvil/trigger`. +type Trigger struct { + Kind string `json:"kind"` + PolicyID string `json:"policyId"` + PolicyRef string `json:"policyRef"` + ConfigSource string `json:"configSource"` + Actor string `json:"actor"` + ResolvedAt time.Time `json:"resolvedAt"` +} + +// Deadline carries the claim clock. `anvil/deadline`. +// +// DeadlineAt and the per-half SealedAt are INDEPENDENT CLOCKS with +// independent semantics and must never be conflated (R.6's forbidden +// actions): SealedAt records per-half completion, DeadlineAt records when an +// unclaimed finding stops being eligible. +type Deadline struct { + // DeadlineAt = scan_run.started_at + ClaimTimeoutSeconds, computed ONCE + // at scan START and never recomputed from any write timestamp. Anchoring + // it to the last write makes the timeout unbounded for a chatty scan. + DeadlineAt time.Time `json:"deadlineAt"` + + // ClaimTimeoutSeconds defaults to DefaultClaimTimeoutSeconds and is + // config-driven. Producer: config loader. Consumer: reaper. + ClaimTimeoutSeconds int `json:"claimTimeoutSeconds"` + + // DastDeadlineSeconds is an INDEPENDENT clock from ClaimTimeoutSeconds, + // null when DAST is disabled. Producer: config loader. Consumer: DAST + // worker, target lifecycle harness. + DastDeadlineSeconds *int `json:"dastDeadlineSeconds"` +} + +// DefaultClaimTimeoutSeconds is 8 hours. It is a claim timeout, not a +// retention or confidentiality guarantee — see internal/record/SECRETS.md +// (R.9). Config-driven; this is only the documented default. +const DefaultClaimTimeoutSeconds = 28800 + +// DBRef records where the audit landed in the store. `anvil/db`. +type DBRef struct { + RecordID string `json:"recordId"` + WrittenAt time.Time `json:"writtenAt"` +} + +// Index is the Tier-0 read plan the coding agent reads first. `anvil/index`. +// Target size <= 8 KB (research/18, "Size — the three-tier read path"). +type Index struct { + Counts IndexCounts `json:"counts"` + + // ReadOrder is DETERMINISTIC and not model-chosen: correlated clusters + // first (they carry runtime proof), then SAST-only by rank, then + // DAST-only. See DefaultReadOrder. + ReadOrder []string `json:"readOrder"` + + ByCluster map[string][]string `json:"byCluster"` + ByCwe map[string][]string `json:"byCwe"` + ByPath map[string][]string `json:"byPath"` + + // TaskCards and Blobs are the Tier-1 and Tier-2 path prefixes. + TaskCards string `json:"taskCards"` + Blobs string `json:"blobs"` +} + +// IndexCounts are the Tier-0 counts. +type IndexCounts struct { + Total int `json:"total"` + Sast int `json:"sast"` + Dast int `json:"dast"` + Clusters int `json:"clusters"` + Unclustered int `json:"unclustered"` +} + +// DefaultReadOrder is the deterministic Tier-0 read order. R.13 must not +// emit any other order without an explicit, logged override. +func DefaultReadOrder() []string { return []string{"clusters", "sastByRank", "dastByRank"} } + +// MaxTier0ManifestBytes is the Tier-0 budget (research/18). +const MaxTier0ManifestBytes = 8 * 1024 + +// MaxTier1CardTokens is the Tier-1 task-card budget in approximate tokens +// (research/18: ~1,500–2,500 tokens/card). +const MaxTier1CardTokens = 2500 + +// MaxAdvisoryExcerptTokens caps an inlined advisory excerpt +// (research/24: "<=800 tokens, pre-trimmed by the ingestion side, never a +// whole advisory"). +const MaxAdvisoryExcerptTokens = 800 + +// --------------------------------------------------------------------------- +// Per-half run +// --------------------------------------------------------------------------- + +// Run is one half of the audit — the SAST half or the DAST half. Both runs +// carry the SAME `automationDetails.correlationGuid`, which is the +// SARIF-native (§3.17.5) statement that they are one audit. +type Run struct { + Tool Tool `json:"tool"` + AutomationDetails RunAutomationDetails `json:"automationDetails"` + OriginalURIBaseIDs map[string]ArtifactLocation `json:"originalUriBaseIds,omitempty"` + Taxonomies []ToolComponent `json:"taxonomies,omitempty"` + Results []Result `json:"results"` + ExternalPropertyFileReferences *ExternalPropertyFileReferences `json:"externalPropertyFileReferences,omitempty"` + Properties RunProperties `json:"properties"` +} + +// RunProperties is the typed `anvil/*` bag on `run.properties`. +type RunProperties struct { + // Half names which half produced this run. Producer: SAST/DAST worker. + // Consumer: routing. + Half Half `json:"anvil/half"` + + // Status is the per-half seal status. HalfStatusSealed is R.6's hard + // consumer read gate. Producer: SAST/DAST worker at seal time. + // Consumer: the re-entrant consumer, report. + Status HalfStatus `json:"anvil/status"` + + // SealedAt is plan/00-SPINE.md S6's per-half `sealedAt`, stored as + // `audit_record.sast_sealed_at` / `.dast_sealed_at`. It is required once + // Status == HalfStatusSealed, and is + // explicitly null otherwise (not omitted — a missing key and an + // unsealed half must not be the same observation). Producer: SAST/DAST + // worker. Consumer: re-entrant consumer read gate, deadline math. + SealedAt *time.Time `json:"anvil/sealedAt"` + + // DastCoverage is required on the DAST run. Producer: attack-surface + // discovery (area D, D.26). Consumer: coding agent (confidence + // weighting), report. + DastCoverage *DastCoverage `json:"anvil/dastCoverage,omitempty"` + + // RouteTableDigest is required on the DAST run. Producer: DAST worker. + // Consumer: audit trail, correlation replay. + RouteTableDigest string `json:"anvil/routeTableDigest,omitempty"` + + // AdvisorySnapshot is required on the SAST run. Producer: ingestion + // subsystem (area A). Consumer: coding agent (staleness), report. + AdvisorySnapshot *AdvisorySnapshot `json:"anvil/advisorySnapshot,omitempty"` + + // RuntimeTarget is required on the DAST run. Producer: DAST worker. + // Consumer: correlation, reproduction replay. + RuntimeTarget *RuntimeTarget `json:"anvil/runtimeTarget,omitempty"` +} + +// DastCoverage reports what fraction of the discovered attack surface was +// actually probed. `anvil/dastCoverage`. +// +// It carries a NUMERATOR AND A DENOMINATOR AND A PROVENANCE MIX, never a bare +// ratio (research/14 critique m6, carried into +// plan/40-record-and-storage.md's contract table). A bare "62% covered" is +// unfalsifiable; ProbedCount=31 of InventoryUnionCount=50, of which 40 came +// from a runtime spec and 10 from a crawl, is not. +// +// This struct consolidates plan/00-SPINE.md S6's `dast_coverage`, +// `endpoint_coverage` and `inventory_provenance` into one field. Open +// Question 5 in plan/40-record-and-storage.md asks the attack-surface area to +// confirm no information is lost by that consolidation; nothing here forbids +// splitting it later. +type DastCoverage struct { + // ProbedCount is confirmed-probed endpoints. NEVER a request count. + ProbedCount int `json:"probedCount"` + + // InventoryUnionCount is the union of the Tier 0-2 inventory — the + // denominator D.26 is required to use. + InventoryUnionCount int `json:"inventoryUnionCount"` + + // EndpointCoverage is plan/00-SPINE.md S6's `endpoint_coverage`: + // ProbedCount / InventoryUnionCount, in [0,1]. Carried explicitly so a + // consumer never has to guess the denominator, and validated against the + // two counts by ValidateDastCoverage. + EndpointCoverage float64 `json:"endpointCoverage"` + + // ServerLineCoverage is populated on scheduled full scans only, via a + // language coverage agent inside the sandbox. Null (NOT zero) on + // incremental scans — zero would read as "we ran and covered nothing." + ServerLineCoverage *float64 `json:"serverLineCoverage"` + + // InventoryProvenanceMix is plan/00-SPINE.md S6's `inventory_provenance` + // aggregated to the record level: endpoint count per InventoryProvenance + // literal. This is what makes the SAST->DAST handoff auditable. + InventoryProvenanceMix map[InventoryProvenance]int `json:"inventoryProvenanceMix"` + + // ConfirmedCount and CandidateCount split the inventory by whether the + // endpoint was confirmed to exist or only inferred. + ConfirmedCount int `json:"confirmedCount"` + CandidateCount int `json:"candidateCount"` +} + +// AdvisorySnapshot identifies the advisory corpus the SAST half read. +// `anvil/advisorySnapshot`. +type AdvisorySnapshot struct { + FeedIDs []string `json:"feedIds"` + SnapshotDigest string `json:"snapshotDigest"` + ScrapedAt time.Time `json:"scrapedAt"` +} + +// RuntimeTarget records the DAST scope actually used. +// `anvil/runtimeTarget`. +type RuntimeTarget struct { + BaseURL string `json:"baseUrl"` + // AuthProfileRef points at a config file and revision. The record never + // carries credentials. + AuthProfileRef string `json:"authProfileRef"` + Scope []string `json:"scope"` + Excluded []string `json:"excluded"` +} + +// RunAutomationDetails is SARIF §3.17. CorrelationGuid must be identical in +// both runs and equal to AuditProperties.AuditID. +type RunAutomationDetails struct { + ID string `json:"id"` + GUID string `json:"guid"` + CorrelationGUID string `json:"correlationGuid"` + Description *Message `json:"description,omitempty"` +} + +// --------------------------------------------------------------------------- +// Per-finding result +// --------------------------------------------------------------------------- + +// Result is one finding. SARIF-native slots carry everything SARIF can carry; +// Properties carries only what it cannot. +type Result struct { + RuleID string `json:"ruleId"` + RuleIndex *int `json:"ruleIndex,omitempty"` + Kind Kind `json:"kind,omitempty"` + Level Level `json:"level,omitempty"` + Rank *float64 `json:"rank,omitempty"` + GUID string `json:"guid,omitempty"` + + // CorrelationGUID is assigned per CLUSTER, not per finding (SARIF + // §3.27.4): every finding asserted to be the same underlying defect + // shares it. Present only on clustered findings. Producer: the + // correlation engine (R.12). + CorrelationGUID string `json:"correlationGuid,omitempty"` + + Message Message `json:"message"` + + // Locations carries the SAST half's file/line/snippet and the DAST + // half's endpoint. Required for SAST; a pure-DAST result has no file. + Locations []Location `json:"locations,omitempty"` + RelatedLocations []Location `json:"relatedLocations,omitempty"` + CodeFlows []CodeFlow `json:"codeFlows,omitempty"` + + Taxa []ReportingDescriptorReference `json:"taxa,omitempty"` + + // WebRequest and WebResponse are the SARIF-native DAST evidence slots + // (§3.27.14/15). MASKED BY R.8 BEFORE STORAGE — research/18 Risk #10: + // "an 8-hour TTL is not a security control for a token that is still + // valid." + WebRequest *WebRequest `json:"webRequest,omitempty"` + WebResponse *WebResponse `json:"webResponse,omitempty"` + + // PartialFingerprints keys are the Partial* constants above. + PartialFingerprints map[string]string `json:"partialFingerprints,omitempty"` + + // Provenance is SARIF §3.48 regression history. Producer: the store, on + // read-back. Consumer: regression history, report. + Provenance *ResultProvenance `json:"provenance,omitempty"` + + // Fixes is written only after a coding-agent proposal (SARIF §3.27.30). + // plan/00-SPINE.md S7: "Never auto-merge. Propose only." + Fixes []Fix `json:"fixes,omitempty"` + + Properties ResultProperties `json:"properties"` +} + +// ResultProperties is the typed `anvil/*` bag on `result.properties`. +type ResultProperties struct { + // FindingID is the record-local cross-reference used by task cards and + // the DB. Producer: record assembler. + FindingID string `json:"anvil/findingId"` + + // Half names the producing half. Producer: detector. + Half Half `json:"anvil/half"` + + // Confidence is DETECTOR CERTAINTY in [0,1], and is deliberately NOT + // SARIF's `rank`: rank means priority and level means severity, so a + // consumer that reads rank as certainty cannot tell "high severity" from + // "high confidence" (research/18, "What Anvil loses by choosing SARIF"). + // research/18 Risk #8 additionally requires that `rank` on INGESTED + // third-party SARIF be treated as untrusted and re-derived. + Confidence float64 `json:"anvil/confidence"` + + // Verdict is the triage judgment. Producer: detector model / triage + // gate, via B.12's mapping. Consumer: the consumption pipeline, which + // drops VerdictFalsePositive and demotes VerdictInsufficientContext to + // report-only. + Verdict Verdict `json:"anvil/verdict"` + + // RemediableByAgent is plan/00-SPINE.md S6's `remediable_by_agent`, and + // is stored as `finding.remediable_by_agent`. It is false for every host + // finding, always (plan/00-SPINE.md S7 read-only host agent), and the + // store enforces that with a CHECK constraint. Producer: record + // assembler, derived from Detector. Consumer: coding agent. + RemediableByAgent bool `json:"anvil/remediableByAgent"` + + // Reasoning is the detector's explanation. Anvil-generated. + Reasoning string `json:"anvil/reasoning"` + + // Detector identifies the model and prompt that produced the finding. + Detector DetectorRef `json:"anvil/detector"` + + // EvidenceClass drives ranking and the queue re-cut. + EvidenceClass EvidenceClass `json:"anvil/evidenceClass"` + + // Trust classifies every string in this result that originated outside + // Anvil. REQUIRED on every result. See TrustAssertion. + Trust TrustAssertion `json:"anvil/trust"` + + // Advisory is required when an advisory is linked. Producer: ingestion + // subsystem at record-assembly time. Consumer: coding agent (down-weight + // stale or degraded context), report. + Advisory *AdvisoryContext `json:"anvil/advisory,omitempty"` + + // Risk carries research/24's non-negotiable ranking inputs. + // DEVIATION: no row for it exists in plan/40-record-and-storage.md's + // Record Field Contract table; research/24 lists it as non-negotiable + // "because there is no orchestrator to compute them later". See + // CONTRACT.md "Logged deviations", item 1. + Risk *Risk `json:"anvil/risk,omitempty"` + + // PatchContext is what the coding agent needs to write the patch without + // further lookups. + PatchContext *PatchContext `json:"anvil/patchContext,omitempty"` + + // Correlation is present only on clustered findings. Producer: R.12. + Correlation *Correlation `json:"anvil/correlation,omitempty"` + + // Repro is required on any reproducer. Producer: DAST worker or the + // dynamic-analysis harness. Consumer: the verification pipeline — + // plan/00-SPINE.md S7 lets only a DAST reproduction that now fails earn + // "verified fixed", and the sanitizer/ASLR state qualifies that claim. + Repro *Repro `json:"anvil/repro,omitempty"` + + // Locus.ProximityClass drives fix-grouping. + Locus *Locus `json:"anvil/locus,omitempty"` + + // ChunkRef is the Tier-1 task-card pointer. Producer: R.13. + ChunkRef string `json:"anvil/chunkRef,omitempty"` + + // GroupID is RESERVED here and ASSIGNED BY THE CODING-AGENT CONSUMPTION + // PIPELINE, not by this area. Empty on a freshly assembled record. + GroupID string `json:"anvil/groupId,omitempty"` +} + +// TrustAssertion states the trust level of every string in a result that +// originated outside Anvil. `anvil/trust`. +// +// DEVIATION FROM THE PLAN'S CONTRACT TABLE, deliberate: the table types +// `anvil/trust` as a bare enum, but plan/00-SPINE.md S6 requires trust "on +// EVERY string originating outside Anvil" and one result carries several +// strings of different provenance at once — a repo snippet, a +// model-generated explanation, an attacker-controlled response body. A single +// enum per result cannot express that, and the way it fails is to collapse to +// the most permissive value. The literals are unchanged. See CONTRACT.md +// "Logged deviations", item 3. +type TrustAssertion struct { + // Default applies to any string not named in Fields. A result carrying a + // WebResponse must set Default to TrustUntrusted: plan/00-SPINE.md S7 + // names the DAST response body the highest-risk field in the system. + Default Trust `json:"default"` + + // Fields maps an RFC 6901 JSON Pointer, relative to this RESULT object, + // to the trust level of the string at that pointer. Every pointer + // returned by Result.ExternalStringPointers must appear here or be + // covered by an untrusted Default. + Fields map[string]Trust `json:"fields,omitempty"` +} + +// DetectorRef identifies the model and prompt behind a finding. +// `anvil/detector`. +type DetectorRef struct { + // Kind selects the fingerprint tier (R.2) and populates + // `finding.detector`. + Kind DetectorKind `json:"kind"` + + Model string `json:"model"` + Revision string `json:"revision"` + // PromptDigest makes a detector decision replayable without storing the + // prompt. + PromptDigest string `json:"promptDigest,omitempty"` + AdvisoryItemID string `json:"advisoryItemId,omitempty"` +} + +// AdvisoryContext links a finding to advisory data and states how stale that +// data is. `anvil/advisory`. +type AdvisoryContext struct { + IDs []string `json:"ids"` + CveIDs []string `json:"cveIds"` + SourceFeed string `json:"sourceFeed"` + SnapshotDigest string `json:"snapshotDigest"` + // LicenseSpdx is carried per finding because the feed licence attaches + // to the text, not to Anvil (plan/00-SPINE.md S8, plan/80-compliance.md). + LicenseSpdx string `json:"licenseSpdx,omitempty"` + + // AsOf is plan/00-SPINE.md S6's `as_of`: when this advisory data was + // current. + AsOf time.Time `json:"asOf"` + // StalenessSeconds is S6's `staleness_seconds`: record-assembly time + // minus AsOf. Carried explicitly so a consumer never has to know the + // assembly clock. + StalenessSeconds int `json:"stalenessSeconds"` + // ParseDegraded is S6's `parse_degraded`: the feed parsed with loss. + // A consumer must down-weight, not silently trust, degraded context. + ParseDegraded bool `json:"parseDegraded"` + + // Excerpt is <= MaxAdvisoryExcerptTokens, pre-trimmed by ingestion, + // never a whole advisory (research/24). It is EXTERNAL TEXT and + // therefore carries its own trust inline. + Excerpt *TrustedString `json:"excerpt,omitempty"` +} + +// TrustedString is a string that originated outside Anvil, carrying its own +// trust level inline. Used for `anvil/*` extension strings; SARIF-native +// strings cannot change shape, so those are classified by pointer in +// TrustAssertion.Fields instead. +type TrustedString struct { + Text string `json:"text"` + Trust Trust `json:"trust"` +} + +// Risk carries the ranking inputs research/24 names non-negotiable. +// `anvil/risk`. Producer: Lane A ingestion. Consumer: ranking (R.11, R.13). +type Risk struct { + CvssV4Base *float64 `json:"cvssV4Base,omitempty"` + EpssScore *float64 `json:"epssScore,omitempty"` + EpssPercentile *float64 `json:"epssPercentile,omitempty"` + EpssModelDate *time.Time `json:"epssModelDate,omitempty"` + KevMember bool `json:"kevMember"` + KevRansomwareUse bool `json:"kevRansomwareUse"` +} + +// PatchContext is everything the coding agent needs to write the patch with +// no further lookups. `anvil/patchContext`. +type PatchContext struct { + Language string `json:"language"` + LanguageVersion string `json:"languageVersion,omitempty"` + Framework string `json:"framework,omitempty"` + DBDriver string `json:"dbDriver,omitempty"` + Imports []string `json:"imports,omitempty"` + TestCommand string `json:"testCommand,omitempty"` + BuildCommand string `json:"buildCommand,omitempty"` + EditableFiles []string `json:"editableFiles"` +} + +// Correlation is the LINK assertion. `anvil/correlation`. +// +// LINK, NEVER MERGE: both findings always survive independently in the +// record. The SAST finding owns the file and line; the DAST finding owns the +// proof; merging destroys exactly what the other contributes +// (research/18, "Correlation policy — link, never merge"). Merged is +// therefore unconditionally false in every code path. +type Correlation struct { + ClusterID string `json:"clusterId"` + Role Half `json:"role"` + Peers []string `json:"peers"` + Method string `json:"method,omitempty"` + + // Signals must contain at least MinCorrelationSignals independent + // entries, and CorrelationSignalCweMatch alone is never sufficient. + Signals []SignalWeight `json:"signals"` + + Confidence float64 `json:"confidence"` + + // Verified is true only when a signal in Signals satisfies + // SufficientForVerified. Confidence alone never qualifies. + Verified bool `json:"verified"` + VerificationMethod string `json:"verificationMethod,omitempty"` + + // Merged is always false. See the type comment. + Merged bool `json:"merged"` + + // Caveat records why this cluster should or should not be trusted. + Caveat string `json:"caveat,omitempty"` +} + +// MinCorrelationSignals is research/18's ">=2 independent signals before +// emitting a link at all". +const MinCorrelationSignals = 2 + +// SignalWeight is one weighted correlation signal. +type SignalWeight struct { + Name CorrelationSignal `json:"name"` + Weight float64 `json:"weight"` + Detail string `json:"detail,omitempty"` +} + +// Repro is a replayable reproduction. `anvil/repro`. +type Repro struct { + Curl string `json:"curl,omitempty"` + InjectionPoint ReproInjection `json:"injectionPoint"` + Payload string `json:"payload,omitempty"` + PayloadEncoding string `json:"payloadEncoding,omitempty"` + Steps []string `json:"steps,omitempty"` + Baseline *ReproBaseline `json:"baseline,omitempty"` + ObservedSignal ReproSignal `json:"observedSignal"` + ExpectedAfterFix *ReproExpectation `json:"expectedAfterFix,omitempty"` + SideEffects string `json:"sideEffects,omitempty"` + TargetStateRef string `json:"targetStateRef,omitempty"` + + // Env qualifies what "reproduced" means. Required on any reproducer. + Env ReproEnv `json:"env"` +} + +// ReproEnv records the dynamic-analysis environment. +// +// Sanitizers and AslrEnabled are plan/00-SPINE.md S6's required +// "sanitizer + ASLR state on any reproducer", and they are not bookkeeping: +// a crash that reproduces only under ASan is a different claim from one that +// reproduces on a stock build, and a use-after-free that reproduces only with +// ASLR disabled may not be exploitable as shipped. plan/00-SPINE.md S7 lets +// only a reproduction that now FAILS earn "verified fixed" — a verification +// re-run under a different sanitizer or ASLR setting than the original is not +// the same experiment, and without these fields nothing can detect that. +type ReproEnv struct { + // Sanitizers are the sanitizer names active during reproduction, e.g. + // "asan", "ubsan", "msan", "tsan". Empty means a stock build. + Sanitizers []string `json:"sanitizers"` + // AslrEnabled is the ASLR state during reproduction. + AslrEnabled bool `json:"aslrEnabled"` + // Arch and OS pin the rest of the environment. + Arch string `json:"arch,omitempty"` + OS string `json:"os,omitempty"` +} + +// ReproInjection says where the payload went in. +type ReproInjection struct { + Kind InjectionPoint `json:"kind"` + Name string `json:"name"` +} + +// ReproBaseline is the pre-payload observation the reproduction is measured +// against. +type ReproBaseline struct { + StatusCode int `json:"statusCode"` + LatencyMs int `json:"latencyMs,omitempty"` +} + +// ReproSignal says how the defect was observed. +type ReproSignal struct { + Kind EvidenceSignal `json:"kind"` + // Match is a regex-extracted evidence SPAN, never the raw body + // (plan/00-SPINE.md S7: "Hash-and-reference by default; inline only a + // regex-extracted evidence span"). It is attacker-controlled text and + // carries its own trust inline. + Match *TrustedString `json:"match,omitempty"` + // BodySha256 references the full body as a Tier-2 blob. + BodySha256 string `json:"bodySha256,omitempty"` + BodyOffset int `json:"bodyOffset,omitempty"` +} + +// ReproExpectation is the accept oracle for a fix. +type ReproExpectation struct { + StatusCode int `json:"statusCode,omitempty"` + MustNotContain []string `json:"mustNotContain,omitempty"` +} + +// Locus is the fix-grouping input. `anvil/locus`. +// +// Only ProximityClass lives here: path, start line, end line and enclosing +// symbol are SARIF-native (physicalLocation.region and logicalLocations) and +// duplicating them into the property bag would create two sources of truth +// for the same fact. +type Locus struct { + // ProximityClass drives fix-grouping per research/24's Hunk4J citation. + // The vocabulary is owned by the coding-agent consumption area and is + // NOT frozen here; it must be registered in this file before a second + // area consumes it, or it becomes the eleventh defect of the same shape. + ProximityClass string `json:"proximityClass"` +} + +// --------------------------------------------------------------------------- +// SARIF-native supporting types (the subset Anvil produces) +// --------------------------------------------------------------------------- + +// Message is SARIF §3.11. +type Message struct { + Text string `json:"text"` +} + +// Tool is SARIF §3.18. +type Tool struct { + Driver ToolComponent `json:"driver"` +} + +// ToolComponent is SARIF §3.19. Also used for taxonomies (e.g. CWE). +type ToolComponent struct { + Name string `json:"name"` + GUID string `json:"guid,omitempty"` + Version string `json:"version,omitempty"` + Organization string `json:"organization,omitempty"` + InformationURI string `json:"informationUri,omitempty"` + ShortDescription *Message `json:"shortDescription,omitempty"` + IsComprehensive *bool `json:"isComprehensive,omitempty"` + Rules []ReportingDescriptor `json:"rules,omitempty"` + Taxa []ReportingDescriptor `json:"taxa,omitempty"` +} + +// ReportingDescriptor is SARIF §3.49 — a rule or a taxon. +type ReportingDescriptor struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + ShortDescription *Message `json:"shortDescription,omitempty"` + FullDescription *Message `json:"fullDescription,omitempty"` + Help *Message `json:"help,omitempty"` + HelpURI string `json:"helpUri,omitempty"` + DefaultConfiguration *ReportingConfiguration `json:"defaultConfiguration,omitempty"` + Relationships []ReportingDescriptorRelationship `json:"relationships,omitempty"` +} + +// ReportingConfiguration is SARIF §3.50. +type ReportingConfiguration struct { + Level Level `json:"level,omitempty"` +} + +// ReportingDescriptorRelationship is SARIF §3.53 — how a rule relates to a +// taxon. This is the spec-preferred CWE mechanism (§3.8.2), not tags. +type ReportingDescriptorRelationship struct { + Target ReportingDescriptorReference `json:"target"` + Kinds []string `json:"kinds,omitempty"` +} + +// ReportingDescriptorReference is SARIF §3.52. +type ReportingDescriptorReference struct { + ID string `json:"id,omitempty"` + Index *int `json:"index,omitempty"` + GUID string `json:"guid,omitempty"` + ToolComponent *ToolComponentRef `json:"toolComponent,omitempty"` +} + +// ToolComponentRef is SARIF §3.54. +type ToolComponentRef struct { + Name string `json:"name,omitempty"` + Index *int `json:"index,omitempty"` + GUID string `json:"guid,omitempty"` +} + +// Location is SARIF §3.28. +type Location struct { + ID *int `json:"id,omitempty"` + PhysicalLocation *PhysicalLocation `json:"physicalLocation,omitempty"` + LogicalLocations []LogicalLocation `json:"logicalLocations,omitempty"` + Message *Message `json:"message,omitempty"` + Properties map[string]any `json:"properties,omitempty"` +} + +// PhysicalLocation is SARIF §3.29. +type PhysicalLocation struct { + ArtifactLocation ArtifactLocation `json:"artifactLocation"` + // Region is the exact defect (§3.29.4). + Region *Region `json:"region,omitempty"` + // ContextRegion is the surrounding context the agent needs to patch + // (§3.29.5). + ContextRegion *Region `json:"contextRegion,omitempty"` +} + +// ArtifactLocation is SARIF §3.4. +type ArtifactLocation struct { + URI string `json:"uri"` + URIBaseID string `json:"uriBaseId,omitempty"` + Index *int `json:"index,omitempty"` +} + +// Region is SARIF §3.30. +// +// Line and column numbers live HERE and are never hashed into a fingerprint: +// code motion is the documented killer of fingerprint stability +// (research/18 Risk #5). +type Region struct { + StartLine int `json:"startLine,omitempty"` + StartColumn int `json:"startColumn,omitempty"` + EndLine int `json:"endLine,omitempty"` + EndColumn int `json:"endColumn,omitempty"` + Snippet *Snippet `json:"snippet,omitempty"` +} + +// Snippet is SARIF §3.30.13 `region.snippet`. +// +// Its Text is VERBATIM TARGET-REPO SOURCE. It is TrustUntrusted even though +// Anvil is what put it in the struct. See Trust's doc comment. +type Snippet struct { + Text string `json:"text"` +} + +// LogicalLocation is SARIF §3.33. +type LogicalLocation struct { + Name string `json:"name,omitempty"` + FullyQualifiedName string `json:"fullyQualifiedName"` + Kind string `json:"kind,omitempty"` +} + +// CodeFlow is SARIF §3.36 — the taint path from source to sink. +type CodeFlow struct { + Message *Message `json:"message,omitempty"` + ThreadFlows []ThreadFlow `json:"threadFlows"` +} + +// ThreadFlow is SARIF §3.37. +type ThreadFlow struct { + Locations []ThreadFlowLocation `json:"locations"` +} + +// ThreadFlowLocation is SARIF §3.38. +type ThreadFlowLocation struct { + Importance string `json:"importance,omitempty"` + Location Location `json:"location"` +} + +// WebRequest is SARIF §3.46. +type WebRequest struct { + Index *int `json:"index,omitempty"` + Protocol string `json:"protocol,omitempty"` + Version string `json:"version,omitempty"` + Target string `json:"target,omitempty"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Parameters map[string]string `json:"parameters,omitempty"` + Body *ArtifactContent `json:"body,omitempty"` +} + +// WebResponse is SARIF §3.47. +// +// Its Body is the highest-risk field in the entire record: up to 32 KB of +// attacker-controlled bytes headed for a repo-credentialed agent +// (plan/00-SPINE.md S7). It is masked by R.8 before storage, capped at +// MaxInlineResponseBodyBytes, and always TrustUntrusted. +type WebResponse struct { + Index *int `json:"index,omitempty"` + Protocol string `json:"protocol,omitempty"` + Version string `json:"version,omitempty"` + StatusCode int `json:"statusCode,omitempty"` + ReasonPhrase string `json:"reasonPhrase,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body *ArtifactContent `json:"body,omitempty"` + NoResponseReceived bool `json:"noResponseReceived,omitempty"` +} + +// ArtifactContent is SARIF §3.3. +type ArtifactContent struct { + Text string `json:"text,omitempty"` +} + +// ResultProvenance is SARIF §3.48 — regression history, written by the store +// on read-back. +type ResultProvenance struct { + FirstDetectionTimeUtc *time.Time `json:"firstDetectionTimeUtc,omitempty"` + LastDetectionTimeUtc *time.Time `json:"lastDetectionTimeUtc,omitempty"` + FirstDetectionRunGUID string `json:"firstDetectionRunGuid,omitempty"` + LastDetectionRunGUID string `json:"lastDetectionRunGuid,omitempty"` + InvocationIndex *int `json:"invocationIndex,omitempty"` +} + +// Fix is SARIF §3.27.30 — a PROPOSED patch. Never applied automatically. +type Fix struct { + Description *Message `json:"description,omitempty"` + ArtifactChanges []ArtifactChange `json:"artifactChanges"` +} + +// ArtifactChange is SARIF §3.56. +type ArtifactChange struct { + ArtifactLocation ArtifactLocation `json:"artifactLocation"` + Replacements []Replacement `json:"replacements"` +} + +// Replacement is SARIF §3.57. +type Replacement struct { + DeletedRegion Region `json:"deletedRegion"` + InsertedContent *ArtifactContent `json:"insertedContent,omitempty"` +} + +// ExternalPropertyFileReferences is SARIF §3.15 — the native mechanism for +// externalising the arrays that blow up the Tier-0 budget. +type ExternalPropertyFileReferences struct { + Results []ExternalPropertyFileReference `json:"results,omitempty"` + Artifacts []ExternalPropertyFileReference `json:"artifacts,omitempty"` + WebRequests []ExternalPropertyFileReference `json:"webRequests,omitempty"` + WebResponses []ExternalPropertyFileReference `json:"webResponses,omitempty"` +} + +// ExternalPropertyFileReference is SARIF §3.16. +type ExternalPropertyFileReference struct { + Location *ArtifactLocation `json:"location,omitempty"` + GUID string `json:"guid,omitempty"` + ItemCount *int `json:"itemCount,omitempty"` +} + +// --------------------------------------------------------------------------- +// Trust classification helpers +// --------------------------------------------------------------------------- + +// ExternalStringPointers returns the RFC 6901 JSON Pointers, relative to this +// result object, of every string it carries that originated OUTSIDE Anvil. +// +// This is the list plan/00-SPINE.md S6's "on every string originating outside +// Anvil" resolves to in practice. It deliberately includes region snippets: +// a repo source snippet is external even though Anvil assembled the struct, +// and treating it as Anvil-generated is what disables the containment check +// on the string most likely to carry an injected instruction. +func (r *Result) ExternalStringPointers() []string { + var ptrs []string + add := func(p string) { ptrs = append(ptrs, p) } + + locPtrs := func(prefix string, locs []Location) { + for i, loc := range locs { + if loc.PhysicalLocation == nil { + continue + } + base := prefix + "/" + strconv.Itoa(i) + "/physicalLocation" + if loc.PhysicalLocation.Region != nil && loc.PhysicalLocation.Region.Snippet != nil { + add(base + "/region/snippet/text") + } + if loc.PhysicalLocation.ContextRegion != nil && loc.PhysicalLocation.ContextRegion.Snippet != nil { + add(base + "/contextRegion/snippet/text") + } + } + } + locPtrs("/locations", r.Locations) + locPtrs("/relatedLocations", r.RelatedLocations) + + for i, cf := range r.CodeFlows { + for j, tf := range cf.ThreadFlows { + for k, tfl := range tf.Locations { + pl := tfl.Location.PhysicalLocation + if pl == nil || pl.Region == nil || pl.Region.Snippet == nil { + continue + } + add(fmt.Sprintf("/codeFlows/%d/threadFlows/%d/locations/%d/location/physicalLocation/region/snippet/text", i, j, k)) + } + } + } + + if r.WebResponse != nil { + if r.WebResponse.Body != nil { + add("/webResponse/body/text") + } + if len(r.WebResponse.Headers) > 0 { + add("/webResponse/headers") + } + } + return ptrs +} + +// ValidateResultTrust reports whether r's TrustAssertion covers every string +// that originated outside Anvil, and whether each such string is classified +// legally. +// +// A pointer may be classified TrustUntrusted or TrustVerified. It may NOT be +// classified TrustAnvilGenerated, and if it is not named in Fields then +// Default must itself be legal for external strings. This is the check that +// would have caught area B stamping anvil_generated on a verbatim +// target-repo snippet. +func ValidateResultTrust(r *Result) error { + if err := ValidateTrust(string(r.Properties.Trust.Default)); err != nil { + return err + } + for ptr, t := range r.Properties.Trust.Fields { + if err := ValidateTrust(string(t)); err != nil { + return fmt.Errorf("anvil/trust.fields[%q]: %w", ptr, err) + } + } + for _, ptr := range r.ExternalStringPointers() { + t, named := r.Properties.Trust.Fields[ptr] + if !named { + t = r.Properties.Trust.Default + } + if !t.LegalForExternalString() { + return fmt.Errorf( + "record: %s is a string originating outside Anvil but is classified %q; "+ + "external strings must be %q or %q (a repo snippet is untrusted even though Anvil assembled it)", + ptr, t, TrustUntrusted, TrustVerified) + } + } + if r.WebResponse != nil && r.Properties.Trust.Default != TrustUntrusted { + return fmt.Errorf( + "record: a result carrying a webResponse must set anvil/trust.default to %q, got %q "+ + "(00-SPINE.md S7: the DAST response body is the highest-risk field)", + TrustUntrusted, r.Properties.Trust.Default) + } + return nil +} + +// --------------------------------------------------------------------------- +// Whole-record validation +// --------------------------------------------------------------------------- + +// Validate checks a SARIFLog against the parts of this contract that are +// checkable in Go: the pinned SARIF version, every frozen enum, the required +// envelope fields, the per-half seal invariants, and the trust classification +// of every result. +// +// It is deliberately NOT a JSON Schema replacement — schemas/ +// anvil-record-v1.schema.json is the wire gate. This is the in-process gate, +// so a producer fails at assembly time rather than at the store boundary. +func (l *SARIFLog) Validate() error { + if l.Version != SARIFVersion { + return fmt.Errorf("record: version must be %q exactly, got %q "+ + "(do not track the unratified SARIF 2.2 draft)", SARIFVersion, l.Version) + } + if l.Schema != SARIFSchemaURI { + return fmt.Errorf("record: $schema must be %q, got %q", SARIFSchemaURI, l.Schema) + } + + p := &l.Properties + if p.SchemaVersion == "" { + return fmt.Errorf("record: %s is required", PropAuditSchemaVersion) + } + if p.AuditID == "" { + return fmt.Errorf("record: %s is required", PropAuditID) + } + if err := ValidateState(string(p.State)); err != nil { + return err + } + if p.Version < 1 { + return fmt.Errorf("record: %s must be a positive monotonic integer, got %d", + PropAuditVersion, p.Version) + } + if p.CreatedAt.IsZero() { + return fmt.Errorf("record: %s is required", PropAuditCreatedAt) + } + if err := ValidateTargetProvenance(string(p.Target.Provenance)); err != nil { + return err + } + if err := ValidateTargetProvisioning(string(p.Target.Provisioning)); err != nil { + return err + } + if err := ValidateDastStatus(string(p.DastStatus)); err != nil { + return err + } + if p.Deadline.DeadlineAt.IsZero() { + return fmt.Errorf("record: %s.deadlineAt is required and is computed once at scan START", PropAuditDeadline) + } + if p.Deadline.ClaimTimeoutSeconds <= 0 { + return fmt.Errorf("record: %s.claimTimeoutSeconds must be positive, got %d", + PropAuditDeadline, p.Deadline.ClaimTimeoutSeconds) + } + // The deadline is anchored to scan start, never to the last write. + want := p.CreatedAt.Add(time.Duration(p.Deadline.ClaimTimeoutSeconds) * time.Second) + if !p.Deadline.DeadlineAt.Equal(want) { + return fmt.Errorf("record: %s.deadlineAt is %s but createdAt+claimTimeoutSeconds is %s; "+ + "the deadline is anchored to scan START and never recomputed", + PropAuditDeadline, p.Deadline.DeadlineAt.UTC().Format(time.RFC3339), want.UTC().Format(time.RFC3339)) + } + if p.Deadline.DastDeadlineSeconds != nil && *p.Deadline.DastDeadlineSeconds <= 0 { + return fmt.Errorf("record: %s.dastDeadlineSeconds must be null or positive", PropAuditDeadline) + } + + seenHalf := map[Half]bool{} + for i := range l.Runs { + run := &l.Runs[i] + if err := run.validate(p.AuditID); err != nil { + return fmt.Errorf("runs[%d]: %w", i, err) + } + if seenHalf[run.Properties.Half] { + return fmt.Errorf("runs[%d]: duplicate half %q; an audit has at most one run per half", + i, run.Properties.Half) + } + seenHalf[run.Properties.Half] = true + } + return l.validateStateAgainstHalves(seenHalf) +} + +// validateStateAgainstHalves enforces that anvil/state agrees with the two +// halves' seal status. This is where a DAST-first seal has to be expressible: +// if it were not, the only reachable states would be collecting and +// sast_sealed, and plan/00-SPINE.md S1's "two INDEPENDENTLY-sealed halves" +// would be false in the implementation while true in the document. +func (l *SARIFLog) validateStateAgainstHalves(seen map[Half]bool) error { + sastSealed, dastSealed := false, false + for i := range l.Runs { + rp := &l.Runs[i].Properties + if rp.Status != HalfStatusSealed { + continue + } + switch rp.Half { + case HalfSast: + sastSealed = true + case HalfDast: + dastSealed = true + } + } + // A DAST-disabled audit reaches both_sealed with DastStatusNotRun (R.6), + // so an absent DAST run counts as sealed for state purposes only when + // the audit-level DastStatus says the half was never going to run. + if !seen[HalfDast] { + switch l.Properties.DastStatus { + case DastStatusNotRun, DastStatusSkippedNoManifest: + dastSealed = true + } + } + + var want State + switch { + case sastSealed && dastSealed: + want = StateBothSealed + case sastSealed: + want = StateSastSealed + case dastSealed: + want = StateDastSealed + default: + want = StateCollecting + } + + got := l.Properties.State + // consumed and expired are terminal states reached after both_sealed or + // after the claim clock ran out; they are not derivable from the halves. + if got == StateConsumed || got == StateExpired { + return nil + } + if got != want { + return fmt.Errorf("record: %s is %q but the halves imply %q "+ + "(sastSealed=%t dastSealed=%t)", PropAuditState, got, want, sastSealed, dastSealed) + } + return nil +} + +func (r *Run) validate(auditID string) error { + if err := ValidateHalf(string(r.Properties.Half)); err != nil { + return err + } + if err := ValidateHalfStatus(string(r.Properties.Status)); err != nil { + return err + } + if r.Properties.Status == HalfStatusSealed && r.Properties.SealedAt == nil { + return fmt.Errorf("%s is %q so %s is required", PropRunStatus, HalfStatusSealed, PropRunSealedAt) + } + if r.Properties.Status != HalfStatusSealed && r.Properties.SealedAt != nil { + return fmt.Errorf("%s is %q so %s must be null", PropRunStatus, r.Properties.Status, PropRunSealedAt) + } + if r.AutomationDetails.CorrelationGUID != auditID { + return fmt.Errorf("automationDetails.correlationGuid is %q but the audit id is %q; "+ + "both runs must carry the audit id, which is the SARIF-native statement that they are one audit", + r.AutomationDetails.CorrelationGUID, auditID) + } + if r.Properties.Half == HalfDast { + if r.Properties.DastCoverage == nil { + return fmt.Errorf("%s is required on the DAST run", PropRunDastCoverage) + } + if err := ValidateDastCoverage(r.Properties.DastCoverage); err != nil { + return err + } + } + for i := range r.Results { + if err := r.Results[i].validate(r.Properties.Half); err != nil { + return fmt.Errorf("results[%d]: %w", i, err) + } + } + return nil +} + +// ValidateDastCoverage checks that coverage is reported as a numerator and a +// denominator whose ratio matches the stated EndpointCoverage — never as a +// bare, unfalsifiable percentage — and that ServerLineCoverage is null rather +// than zero when it was not measured. +func ValidateDastCoverage(c *DastCoverage) error { + if c.ProbedCount < 0 || c.InventoryUnionCount < 0 { + return fmt.Errorf("%s: counts must be non-negative", PropRunDastCoverage) + } + if c.ProbedCount > c.InventoryUnionCount { + return fmt.Errorf("%s: probedCount %d exceeds inventoryUnionCount %d", + PropRunDastCoverage, c.ProbedCount, c.InventoryUnionCount) + } + var want float64 + if c.InventoryUnionCount > 0 { + want = float64(c.ProbedCount) / float64(c.InventoryUnionCount) + } + if diff := c.EndpointCoverage - want; diff > 1e-9 || diff < -1e-9 { + return fmt.Errorf("%s: endpointCoverage %v does not equal probedCount/inventoryUnionCount %v "+ + "(endpoint_coverage is confirmed-probed endpoints over the Tier 0-2 inventory union, never a request count)", + PropRunDastCoverage, c.EndpointCoverage, want) + } + if c.ServerLineCoverage != nil && (*c.ServerLineCoverage < 0 || *c.ServerLineCoverage > 1) { + return fmt.Errorf("%s: serverLineCoverage must be null or within [0,1]", PropRunDastCoverage) + } + for prov := range c.InventoryProvenanceMix { + if err := ValidateInventoryProvenance(string(prov)); err != nil { + return err + } + } + return nil +} + +func (r *Result) validate(runHalf Half) error { + p := &r.Properties + if p.FindingID == "" { + return fmt.Errorf("%s is required", PropResultFindingID) + } + if err := ValidateHalf(string(p.Half)); err != nil { + return err + } + if p.Half != runHalf { + return fmt.Errorf("%s is %q inside the %q run", PropResultHalf, p.Half, runHalf) + } + if p.Confidence < 0 || p.Confidence > 1 { + return fmt.Errorf("%s must be within [0,1], got %v (it is detector certainty, not SARIF rank)", + PropResultConfidence, p.Confidence) + } + if err := ValidateVerdict(string(p.Verdict)); err != nil { + return err + } + if err := ValidateEvidenceClass(string(p.EvidenceClass)); err != nil { + return err + } + if err := ValidateDetectorKind(string(p.Detector.Kind)); err != nil { + return err + } + // plan/00-SPINE.md S7: the host agent is read-only, "not behind a flag." + // The store enforces the same rule with a CHECK constraint; enforcing it + // here too means a producer fails before it reaches the store. + if p.Detector.Kind == DetectorKindHost && p.RemediableByAgent { + return fmt.Errorf("%s must be false for a host finding (00-SPINE.md S7: the host agent is read-only)", + PropResultRemediableByAgent) + } + if p.EvidenceClass == EvidenceClassHost && p.RemediableByAgent { + return fmt.Errorf("%s must be false when %s is %q", + PropResultRemediableByAgent, PropResultEvidenceClass, EvidenceClassHost) + } + if r.PartialFingerprints[PartialFingerprintAnvilFindingID] == "" { + return fmt.Errorf("partialFingerprints[%q] is required", PartialFingerprintAnvilFindingID) + } + if fp := r.PartialFingerprints[PartialFingerprintAnvilFindingID]; len(fp) != FingerprintDigestHexLen { + return fmt.Errorf("partialFingerprints[%q] must be %d lowercase hex characters (never truncated), got %d", + PartialFingerprintAnvilFindingID, FingerprintDigestHexLen, len(fp)) + } + if r.hasPhysicalCodeLocation() && r.PartialFingerprints[PartialFingerprintPrimaryLocationLineHash] == "" { + return fmt.Errorf("partialFingerprints[%q] is required when a physical code location exists (the GitHub projection reads only this key)", + PartialFingerprintPrimaryLocationLineHash) + } + if p.Advisory != nil { + if p.Advisory.AsOf.IsZero() { + return fmt.Errorf("%s.asOf is required when an advisory is linked", PropResultAdvisory) + } + if p.Advisory.StalenessSeconds < 0 { + return fmt.Errorf("%s.stalenessSeconds must be non-negative", PropResultAdvisory) + } + if p.Advisory.Excerpt != nil && !p.Advisory.Excerpt.Trust.LegalForExternalString() { + return fmt.Errorf("%s.excerpt is external text and cannot be %q", + PropResultAdvisory, TrustAnvilGenerated) + } + } + if p.Repro != nil { + if err := ValidateInjectionPoint(string(p.Repro.InjectionPoint.Kind)); err != nil { + return err + } + if err := ValidateEvidenceSignal(string(p.Repro.ObservedSignal.Kind)); err != nil { + return err + } + if p.Repro.Env.Sanitizers == nil { + return fmt.Errorf("%s.env.sanitizers is required on any reproducer (use an empty array for a stock build, not null)", + PropResultRepro) + } + } + if p.Correlation != nil { + if err := validateCorrelation(p.Correlation); err != nil { + return err + } + if r.CorrelationGUID == "" { + return fmt.Errorf("a clustered result must carry the SARIF-native result.correlationGuid") + } + } + return ValidateResultTrust(r) +} + +func (r *Result) hasPhysicalCodeLocation() bool { + for _, loc := range r.Locations { + if loc.PhysicalLocation != nil && loc.PhysicalLocation.Region != nil && + loc.PhysicalLocation.Region.StartLine > 0 { + return true + } + } + return false +} + +func validateCorrelation(c *Correlation) error { + if c.Merged { + return fmt.Errorf("%s.merged must be false: link, never merge — the SAST finding owns the file and line, the DAST finding owns the proof, and merging destroys what the other contributes", + PropResultCorrelation) + } + if len(c.Signals) < MinCorrelationSignals { + return fmt.Errorf("%s.signals needs at least %d independent signals, got %d", + PropResultCorrelation, MinCorrelationSignals, len(c.Signals)) + } + onlyCwe := true + verifiable := false + for _, s := range c.Signals { + if err := ValidateCorrelationSignal(string(s.Name)); err != nil { + return err + } + if s.Name != CorrelationSignalCweMatch { + onlyCwe = false + } + if s.Name.SufficientForVerified() { + verifiable = true + } + } + if onlyCwe { + return fmt.Errorf("%s: a CWE-only match is banned as a sole signal", PropResultCorrelation) + } + if c.Verified && !verifiable { + return fmt.Errorf("%s.verified is true but no %q or %q signal is present; confidence alone never qualifies (00-SPINE.md S7)", + PropResultCorrelation, CorrelationSignalResponseStackTrace, CorrelationSignalRerunFlip) + } + if c.Confidence < 0 || c.Confidence > 1 { + return fmt.Errorf("%s.confidence must be within [0,1]", PropResultCorrelation) + } + return nil +} + +// --------------------------------------------------------------------------- +// Cross-area ownership, recorded in code so it survives the plan documents +// --------------------------------------------------------------------------- + +// AreaMappingOwners names the boundary steps that are permitted to translate +// a foreign vocabulary onto this file's literals, per +// plan/IMPLEMENTATION-PLAN.md §6. Anything not listed here must emit these +// literals directly. "A mapping with an owner and a test is not the same +// thing as two vocabularies drifting." +var AreaMappingOwners = map[string]string{ + "anvil/verdict": "B.12 — maps Lane B's in-process Verdict.Result (EXHIBITS|...) onto Verdict, " + + "including case normalisation, at the point it places findings on the record (ruling G8).", + "anvil/state": "O.2 — emits State directly; its former open|sast_sealed|sealed|expired machine is struck (ruling G2).", + "anvil/status": "O.2 — keys per-half transitions on HalfStatusSealed, not on a `complete` token (ruling G5).", + "anvil/dastStatus": "D.26 — emits DastStatus directly; its former five-value set shared zero literals " + + "with the record's column and could not be stored (rulings G3+G6).", + "anvil/target.provisioning": "D.26 — writes the provisioning path here, NOT into target.provenance (rulings G4+G7).", + "handoff.state": "R.4 owns the DDL; X.8/X.9 read and write it. Area X's anvil_ledger is deleted (rulings G9+G10).", +} diff --git a/internal/record/contract_test.go b/internal/record/contract_test.go new file mode 100644 index 0000000..ef40591 --- /dev/null +++ b/internal/record/contract_test.go @@ -0,0 +1,221 @@ +package record + +import ( + "testing" +) + +// The six enums IMPLEMENTATION-PLAN.md section 6 froze, pinned here as literal +// strings. +// +// These are not a restatement of the code for its own sake. Ten confirmed +// cross-area defects came from four areas each declaring their own version of +// this vocabulary, and every one was a produce/consume break: one area wrote +// literals another area's NOT NULL column could not accept. `dast_status` had +// *zero* values in common between two areas that both claimed authority over it. +// +// Comparing these constants against the enum accessors is therefore not circular +// -- the accessors are what other packages consume, and this table is what the +// ruling says they must be. A future edit that "tidies" a literal has to change +// it in two places, and the second one is a wall of comments explaining why not. +var frozenEnums = map[string][]string{ + "anvil/state": { + "collecting", "sast_sealed", "dast_sealed", "both_sealed", "consumed", "expired", + }, + "anvil/status": { + "running", "sealed", "failed", "timed_out", "skipped", + }, + "anvil/dastStatus": { + "not_run", "skipped_no_manifest", "running", "completed_clean", "completed_findings", + "completed_partial", "target_boot_failed", "target_unreachable", "timed_out", + }, + "anvil/target.provenance": { + "booted_clean", "boot_failed", "build_failed", "no_target_declared", + "unreachable_at_scan_time", + }, + "anvil/target.provisioning": { + "ephemeral_manifest", "live_url_authorized", + }, + "anvil/verdict": { + "true_positive", "false_positive", "insufficient_context", + }, +} + +func actualEnums() map[string][]string { + out := map[string][]string{} + for _, v := range StateValues() { + out["anvil/state"] = append(out["anvil/state"], string(v)) + } + for _, v := range HalfStatusValues() { + out["anvil/status"] = append(out["anvil/status"], string(v)) + } + for _, v := range DastStatusValues() { + out["anvil/dastStatus"] = append(out["anvil/dastStatus"], string(v)) + } + for _, v := range TargetProvenanceValues() { + out["anvil/target.provenance"] = append(out["anvil/target.provenance"], string(v)) + } + for _, v := range TargetProvisioningValues() { + out["anvil/target.provisioning"] = append(out["anvil/target.provisioning"], string(v)) + } + for _, v := range VerdictValues() { + out["anvil/verdict"] = append(out["anvil/verdict"], string(v)) + } + return out +} + +func TestFrozenEnumsMatchTheRuling(t *testing.T) { + got := actualEnums() + for name, want := range frozenEnums { + have, ok := got[name] + if !ok { + t.Errorf("%s: no accessor found", name) + continue + } + if len(have) != len(want) { + t.Errorf("%s: has %d values, ruling froze %d\n got: %q\n want: %q", + name, len(have), len(want), have, want) + continue + } + for i := range want { + if have[i] != want[i] { + t.Errorf("%s[%d] = %q, ruling froze %q (full: got %q want %q)", + name, i, have[i], want[i], have, want) + } + } + } +} + +// The literals four other areas were using before the ruling. Each one is a +// value some area actually wrote, and every one must now be rejected. If any of +// these starts validating, the ruling has been quietly undone. +func TestPreRulingLiteralsAreRejected(t *testing.T) { + cases := []struct { + field string + literal string + validate func(string) error + wasUsedBy string + }{ + {"anvil/state", "open", ValidateState, "area O's old 4-state machine"}, + {"anvil/state", "sealed", ValidateState, "area O -- collides with the per-half token"}, + {"anvil/status", "complete", ValidateHalfStatus, "area O keyed its transitions on this"}, + {"anvil/dastStatus", "clean", ValidateDastStatus, "area D"}, + {"anvil/dastStatus", "findings", ValidateDastStatus, "area D"}, + {"anvil/dastStatus", "failed_to_boot", ValidateDastStatus, "area D"}, + {"anvil/dastStatus", "partial", ValidateDastStatus, "area D -- now completed_partial"}, + {"anvil/target.provenance", "ephemeral_manifest", ValidateTargetProvenance, + "area D wrote the provisioning path into the provenance field"}, + {"anvil/target.provenance", "live_url_authorized", ValidateTargetProvenance, "area D"}, + {"anvil/verdict", "EXHIBITS", ValidateVerdict, "area B -- B.12 must map, not pass through"}, + {"anvil/verdict", "DOES_NOT_EXHIBIT", ValidateVerdict, "area B"}, + {"anvil/verdict", "INSUFFICIENT_CONTEXT", ValidateVerdict, + "area B -- the record uses lowercase; case normalisation is B.12's job"}, + } + for _, c := range cases { + t.Run(c.field+"/"+c.literal, func(t *testing.T) { + if err := c.validate(c.literal); err == nil { + t.Errorf("%q accepted as a legal %s. It was used by %s and the ruling "+ + "in IMPLEMENTATION-PLAN.md section 6 replaced it; accepting it "+ + "re-opens a produce/consume break.", c.literal, c.field, c.wasUsedBy) + } + }) + } +} + +func TestEveryFrozenValueValidates(t *testing.T) { + validators := map[string]func(string) error{ + "anvil/state": ValidateState, + "anvil/status": ValidateHalfStatus, + "anvil/dastStatus": ValidateDastStatus, + "anvil/target.provenance": ValidateTargetProvenance, + "anvil/target.provisioning": ValidateTargetProvisioning, + "anvil/verdict": ValidateVerdict, + } + for field, values := range frozenEnums { + for _, v := range values { + if err := validators[field](v); err != nil { + t.Errorf("%s: frozen value %q rejected: %v", field, v, err) + } + } + } +} + +// The thirteen handoff dispositions, which are the union of area 40's original +// set and the four that existed only in area 60's rival `anvil_ledger` table. +// That table is deleted by ruling G10; if these four are missing, area X's exit +// criterion 14 ("every disposition has a reachable code path and a test") +// becomes unsatisfiable. +func TestHandoffStateCoversTheDeletedLedgerDispositions(t *testing.T) { + fromLedgerOnly := []string{ + "fixed_incidentally", "split_required", "withdrawn", "superseded", + } + for _, v := range fromLedgerOnly { + if err := ValidateHandoffState(v); err != nil { + t.Errorf("handoff.state rejects %q, which area 60's anvil_ledger carried. "+ + "Ruling G10 collapsed that table into handoff; dropping the value "+ + "loses the disposition entirely: %v", v, err) + } + } + if len(HandoffStateValues()) != 13 { + t.Errorf("handoff.state has %d values, ruling G10 specifies 13", + len(HandoffStateValues())) + } +} + +// S6: anvil/trust is required on every string originating outside Anvil, and a +// repo source snippet is `untrusted` even though Anvil assembled the struct +// holding it. Area B was found stamping `anvil_generated` on exactly that, +// which would disable area X's containment check on the string that most needs +// it -- attacker-influenced source text heading for a repo-credentialed agent. +func TestTrustLegalityForExternalStrings(t *testing.T) { + cases := []struct { + trust Trust + legal bool + why string + }{ + {TrustUntrusted, true, "the default for anything Anvil did not author"}, + {TrustVerified, true, "explicitly promoted after checking"}, + {TrustAnvilGenerated, false, + "Anvil assembling a struct around external bytes does not make the bytes Anvil's"}, + } + for _, c := range cases { + t.Run(string(c.trust), func(t *testing.T) { + if got := c.trust.LegalForExternalString(); got != c.legal { + t.Errorf("Trust(%q).LegalForExternalString() = %v, want %v -- %s", + c.trust, got, c.legal, c.why) + } + }) + } +} + +// S7: correlation links, never merges, and requires >=2 independent signals. A +// CWE match alone is explicitly banned as a sole signal -- it is the cheapest +// and least specific thing two findings can share. +func TestCweMatchAloneNeverQualifiesAsVerified(t *testing.T) { + for _, s := range CorrelationSignalValues() { + sufficient := s.SufficientForVerified() + if string(s) == "cweMatch" && sufficient { + t.Error("a CWE match alone qualifies as verified; S7 bans it as a sole signal") + } + } +} + +func TestUnknownValuesAreRejectedNotIgnored(t *testing.T) { + validators := map[string]func(string) error{ + "anvil/state": ValidateState, + "anvil/status": ValidateHalfStatus, + "anvil/dastStatus": ValidateDastStatus, + "anvil/target.provenance": ValidateTargetProvenance, + "anvil/target.provisioning": ValidateTargetProvisioning, + "anvil/verdict": ValidateVerdict, + "handoff.state": ValidateHandoffState, + } + // The empty string is the important one: a zero-valued Go string must not + // silently pass as "unset but fine". + for _, bogus := range []string{"", "unknown", "PASS", "Sealed", "sealed "} { + for field, validate := range validators { + if err := validate(bogus); err == nil { + t.Errorf("%s accepted %q", field, bogus) + } + } + } +} diff --git a/schemas/anvil-record-v1.schema.json b/schemas/anvil-record-v1.schema.json new file mode 100644 index 0000000..b2d2311 --- /dev/null +++ b/schemas/anvil-record-v1.schema.json @@ -0,0 +1,682 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://anvil.invalid/schemas/anvil-record-v1.schema.json", + "title": "Anvil unified audit record v1 (SARIF 2.1.0 + anvil/* extension)", + "description": "Constrains the anvil/* property-bag extension that Anvil layers on top of stock SARIF 2.1.0, and pins the SARIF-native slots Anvil depends on. Ground truth: plan/40-record-and-storage.md 'Record Field Contract'; enum literals frozen by plan/IMPLEMENTATION-PLAN.md section 6; required fields by plan/00-SPINE.md S6. Producer/consumer for every field: internal/record/CONTRACT.md. Go source of truth: internal/record/contract.go.", + + "x-anvil-baseSchema": "https://json.schemastore.org/sarif-2.1.0.json", + "x-anvil-validationProcedure": "TWO GATES, both required. Gate 1: validate the record against https://json.schemastore.org/sarif-2.1.0.json (stock SARIF conformance; needed for GitHub and DefectDojo to accept it). Gate 2: validate against THIS schema (the anvil/* extension and the S6-required fields). This schema does not $ref the SARIF base schema because that reference is not resolvable in Anvil's offline CI; keeping the two gates separate makes the offline gate work and makes a SARIF-conformance failure distinguishable from an anvil/* failure.", + "x-anvil-contractVersion": "1.0.0", + + "type": "object", + "required": ["$schema", "version", "runs", "properties"], + "properties": { + "$schema": { + "description": "Pinned to SARIF 2.1.0. Do not track the unratified 2.2 draft (research/18 Risk #9).", + "const": "https://json.schemastore.org/sarif-2.1.0.json" + }, + "version": { "const": "2.1.0" }, + "properties": { "$ref": "#/$defs/auditProperties" }, + "runs": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "description": "One audit identity, two independently-sealed halves (00-SPINE.md S1). At most one run per half.", + "items": { "$ref": "#/$defs/run" } + } + }, + + "$defs": { + "timestamp": { "type": "string", "format": "date-time" }, + "nullableTimestamp": { "type": ["string", "null"], "format": "date-time" }, + "sha256Hex": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "unitInterval": { "type": "number", "minimum": 0, "maximum": 1 }, + + "state": { + "description": "FROZEN ENUM 1/6 (anvil/state). dast_sealed and both_sealed exist because 00-SPINE.md S1 requires two INDEPENDENTLY-sealed halves: a machine that can only seal SAST-then-DAST cannot express a DAST-first seal, and collapsing them makes `sealed` terminal, which makes `consumed` unreachable.", + "type": "string", + "enum": ["collecting", "sast_sealed", "dast_sealed", "both_sealed", "consumed", "expired"] + }, + "halfStatus": { + "description": "FROZEN ENUM 2/6 (per-half anvil/status). `sealed` is the hard consumer read gate (R.6); an area keying on any other token means the gate never opens.", + "type": "string", + "enum": ["running", "sealed", "failed", "timed_out", "skipped"] + }, + "dastStatus": { + "description": "FROZEN ENUM 3/6 (anvil/dastStatus). Never null. `skipped_no_manifest` is deliberately distinct from `not_run`: no target manifest was declared, versus the DAST tier not being installed at all. S6 requires a target that failed to be distinguishable from one scanned clean; only `completed_clean` may be read as 'dynamically scanned, nothing found'.", + "type": "string", + "enum": [ + "not_run", + "skipped_no_manifest", + "running", + "completed_clean", + "completed_findings", + "completed_partial", + "target_boot_failed", + "target_unreachable", + "timed_out" + ] + }, + "targetProvenance": { + "description": "FROZEN ENUM 4/6 (anvil/target.provenance). The target's BOOT/REACHABILITY OUTCOME. dastStatus is derived from this field, not from target.provisioning.", + "type": "string", + "enum": ["booted_clean", "boot_failed", "build_failed", "no_target_declared", "unreachable_at_scan_time"] + }, + "targetProvisioning": { + "description": "FROZEN ENUM 5/6 (anvil/target.provisioning). NEW required field. WHICH PROVISIONING PATH produced the target. A different measurement from target.provenance; merging them loses information S6 requires, and the two carry different authorization consequences (S7).", + "type": "string", + "enum": ["ephemeral_manifest", "live_url_authorized"] + }, + "verdict": { + "description": "FROZEN ENUM 6/6 (anvil/verdict). `insufficient_context` is a real verdict, not a low confidence score (00-SPINE.md S6 is explicit). A low score means 'probably not real'; insufficient_context means 'may well be real and the detector could not see enough to tell'. Those demand opposite handling and a float cannot express the difference.", + "type": "string", + "enum": ["true_positive", "false_positive", "insufficient_context"] + }, + "trust": { + "description": "anvil/trust (00-SPINE.md S6, required on EVERY string originating outside Anvil). A repo source snippet is `untrusted` even though Anvil put it in the struct: the question is who wrote the bytes, never who assigned the field.", + "type": "string", + "enum": ["untrusted", "anvil_generated", "verified"] + }, + "externalTrust": { + "description": "The subset of anvil/trust legal for a string that originated outside Anvil. `anvil_generated` is excluded by construction.", + "type": "string", + "enum": ["untrusted", "verified"] + }, + "half": { "type": "string", "enum": ["sast", "dast"] }, + "evidenceClass": { + "type": "string", + "enum": ["dast_confirmed", "sast_reachable", "sast_static_only", "sca", "host"] + }, + "detectorKind": { "type": "string", "enum": ["sast", "dast", "sca", "host"] }, + "injectionPoint": { "type": "string", "enum": ["query", "body", "header", "cookie", "path"] }, + "evidenceSignal": { + "type": "string", + "enum": ["responseStackTrace", "statusCodeFlip", "dbErrorString", "timingSideChannel", "reflectedPayload", "other"] + }, + "correlationSignalName": { + "type": "string", + "enum": ["responseStackTrace", "routeTable", "callGraphReach", "parameterName", "cweMatch", "rerunFlip"] + }, + "inventoryProvenance": { + "type": "string", + "enum": ["runtime_spec", "repo_spec", "static_extraction", "crawl"] + }, + "handoffState": { + "description": "Frozen alongside the six (IMPLEMENTATION-PLAN.md section 6, rulings G9+G10). Not carried on the wire record; declared here so the store DDL (R.4) and area X agree on one vocabulary and area X's deleted anvil_ledger cannot reappear.", + "type": "string", + "enum": [ + "ready", "leased", "validated", "failed_validation", "failed_format", + "skipped_budget", "false_positive", "regression_introduced", + "fixed_incidentally", "split_required", "withdrawn", "superseded", "expired" + ] + }, + "consumptionClass": { + "type": "string", + "enum": ["static_only", "requires_dynamic_confirmation"] + }, + "trustedString": { + "description": "An anvil/* extension string that originated outside Anvil, carrying its trust inline. SARIF-native strings cannot change shape, so those are classified by JSON Pointer in anvil/trust.fields instead.", + "type": "object", + "required": ["text", "trust"], + "properties": { + "text": { "type": "string" }, + "trust": { "$ref": "#/$defs/externalTrust" } + } + }, + + "auditProperties": { + "description": "sarifLog.properties — the audit envelope.", + "type": "object", + "required": [ + "anvil/schemaVersion", + "anvil/auditId", + "anvil/state", + "anvil/version", + "anvil/createdAt", + "anvil/target", + "anvil/trigger", + "anvil/deadline", + "anvil/index", + "anvil/dastStatus" + ], + "properties": { + "anvil/schemaVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "anvil/auditId": { "type": "string", "minLength": 1 }, + "anvil/state": { "$ref": "#/$defs/state" }, + "anvil/version": { + "description": "Monotonic; a bump re-cuts the work queue (S6, R.11).", + "type": "integer", + "minimum": 1 + }, + "anvil/createdAt": { "$ref": "#/$defs/timestamp" }, + "anvil/target": { "$ref": "#/$defs/target" }, + "anvil/trigger": { "$ref": "#/$defs/trigger" }, + "anvil/deadline": { "$ref": "#/$defs/deadline" }, + "anvil/db": { "$ref": "#/$defs/dbRef" }, + "anvil/index": { "$ref": "#/$defs/index" }, + "anvil/dastStatus": { "$ref": "#/$defs/dastStatus" } + } + }, + + "target": { + "type": "object", + "required": ["repoUrl", "ref", "commit", "subpath", "provenance", "provisioning"], + "properties": { + "repoUrl": { "type": "string" }, + "ref": { "type": "string" }, + "commit": { "type": "string" }, + "subpath": { "type": "string" }, + "runtimeBaseUrl": { "type": "string" }, + "provenance": { "$ref": "#/$defs/targetProvenance" }, + "provisioning": { "$ref": "#/$defs/targetProvisioning" } + } + }, + + "trigger": { + "description": "The record states WHICH configured policy fired. No trigger condition is ever encoded in the schema itself (research/18).", + "type": "object", + "required": ["kind", "policyId", "policyRef", "configSource", "actor", "resolvedAt"], + "properties": { + "kind": { "type": "string" }, + "policyId": { "type": "string" }, + "policyRef": { "type": "string" }, + "configSource": { "type": "string" }, + "actor": { "type": "string" }, + "resolvedAt": { "$ref": "#/$defs/timestamp" } + } + }, + + "deadline": { + "description": "Replaces branch 18's anvil/buffer per the 00-SPINE.md S1 correction: the 8 hours is a CLAIM TIMEOUT, not a deletion policy and not a confidentiality control. deadlineAt = scan start + claimTimeoutSeconds, computed once and never recomputed from any write timestamp.", + "type": "object", + "required": ["deadlineAt", "claimTimeoutSeconds", "dastDeadlineSeconds"], + "properties": { + "deadlineAt": { "$ref": "#/$defs/timestamp" }, + "claimTimeoutSeconds": { "type": "integer", "minimum": 1 }, + "dastDeadlineSeconds": { + "description": "An INDEPENDENT clock from claimTimeoutSeconds. Null when DAST is disabled.", + "type": ["integer", "null"], + "minimum": 1 + } + } + }, + + "dbRef": { + "type": "object", + "required": ["recordId", "writtenAt"], + "properties": { + "recordId": { "type": "string" }, + "writtenAt": { "$ref": "#/$defs/timestamp" } + } + }, + + "index": { + "description": "Tier-0 manifest, target <= 8 KB. readOrder is deterministic, not model-chosen.", + "type": "object", + "required": ["counts", "readOrder", "byCluster", "byCwe", "byPath", "taskCards", "blobs"], + "properties": { + "counts": { + "type": "object", + "required": ["total", "sast", "dast", "clusters", "unclustered"], + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "sast": { "type": "integer", "minimum": 0 }, + "dast": { "type": "integer", "minimum": 0 }, + "clusters": { "type": "integer", "minimum": 0 }, + "unclustered": { "type": "integer", "minimum": 0 } + } + }, + "readOrder": { + "type": "array", + "items": { "type": "string", "enum": ["clusters", "sastByRank", "dastByRank"] } + }, + "byCluster": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, + "byCwe": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, + "byPath": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, + "taskCards": { "type": "string" }, + "blobs": { "type": "string" } + } + }, + + "run": { + "description": "One independently-sealed half. Both runs carry the SAME automationDetails.correlationGuid, equal to anvil/auditId: the SARIF-native statement that they are one audit.", + "type": "object", + "required": ["tool", "automationDetails", "results", "properties"], + "properties": { + "tool": { "type": "object" }, + "automationDetails": { + "type": "object", + "required": ["correlationGuid"], + "properties": { + "id": { "type": "string" }, + "guid": { "type": "string" }, + "correlationGuid": { "type": "string", "minLength": 1 }, + "description": { "type": "object" } + } + }, + "originalUriBaseIds": { "type": "object" }, + "taxonomies": { "type": "array" }, + "externalPropertyFileReferences": { "type": "object" }, + "results": { "type": "array", "items": { "$ref": "#/$defs/result" } }, + "properties": { "$ref": "#/$defs/runProperties" } + }, + "allOf": [ + { + "$comment": "The DAST run must carry coverage, a route-table digest and its runtime target. Without dastCoverage, dastStatus:'completed_clean' is unfalsifiable.", + "if": { + "properties": { "properties": { "properties": { "anvil/half": { "const": "dast" } }, "required": ["anvil/half"] } }, + "required": ["properties"] + }, + "then": { + "properties": { + "properties": { "required": ["anvil/dastCoverage", "anvil/routeTableDigest", "anvil/runtimeTarget"] } + } + } + }, + { + "$comment": "The SAST run must carry the advisory snapshot digest it read, or staleness is unknowable.", + "if": { + "properties": { "properties": { "properties": { "anvil/half": { "const": "sast" } }, "required": ["anvil/half"] } }, + "required": ["properties"] + }, + "then": { + "properties": { "properties": { "required": ["anvil/advisorySnapshot"] } } + } + }, + { + "$comment": "R.6's read gate: sealedAt is required exactly when status is sealed, and must be null otherwise. A missing key and an unsealed half must not be the same observation.", + "if": { + "properties": { "properties": { "properties": { "anvil/status": { "const": "sealed" } }, "required": ["anvil/status"] } }, + "required": ["properties"] + }, + "then": { + "properties": { "properties": { "properties": { "anvil/sealedAt": { "type": "string", "format": "date-time" } } } } + }, + "else": { + "properties": { "properties": { "properties": { "anvil/sealedAt": { "type": "null" } } } } + } + } + ] + }, + + "runProperties": { + "type": "object", + "required": ["anvil/half", "anvil/status", "anvil/sealedAt"], + "properties": { + "anvil/half": { "$ref": "#/$defs/half" }, + "anvil/status": { "$ref": "#/$defs/halfStatus" }, + "anvil/sealedAt": { "$ref": "#/$defs/nullableTimestamp" }, + "anvil/dastCoverage": { "$ref": "#/$defs/dastCoverage" }, + "anvil/routeTableDigest": { "type": "string" }, + "anvil/advisorySnapshot": { + "type": "object", + "required": ["feedIds", "snapshotDigest", "scrapedAt"], + "properties": { + "feedIds": { "type": "array", "items": { "type": "string" } }, + "snapshotDigest": { "type": "string" }, + "scrapedAt": { "$ref": "#/$defs/timestamp" } + } + }, + "anvil/runtimeTarget": { + "type": "object", + "required": ["baseUrl", "authProfileRef", "scope"], + "properties": { + "baseUrl": { "type": "string" }, + "authProfileRef": { + "description": "A config file path and revision. The record NEVER carries credentials.", + "type": "string" + }, + "scope": { "type": "array", "items": { "type": "string" } }, + "excluded": { "type": "array", "items": { "type": "string" } } + } + } + } + }, + + "dastCoverage": { + "description": "Consolidates S6's dast_coverage, endpoint_coverage and inventory_provenance. A numerator, a denominator and a provenance mix — never a bare ratio (research/14 critique m6). endpoint_coverage is confirmed-probed endpoints over the union of the Tier 0-2 inventory, NEVER a request count.", + "type": "object", + "required": [ + "probedCount", + "inventoryUnionCount", + "endpointCoverage", + "serverLineCoverage", + "inventoryProvenanceMix", + "confirmedCount", + "candidateCount" + ], + "properties": { + "probedCount": { "type": "integer", "minimum": 0 }, + "inventoryUnionCount": { "type": "integer", "minimum": 0 }, + "endpointCoverage": { "$ref": "#/$defs/unitInterval" }, + "serverLineCoverage": { + "description": "Null, NOT zero, when not measured. Zero would read as 'we ran and covered nothing'.", + "type": ["number", "null"], + "minimum": 0, + "maximum": 1 + }, + "inventoryProvenanceMix": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/inventoryProvenance" }, + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "confirmedCount": { "type": "integer", "minimum": 0 }, + "candidateCount": { "type": "integer", "minimum": 0 } + } + }, + + "result": { + "type": "object", + "required": ["ruleId", "message", "partialFingerprints", "properties"], + "properties": { + "ruleId": { "type": "string", "minLength": 1 }, + "ruleIndex": { "type": "integer", "minimum": 0 }, + "kind": { "type": "string", "enum": ["notApplicable", "pass", "fail", "review", "open", "informational"] }, + "level": { "type": "string", "enum": ["none", "note", "warning", "error"] }, + "rank": { + "description": "SARIF PRIORITY 0-100. NEVER confidence: detector certainty lives in anvil/confidence, and ingested third-party `rank` must be treated as untrusted and re-derived (research/18 Risk #8).", + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "guid": { "type": "string" }, + "correlationGuid": { + "description": "Assigned per CLUSTER, not per finding. Present only on clustered findings.", + "type": "string" + }, + "message": { "type": "object", "required": ["text"], "properties": { "text": { "type": "string" } } }, + "locations": { "type": "array" }, + "relatedLocations": { "type": "array" }, + "codeFlows": { "type": "array" }, + "taxa": { "type": "array" }, + "webRequest": { "type": "object" }, + "webResponse": { "type": "object" }, + "provenance": { "type": "object" }, + "fixes": { + "description": "Written only after a coding-agent proposal. 00-SPINE.md S7: never auto-merge, propose only.", + "type": "array" + }, + "partialFingerprints": { + "type": "object", + "required": ["anvilFindingId/v1"], + "properties": { + "anvilFindingId/v1": { + "description": "Full 64-hex anvil-fp/v1 digest. NEVER truncated.", + "$ref": "#/$defs/sha256Hex" + }, + "primaryLocationLineHash": { + "description": "Required when a physical code location exists. The ONLY partial fingerprint GitHub reads.", + "type": "string" + }, + "regionSha256": { + "description": "research/24's fingerprint.region_sha256. Reserved, optional; R.2 decides whether to populate it. Logged deviation 2 in CONTRACT.md.", + "type": "string" + } + } + }, + "properties": { "$ref": "#/$defs/resultProperties" } + }, + "allOf": [ + { + "$comment": "A host finding is never remediable by an agent. 00-SPINE.md S7: the host agent is read-only, not behind a flag. The store enforces the same rule with a CHECK constraint.", + "if": { + "properties": { + "properties": { + "properties": { "anvil/evidenceClass": { "const": "host" } }, + "required": ["anvil/evidenceClass"] + } + }, + "required": ["properties"] + }, + "then": { + "properties": { + "properties": { "properties": { "anvil/remediableByAgent": { "const": false } } } + } + } + }, + { + "$comment": "A DAST response body is up to 32 KB of attacker-controlled bytes headed for a repo-credentialed agent (S7). Any result carrying one defaults to untrusted.", + "if": { "required": ["webResponse"] }, + "then": { + "properties": { + "properties": { + "properties": { "anvil/trust": { "properties": { "default": { "const": "untrusted" } } } } + } + } + } + } + ] + }, + + "resultProperties": { + "type": "object", + "required": [ + "anvil/findingId", + "anvil/half", + "anvil/confidence", + "anvil/verdict", + "anvil/remediableByAgent", + "anvil/reasoning", + "anvil/detector", + "anvil/evidenceClass", + "anvil/trust" + ], + "properties": { + "anvil/findingId": { "type": "string", "minLength": 1 }, + "anvil/half": { "$ref": "#/$defs/half" }, + "anvil/confidence": { + "description": "DETECTOR CERTAINTY in [0,1]. Distinct from SARIF rank (priority) and level (severity).", + "$ref": "#/$defs/unitInterval" + }, + "anvil/verdict": { "$ref": "#/$defs/verdict" }, + "anvil/remediableByAgent": { "type": "boolean" }, + "anvil/reasoning": { "type": "string" }, + "anvil/detector": { + "type": "object", + "required": ["kind", "model", "revision"], + "properties": { + "kind": { "$ref": "#/$defs/detectorKind" }, + "model": { "type": "string" }, + "revision": { "type": "string" }, + "promptDigest": { "type": "string" }, + "advisoryItemId": { "type": "string" } + } + }, + "anvil/evidenceClass": { "$ref": "#/$defs/evidenceClass" }, + "anvil/trust": { "$ref": "#/$defs/trustAssertion" }, + "anvil/advisory": { "$ref": "#/$defs/advisoryContext" }, + "anvil/risk": { "$ref": "#/$defs/risk" }, + "anvil/patchContext": { "$ref": "#/$defs/patchContext" }, + "anvil/correlation": { "$ref": "#/$defs/correlation" }, + "anvil/repro": { "$ref": "#/$defs/repro" }, + "anvil/locus": { + "type": "object", + "required": ["proximityClass"], + "properties": { "proximityClass": { "type": "string" } } + }, + "anvil/chunkRef": { "type": "string" }, + "anvil/groupId": { + "description": "Key RESERVED here; ASSIGNED by the coding-agent consumption pipeline, not by this area.", + "type": "string" + } + } + }, + + "trustAssertion": { + "description": "S6 requires trust on EVERY string originating outside Anvil, and one result carries several strings of different provenance at once. `default` covers anything not named; `fields` maps an RFC 6901 JSON Pointer relative to the result object to that string's trust. Logged deviation 3 in CONTRACT.md.", + "type": "object", + "required": ["default"], + "properties": { + "default": { "$ref": "#/$defs/trust" }, + "fields": { "type": "object", "additionalProperties": { "$ref": "#/$defs/trust" } } + } + }, + + "advisoryContext": { + "type": "object", + "required": ["ids", "cveIds", "sourceFeed", "snapshotDigest", "asOf", "stalenessSeconds", "parseDegraded"], + "properties": { + "ids": { "type": "array", "items": { "type": "string" } }, + "cveIds": { "type": "array", "items": { "type": "string" } }, + "sourceFeed": { "type": "string" }, + "snapshotDigest": { "type": "string" }, + "licenseSpdx": { + "description": "Carried per finding: the feed licence attaches to the text, not to Anvil (S8).", + "type": "string" + }, + "asOf": { "$ref": "#/$defs/timestamp" }, + "stalenessSeconds": { "type": "integer", "minimum": 0 }, + "parseDegraded": { "type": "boolean" }, + "excerpt": { + "description": "<= 800 tokens, pre-trimmed by ingestion, never a whole advisory. External text, so it carries its own trust inline.", + "$ref": "#/$defs/trustedString" + } + } + }, + + "risk": { + "description": "research/24's non-negotiable ranking inputs. LOGGED DEVIATION 1: no row for anvil/risk exists in plan/40-record-and-storage.md's Record Field Contract table.", + "type": "object", + "required": ["kevMember", "kevRansomwareUse"], + "properties": { + "cvssV4Base": { "type": "number", "minimum": 0, "maximum": 10 }, + "epssScore": { "$ref": "#/$defs/unitInterval" }, + "epssPercentile": { "$ref": "#/$defs/unitInterval" }, + "epssModelDate": { "$ref": "#/$defs/timestamp" }, + "kevMember": { "type": "boolean" }, + "kevRansomwareUse": { "type": "boolean" } + } + }, + + "patchContext": { + "type": "object", + "required": ["language", "editableFiles"], + "properties": { + "language": { "type": "string" }, + "languageVersion": { "type": "string" }, + "framework": { "type": "string" }, + "dbDriver": { "type": "string" }, + "imports": { "type": "array", "items": { "type": "string" } }, + "testCommand": { "type": ["string", "null"] }, + "buildCommand": { + "description": "Null or omitted when the language has no build step. Both spellings are accepted; Go's decoder treats null as absent.", + "type": ["string", "null"] + }, + "editableFiles": { "type": "array", "items": { "type": "string" } } + } + }, + + "correlation": { + "description": "LINK, NEVER MERGE. Both findings always survive independently: the SAST finding owns the file and line, the DAST finding owns the proof. >= 2 independent signals are required, and a CWE-only match is banned as a sole signal.", + "type": "object", + "required": ["clusterId", "role", "peers", "signals", "confidence", "verified", "merged"], + "properties": { + "clusterId": { "type": "string" }, + "role": { "$ref": "#/$defs/half" }, + "peers": { "type": "array", "items": { "type": "string" } }, + "method": { "type": "string" }, + "signals": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["name", "weight"], + "properties": { + "name": { "$ref": "#/$defs/correlationSignalName" }, + "weight": { "$ref": "#/$defs/unitInterval" }, + "detail": { "type": "string" } + } + }, + "not": { + "$comment": "A CWE-only signal list is banned: CWE match is necessary, never sufficient.", + "items": { "properties": { "name": { "const": "cweMatch" } }, "required": ["name"] } + } + }, + "confidence": { "$ref": "#/$defs/unitInterval" }, + "verified": { "type": "boolean" }, + "verificationMethod": { "type": "string" }, + "merged": { "const": false }, + "caveat": { "type": "string" } + }, + "allOf": [ + { + "$comment": "00-SPINE.md S7: only a response stack trace naming the SAST file, or a post-patch re-run that flips, earns verified:true. Confidence alone never qualifies.", + "if": { "properties": { "verified": { "const": true } }, "required": ["verified"] }, + "then": { + "properties": { + "signals": { + "contains": { + "properties": { "name": { "enum": ["responseStackTrace", "rerunFlip"] } }, + "required": ["name"] + } + } + } + } + } + ] + }, + + "repro": { + "type": "object", + "required": ["injectionPoint", "observedSignal", "env"], + "properties": { + "curl": { "type": "string" }, + "injectionPoint": { + "type": "object", + "required": ["kind", "name"], + "properties": { + "kind": { "$ref": "#/$defs/injectionPoint" }, + "name": { "type": "string" } + } + }, + "payload": { "type": "string" }, + "payloadEncoding": { "type": "string" }, + "steps": { "type": "array", "items": { "type": "string" } }, + "baseline": { + "type": "object", + "properties": { + "statusCode": { "type": "integer" }, + "latencyMs": { "type": "integer", "minimum": 0 } + } + }, + "observedSignal": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { "$ref": "#/$defs/evidenceSignal" }, + "match": { + "description": "A regex-extracted evidence SPAN, never the raw body (S7: hash-and-reference by default). Attacker-controlled, so it carries its own trust inline.", + "$ref": "#/$defs/trustedString" + }, + "bodySha256": { "type": "string" }, + "bodyOffset": { "type": "integer", "minimum": 0 } + } + }, + "expectedAfterFix": { + "type": "object", + "properties": { + "statusCode": { "type": "integer" }, + "mustNotContain": { "type": "array", "items": { "type": "string" } } + } + }, + "sideEffects": { "type": "string" }, + "targetStateRef": { "type": "string" }, + "env": { + "description": "S6 requires sanitizer + ASLR state on any reproducer. A crash that reproduces only under ASan is a different claim from one that reproduces on a stock build, and a verification re-run under different settings is not the same experiment.", + "type": "object", + "required": ["sanitizers", "aslrEnabled"], + "properties": { + "sanitizers": { + "description": "Empty array for a stock build. NEVER null: null cannot be distinguished from 'nobody recorded it'.", + "type": "array", + "items": { "type": "string" } + }, + "aslrEnabled": { "type": "boolean" }, + "arch": { "type": "string" }, + "os": { "type": "string" } + } + } + } + } + } +}