@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -9,6 +9,8 @@ Annotators that run **after** every detector has emitted, plus state stores read
9
9
 
10
10
  **Calibration + held-out evaluation** — `calibration.js`, `calibration-drift.js`, `validator-metrics.js`, `holdout-eval.js`. The seed corpus lives at `calibration-seed.json`; held-out labels are taken via `loadLabeledJsonl`. Brier and ECE both live in `holdout-eval.js`; never reintroduce a "fit-on-the-table" version.
11
11
 
12
+ **Published accuracy scorecard (R3)** — `accuracy-scorecard.js`. Pure aggregation + markdown/JSON rendering for `docs/SCORECARD.md`; the impure driver that performs the corpus and self-scan runs is `scripts/scorecard.mjs` (`npm run scorecard`). Every rate is carried as `{n, d}` and rendered through `formatRate()` so a percentage can never appear without its denominator; entries a run could not score are excluded from every denominator *and* disclosed by name. No F1 is emitted — see the module header for why, and don't add one without a labelled real-world population to measure precision over.
13
+
12
14
  **Cross-language taint** — `cross-lang-{openapi,grpc,graphql,orm,queues,meta}.js`. Each parses a contract artifact (`openapi.json`, `*.proto`, `*.graphql`, queue config) and emits a chain finding when the same data crosses a language boundary into another module's finding.
13
15
 
14
16
  **Risk amplification** — `epss.js`, `kev` (in `version.js`), `blast-radius.js`, `crown-jewels.js`, `exploitability.js`, `bounty-prediction.js`, `risk-in-dollars` (lives in `scripts/`, not here).
@@ -19,15 +21,61 @@ Annotators that run **after** every detector has emitted, plus state stores read
19
21
 
20
22
  **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (closed-loop re-scan + lint), `fix-plan.js` (oversized-patch fallback), `regression-test-gen.js`, `deterministic-fix.js` (safe context-independent literal-swap patch synthesis — md5/sha1→sha256, TLS verify-off→on — materialized on demand by `mcp/synthesize_fix`; every patch still passes through `apply_fix`'s inline verify before it lands).
21
23
 
24
+ **Measured fix loop (R5)** — `fix-metrics.js`. `verifyFix` times each stage
25
+ (`rescan`/`lint`/`tests`/`honesty`) and appends one record per attempt to
26
+ `.agentic-security/fix-metrics.jsonl`; `summarizeFixDurations` turns those into
27
+ the reported distribution, surfaced on `scan.fixMetrics` and as a stderr line
28
+ on human formats. Distinguish it from `time-to-fix.js`, which *estimates*
29
+ engineering hours from family and patch shape before anything runs — this
30
+ module reports only what was observed.
31
+
32
+ Three bucketing rules are load-bearing and each has a test that fails if
33
+ relaxed: **failed attempts never enter the validated distribution** (a failed
34
+ verification short-circuits, so blending them makes a worse pipeline look
35
+ faster); **"tests skipped" is bucketed apart from "tests passed"**
36
+ (`validatedWithoutTests`, since a project with no detectable suite reaches
37
+ `ok:true` on a weaker and much cheaper check); and **per-stage timings come
38
+ from validated runs only** (a failed run truncates every stage after the
39
+ failure point). Buckets partition the attempts, so the counts always sum.
40
+ Percentiles are nearest-rank — every figure reported is a duration some run
41
+ actually took — and are flagged `reliable:false` below n=10 rather than hidden
42
+ or quoted as settled. Recording goes through `isSafeStateDir`, so it declines
43
+ rather than creating a stray state dir outside a project.
44
+
22
45
  **Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
23
46
 
24
47
  **Methodology additions (`docs/AGENTIC_METHODOLOGY_PRD.md`)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
25
48
  - `falsification.js` — default falsification pass. For each taint-style finding, tries to DISPROVE it (locate a context-matched control on the path, reusing `dataflow/sanitizer-proof.js`'s shape rules read-only); a blocked finding is demoted + `quarantined`, never removed and never severity-touched (recall-preserving, like `proof-gate`). Wired after `annotateProofGate`. Opt out: `AGENTIC_SECURITY_NO_FALSIFICATION=1`. Optional LLM tier over survivors when an endpoint is configured.
26
49
  - `entrypoint-inventory.js` — attack-surface completeness ledger. Enumerates every entry point (HTTP/queue/cron/CLI/env/upload/webhook) with a disposition each; on `scan.entrypointInventory`.
27
- - `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`.
50
+ - `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`. Searches the corpus **once per distinct sink pattern**, not once per finding — findings deriving the same pattern share one walk and one set of (read-only) match records. The counts are always exact; the materialised `instances` list is a bounded sample (`INSTANCE_SAMPLE_LIMIT`, 100) and says so via `instancesTruncated`. Both properties are load-bearing on large corpora: the per-finding walk was O(findings × corpus-bytes) and the instance records were O(findings × matches), which together exhausted a 6 GB heap on a 40k-file suite. If you touch this module, keep the own-site exclusion **per pattern group** — resolving it globally makes a group subtract an exclusion it never matched and drives counts negative.
28
51
  - `model-routing.js` — capability-based CWE/severity→model policy; stamps `finding.dispatchModel` (strongest for crypto/auth/critical, mid for injection, cheapest for low-sev hardening) for cost-sensitive subagent dispatch.
29
52
  - `fix-honesty-gate.js` — deterministic honesty gates on fix output: a residual-risk hand-wave guard, a cited-file:line requirement for any FP/safe verdict, and FULL/MITIGATION/WORKAROUND completeness tiers. Consumed by `fix-verify.js` when the caller supplies fix metadata; the closed-loop test leg (`fix-verify-loop.js`) is wired into `mcp/apply_fix` behind `AGENTIC_SECURITY_FIX_RUN_TESTS=1`.
30
53
 
54
+ **Relevance scoping (R6 + R9)** — `relevance.js`. Turns the two existing *inventories* into *inputs*: `entrypoint-inventory.js` supplies the attack surface, `threat-model.js` supplies assets/boundaries/STRIDE, and `annotateRelevance(findings, ctx)` scores each finding by how reachable and how threat-modelled it is. Sets `entrypointReachable: true|false|null`, `relevance` (0..1), `relevanceTier: 'direct'|'indirect'|'unreachable'|'unknown'`, `relevanceFactors[]`, and re-ranks `exploitability` (ordinal priority, ×1.15 direct / ×0.6 unreachable, floored at 0.05, tier label recomputed on the same thresholds `annotateExploitability` uses). Reachability is a forward BFS over a literal-specifier import graph (JS/TS relative + Python dotted + Java FQCN) starting at every entry-point file.
55
+
56
+ Its contract is **recall-preserving, same precedent as `falsification.js` / `dataflow/proof-gate.js`**: it never removes a finding, never touches `severity`, and never asserts `unreachable` without positive evidence — a negative verdict additionally requires the intra-repo import graph to be hole-free *along the reachable set* (an unresolved relative import or a non-literal `require(x)` in a reachable file hides a possible edge, so every would-be `unreachable` degrades to `unknown`). `null`/`'unknown'` is a first-class state and is **not** the same as `false`. Wired in `engine.js` after the entry-point inventory is built and after every finding has been appended (multi-sink and cross-language chains included) so nothing escapes annotation; this is the one annotator that deliberately runs after `why-fired`.
57
+
58
+ **Enforced verification separation (R7)** — `verification-separation.js`. The falsification pass could already try to *disprove* a finding; what it could not do was prove the checker was not the producer. This module supplies that structural guarantee:
59
+
60
+ - `recordProducer(finding, producerId)` stamps provenance **write-once** — a later party cannot re-stamp itself as producer to manufacture separation.
61
+ - `assertSeparation(finding, verifierId)` refuses when verifier === producer, and **fails closed** when no producer was recorded (unestablishable separation is not separation).
62
+ - `recordVerdict(finding, {verifierId, lens, verdict, reason})` runs that check itself, so there is no path to a recorded verdict that skips it. `lens` is the perspective (`'control-flow'`, `'reachability'`, `'data-shape'`, `'llm-review'`); `verdict ∈ 'upheld'|'refuted'|'undecided'`. One verifier gets one vote per lens — a re-vote replaces rather than stuffs.
63
+ - `consensusOf(finding) -> {verdict, upheld, refuted, undecided, lenses[]}` — majority, `'undecided'` on a tie or on no verdicts.
64
+
65
+ Producer ids are namespaced `detector:<parser>`, verifier ids `verifier:<name>`, so the two spaces cannot collide. **Nothing throws** (posture convention): every entry point returns `{ok:false, refused:true, reason}`.
66
+
67
+ Contract is **recall-preserving, same precedent as `falsification.js` / `proof-gate.js`**: a `refuted` verdict never removes a finding and never touches `severity`. It is a triage signal, not a deletion.
68
+
69
+ Wired in `falsification.js`: the detector is stamped as producer, the falsification pass records under `VERIFIER_FALSIFICATION` on the `control-flow` lens, and the optional LLM tier records separately under `VERIFIER_LLM_REVIEW` on the `llm-review` lens — which is what makes a contested finding legible *as contested* (upheld vs refuted → consensus `undecided`) instead of resolved by whoever spoke last. Result lands on `finding.verification = {producer, verdicts[], consensus}`.
70
+
71
+ **Run attestation (R4)** — `attestation.js`. Turns determinism from an implementation property into something a third party can check. `computeRunAttestation({findings, engineVersion, rulesetVersion, bundleSha, root, sign})` returns `{digest, algorithm, findingCount, engineVersion, rulesetVersion, bundleSha, canonicalisation, proves, doesNotProve, signature?}`; `verifyRunAttestation(attestation, {findings, …})` re-derives and returns `{ok, reason}`.
72
+
73
+ Canonicalisation is an **allowlist, not a denylist** — each finding reduces to `id ⇥ severity ⇥ file ⇥ line ⇥ cwe ⇥ vuln`, rows sorted, multiplicity preserved. That is what makes the digest independent of emission order, run ids, timestamps, durations, separator style, and the absolute prefix (when `root` is given), while a changed severity/file/line/rule id/cwe or a finding appearing or disappearing all change it. A new volatile field cannot leak in without being added to the allowlist deliberately. `parser` and `family` are deliberately **excluded**: `parser` records which analysis engine fired, which is environment-sensitive (the Python AST path vs. its regex fallback), so including it would report an environment difference as a findings difference.
74
+
75
+ **What it proves / does not prove** — the attestation carries both statements inline, and both are asserted by a test so they cannot be quietly dropped. It proves two finding sets with the same digest under the same canonicalisation are the same findings from the same engine/ruleset/bundle. It does **not** prove cross-machine reproducibility — no second machine was compared, and this repo makes no such claim. Signing reuses `integrity.js`'s per-install HMAC key handling verbatim (`signLastScan`); no second key mechanism was introduced. `verifyLastScan` is *not* reused because it verifies against a sibling `.sig` file whereas an attestation carries its signature inline, so verification re-signs and compares in constant time. Being symmetric, the signature is tamper-evidence for the operator, not third-party non-repudiation.
76
+
77
+ Wired in `bin/agentic-security.js` after every filter and after `makeDeterministic`, over `normalizeFindings(scan)` — i.e. it attests the set that actually ships — and surfaced as `attestation` in `toJSON`. `bundleSha` is read from the sidecar next to the *running* bundle and is `'unavailable'` when running from source, rather than reporting a dist hash that may not correspond to this run.
78
+
31
79
  **Integrity + signing** — `integrity.js` (per-install HMAC for `last-scan.json`), `rule-pack-signing.js`. The HMAC key lives at `$XDG_CONFIG_HOME/agentic-security/scan-key`; override via `$AGENTIC_SECURITY_HMAC_KEY`. Premortem-derived; do not regress to hostname-derived.
32
80
 
33
81
  **Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
@@ -44,6 +92,150 @@ Annotators that run **after** every detector has emitted, plus state stores read
44
92
  - **No throwing.** Every annotation in `engine.js` is wrapped `try { … } catch (_) {}`. Your annotator must degrade gracefully — set `null` on the field and continue.
45
93
  - **Dead-module test.** `npm run test:lifecycle` fails the build if you export a public symbol from a posture module that no other source file imports. Wire it in `engine.js` (or allowlist it with a written reason in `test/no-dead-modules.test.js`).
46
94
 
95
+ ## Execution-proof tiers (R2)
96
+
97
+ `proof-tier.js` + `execution-proof.js` add a fourth axis to a finding's
98
+ credibility, orthogonal to `confidence`/`exploitability`: whether the bug was
99
+ *run*, not just reasoned about.
100
+
101
+ **The four tiers** (`PROOF_TIERS`, most-proven first):
102
+
103
+ - `execution-proven` — a generated PoC ran inside the sandbox and the sandbox
104
+ observed the predicted effect (a marker file the PoC's payload should have
105
+ written showed up). The strongest claim the pipeline can make.
106
+ - `proof-failed` — a PoC ran and the marker did **not** appear. This is a
107
+ **triage signal, not a false-positive verdict**. Absence of proof is not
108
+ proof of absence: the PoC may be wrong, the param key may be misinferred,
109
+ or the vulnerable path may need state the single-shot PoC didn't set up.
110
+ Never auto-close or downgrade severity off `proof-failed` alone.
111
+ - `taint-proven` — the analyser's static reasoning (`IR-TAINT`/`MULTI-SINK`)
112
+ found it; nothing executed. This is `proofTierOf()`'s default when no
113
+ execution evidence has been attached.
114
+ - `unproven` — no analyser backing recorded at all (e.g. `REGEX` parser).
115
+
116
+ **Why a marker file, not an exit code.** The sandbox cannot reliably
117
+ distinguish "the payload was denied by confinement" from "the payload ran
118
+ and happened to exit 0" — both look like a clean exit from the parent
119
+ process's point of view. A marker file the PoC only writes *if its exploit
120
+ path actually executes* turns that ambiguity into a directly observable
121
+ fact: the file exists, or it doesn't. Exit code alone is used only for
122
+ timeout/crash detection, never as the proof signal itself.
123
+
124
+ **The backend is recorded in every evidence object** (`proofEvidence.backend`,
125
+ e.g. `'userspace'`) because not all confinement backends carry the same
126
+ guarantee. Two backends now carry a verified-by-execution escape contract:
127
+ `userspace` (verified on the macOS development host) and `namespace`, which
128
+ implements write confinement as well as network isolation (read-only rebind of
129
+ the mount tree, read-write rebind of the sandbox root, capability drop before
130
+ exec) and whose escape suite has now **RUN and passed on a Linux host in CI** —
131
+ see `sandbox/CLAUDE.md` for the host and the eight cases. `execution-proven`
132
+ evidence from either of those backends stands on an executed escape contract.
133
+ The `disabled` backend never produces evidence at all: it refuses to run,
134
+ which is why the tier stays static rather than becoming `proof-failed`.
135
+ Keep reading `proofEvidence.backend` anyway — it is what makes a tier
136
+ re-auditable when a backend's contract changes, and a backend added later
137
+ starts out unverified by default. `attachProofTier()` also enforces the demotion
138
+ guard: `ran:false` can never yield `execution-proven` or `proof-failed`,
139
+ regardless of what tier was requested — it falls back to the finding's
140
+ static standing (`proofTierOf`).
141
+
142
+ **`ran` means the PoC executed, not that `runConfined` returned.** A sandbox
143
+ that could not start (`status:'error'` — confinement binary missing, namespaces
144
+ denied) and a refusal (`status:'disabled'`) both leave the PoC unexecuted, so
145
+ `execution-proof.js` records `ran:false` and a reason naming the sandbox
146
+ failure, leaving the finding at its static tier. Calling that `proof-failed`
147
+ would report a broken sandbox as a failed exploit attempt — a claim about the
148
+ *finding* derived from evidence that only concerns the *host*.
149
+
150
+ `proofTier`/`proofEvidence` are copied through `report/index.js`'s
151
+ `normalizeFindings()` only when the annotator actually attached them —
152
+ never synthesised at the report layer.
153
+
154
+ ## Corpus auto-enrolment (R2's differentiator)
155
+
156
+ `corpus-enroll.js` + `corpus-match.js` turn an execution-proven finding into a
157
+ permanent CVE-replay corpus entry, so every exploit the pipeline proves once is
158
+ defended by the baseline gate forever.
159
+
160
+ **`corpus-match.js` is shared with the gate on purpose.** `bench/cve-replay/runner.mjs`
161
+ imports `matcherFor`/`preHit`/`postHit` from it. If enrolment verified a
162
+ candidate with a different matcher than the gate scores with, it would commit
163
+ entries that fail CI. The pre/post asymmetry (pre matches `vuln` OR `family`
164
+ and regex-tests `cwe`; post is strict on `vuln` and exact on `cwe`) is
165
+ reproduced verbatim from the runner — `bench/cve-replay/CONTRIBUTING.md` records
166
+ it as known imprecision, and changing it would re-verdict the whole committed
167
+ baseline, which is a corpus migration rather than a refactor.
168
+
169
+ **Nothing is written that has not been scored.** `enrollProvenFinding` builds
170
+ the entry in a temp dir, scans `pre/` and `post/`, and moves it into the corpus
171
+ only on `pre:TP post:TN`. There is no force flag, and `scoreCandidate` is
172
+ deliberately unexported so no caller can score by one route and write by
173
+ another — that unscored-write path is the v0.106.0 mistake this module would
174
+ otherwise automate. Refusals also cover: a tier that disagrees with its own
175
+ `proofEvidence`, `ran !== true`, an `execution-proven` tier with nothing
176
+ observed, a missing `post/` (never synthesised by deleting the vulnerable
177
+ line — it would pass for the wrong reason), a `post/` identical to `pre/`, a
178
+ path escaping the entry dir, a `pre/` not containing the finding's file, and a
179
+ duplicate id. The manifest's `vuln_match` is regex-escaped so an unrelated
180
+ detector cannot satisfy the entry.
181
+
182
+ **New entries land in `capability/`, never `regression/`.** `regression/` is
183
+ the CI-gated tier and graduation into it is a human decision with a stated
184
+ policy; an automated writer must not decide what blocks everyone's build.
185
+
186
+ **Operator entry point:** `scripts/enroll-proven-finding.mjs <project>`
187
+ (`--dry-run` scores without writing). It proves findings itself — the scan
188
+ pipeline does **not** attach a `poc` to findings or promote proof tiers, so
189
+ `last-scan.json` never contains an `execution-proven` finding on its own. PoCs
190
+ come from the PoC-generator. Enrolment additionally needs fixed content
191
+ (`finding.fix.patch`) for `post/`; a proven finding with no fix is reported as
192
+ skipped, not dropped. After enrolling, refresh the baseline
193
+ (`npm run bench:cve-replay:update-baseline`) and commit it.
194
+
195
+ ## Scan checkpointing / resume (R8)
196
+
197
+ `scan-checkpoint.js` lets an interrupted scan resume instead of restarting, which
198
+ is what caps usable repository size today. **Opt-in only**: `AGENTIC_SECURITY_RESUME=1`
199
+ (or `runScan(root, {resume:true})`). Default behaviour is byte-for-byte unchanged
200
+ and nothing is written.
201
+
202
+ **What is checkpointed.** Only the per-file loop in `engine.js#runFullScan` — the
203
+ one place per-file work happens. Each completed file's *entire* contribution is
204
+ persisted (routes, findings, taint sources/sinks/sanitizers, logic vulns, secrets,
205
+ at-rest/in-transit ciphers, the suppression-log delta, and the per-file taint
206
+ result the cross-file pass reads), not just its findings. Everything after the
207
+ loop — cross-file taint, gadget detection, the whole annotation pipeline — re-runs
208
+ from scratch, so nothing that depends on the global picture can be stale by
209
+ construction. On replay, `pfr[p]`'s arrays are rebuilt as slices of the aggregate
210
+ arrays, so object identity between the two matches an uninterrupted run exactly.
211
+
212
+ **The property.** A resumed scan must produce the same finding set as an
213
+ uninterrupted one; a checkpoint that silently drops findings turns a slow scan
214
+ into a quietly incomplete one, which is worse than no checkpoint. `test/scan-checkpoint.test.js`
215
+ asserts this end-to-end: a child process is hard-exited (`process.exit`, no
216
+ unwinding) partway through a real scan, the resumed scan is compared against a
217
+ genuinely uninterrupted one, and the fixture is asserted to exercise every
218
+ channel inside the replayed prefix so a dropped channel cannot go unnoticed.
219
+
220
+ **Invalidation is deliberately blunt.** The run key covers engine version,
221
+ ruleset version, bundle SHA, a content hash of every scanned and dependency file
222
+ (which subsumes mtime), and every `AGENTIC_SECURITY_*` env switch. If any of it
223
+ moved, the checkpoint is discarded and the scan starts clean. Redoing work is
224
+ slow; resuming stale work is a correctness bug.
225
+
226
+ **Crash safety: append-and-fsync.** JSONL — one header line pinning the run key,
227
+ then one record per file carrying a SHA-256 of its own payload, each written with
228
+ a single `writeSync` and `fsyncSync`'d before the next file is analysed. Recovery
229
+ reads forward while records verify and truncates at the last byte that did, so a
230
+ torn or tampered tail is dropped rather than resumed into. Nothing is rewritten
231
+ in place. Values JSON cannot round-trip (Date/RegExp/Map/function/…) are refused
232
+ rather than recorded lossily — that file just gets rescanned. On clean completion
233
+ the checkpoint is removed, so the next run cannot resume consumed state.
234
+
235
+ State lives at `<scanRoot>/.agentic-security/scan-checkpoint.jsonl`; like every
236
+ other module here, nothing throws — a failure to open, read or append degrades to
237
+ "no checkpoint", i.e. a normal full scan.
238
+
47
239
  ## Gotchas
48
240
 
49
241
  - The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
@@ -0,0 +1,317 @@
1
+ // Accuracy scorecard (roadmap R3) — aggregation + rendering.
2
+ //
3
+ // WHY THIS LIVES IN src/posture/ RATHER THAN ENTIRELY IN scripts/:
4
+ // every figure this project publishes has to be re-derivable and testable.
5
+ // The aggregation and the rendering are pure functions of their inputs, so
6
+ // they belong where the unit-test suite already runs (tests execute against
7
+ // `src/` directly). The thin driver at `scripts/scorecard.mjs` does the
8
+ // impure half — running the corpus, running the self-scan, reading the
9
+ // bundle hash and the git commit — and hands the results here. That split is
10
+ // what makes the "hand-computed fixture" test in
11
+ // `test/accuracy-scorecard.test.js` possible at all.
12
+ //
13
+ // INTEGRITY CONTRACT (the reason the item exists — read before editing):
14
+ // · Nothing here invents a rate. Every rate is carried as {n, d} and
15
+ // rendered through formatRate(), which always prints the numerator and
16
+ // the denominator alongside the percentage. A denominator of 0 renders
17
+ // "0/0 (n/a)" — never 0% and never 100%.
18
+ // · Entries the run could not score (parser unavailable, scan error) are
19
+ // removed from every denominator AND disclosed by name. Scoring them
20
+ // either way would manufacture a number out of an environment fault.
21
+ // · No F1 is emitted. F1 needs a precision measured over a labelled
22
+ // real-world population; this corpus is curated known-vulnerable
23
+ // fixtures and their fixed counterparts, so its "precision" would be
24
+ // computed over a denominator that does not mean what the word implies.
25
+ // See renderScorecardMarkdown()'s methodology section.
26
+ // · Output is a deterministic function of the inputs apart from one
27
+ // timestamp line (TIMESTAMP_MARKER), so the document regenerates
28
+ // byte-identically on an unchanged tree.
29
+
30
+ // The single line of the rendered document that is permitted to vary between
31
+ // two runs on an unchanged tree. Tests and the regeneration check key on it.
32
+ export const TIMESTAMP_MARKER = 'Generated (UTC)';
33
+
34
+ /**
35
+ * Render a rate with its raw counts. This is the ONLY sanctioned way to put a
36
+ * percentage into the scorecard: a bare percentage with no visible
37
+ * denominator is exactly the kind of unfalsifiable claim this document exists
38
+ * to avoid.
39
+ */
40
+ export function formatRate(n, d) {
41
+ if (!d) return `${n}/${d} (n/a)`;
42
+ return `${n}/${d} (${((n / d) * 100).toFixed(1)}%)`;
43
+ }
44
+
45
+ function emptyBucket(key) {
46
+ return { key, entries: 0, detection: { n: 0, d: 0 }, silence: { n: 0, d: 0 } };
47
+ }
48
+
49
+ function accumulate(bucket, status) {
50
+ bucket.entries++;
51
+ if (status.includes('pre:TP')) { bucket.detection.n++; bucket.detection.d++; }
52
+ else if (status.includes('pre:FN')) { bucket.detection.d++; }
53
+ if (status.includes('post:TN')) { bucket.silence.n++; bucket.silence.d++; }
54
+ else if (status.includes('post:FP')) { bucket.silence.d++; }
55
+ }
56
+
57
+ function sliceBy(scored, key) {
58
+ const map = new Map();
59
+ for (const d of scored) {
60
+ const k = d[key] || 'unknown';
61
+ if (!map.has(k)) map.set(k, emptyBucket(k));
62
+ accumulate(map.get(k), d.status);
63
+ }
64
+ // Sorted by key so the document is stable across runs.
65
+ return [...map.values()].sort((a, b) => a.key.localeCompare(b.key));
66
+ }
67
+
68
+ /**
69
+ * Aggregate the per-entry detail records emitted by the corpus runner.
70
+ *
71
+ * Input records: { cve, tier, cwe, language, status, error? } where status is
72
+ * the runner's own string form, e.g. "pre:TP post:TN", "pre:FN", "env-error".
73
+ */
74
+ export function aggregateCorpus(detail) {
75
+ const all = [...(detail || [])];
76
+ const notScored = all
77
+ .filter(d => !d.status || !d.status.startsWith('pre:'))
78
+ .map(d => ({ cve: d.cve, tier: d.tier || null, language: d.language || null, status: d.status || 'unknown', error: d.error || null }))
79
+ .sort((a, b) => String(a.cve).localeCompare(String(b.cve)));
80
+ const scored = all.filter(d => d.status && d.status.startsWith('pre:'));
81
+
82
+ const overall = emptyBucket('overall');
83
+ for (const d of scored) accumulate(overall, d.status);
84
+
85
+ return {
86
+ totalEntries: all.length,
87
+ scoredEntries: scored.length,
88
+ notScored,
89
+ overall: { detection: overall.detection, silence: overall.silence },
90
+ byLanguage: sliceBy(scored, 'language'),
91
+ byCwe: sliceBy(scored, 'cwe'),
92
+ byTier: sliceBy(scored, 'tier'),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Build the machine-readable scorecard model. `inputs`:
98
+ * provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt }
99
+ * corpusDetail per-entry records from a corpus run performed THIS run
100
+ * selfScan { targets: {name:{total,byFile}}, polyglot: {total,byLanguage} }
101
+ * — measured THIS run
102
+ * committed { corpusBaseline, proofCorpus } — read from committed files,
103
+ * labelled as such in the output, never used to derive a rate
104
+ */
105
+ export function buildScorecard(inputs) {
106
+ const corpus = aggregateCorpus(inputs.corpusDetail);
107
+ const selfScan = inputs.selfScan || {};
108
+ const targets = {};
109
+ for (const k of Object.keys(selfScan.targets || {}).sort()) {
110
+ targets[k] = { total: selfScan.targets[k].total, byFile: selfScan.targets[k].byFile || {} };
111
+ }
112
+ const committed = inputs.committed || {};
113
+ const proof = committed.proofCorpus || null;
114
+ return {
115
+ schema: 'agentic-security/accuracy-scorecard@1',
116
+ provenance: { ...inputs.provenance },
117
+ methodology: {
118
+ population: 'curated known-vulnerable fixtures (pre/) and their fixed counterparts (post/)',
119
+ detectionRateMeans: 'share of known-vulnerable fixtures where the expected finding fired',
120
+ silenceRateMeans: 'share of fixed counterparts where the same finding correctly did not fire',
121
+ notGeneralRecall: true,
122
+ notGeneralFalsePositiveRate: true,
123
+ f1Emitted: false,
124
+ f1OmissionReason: 'no labelled real-world population is available to measure precision over, so any F1 would combine denominators that do not describe the same population',
125
+ },
126
+ corpus: {
127
+ measuredThisRun: true,
128
+ totalEntries: corpus.totalEntries,
129
+ scoredEntries: corpus.scoredEntries,
130
+ notScored: corpus.notScored,
131
+ overall: corpus.overall,
132
+ byLanguage: corpus.byLanguage,
133
+ byCwe: corpus.byCwe,
134
+ byTier: corpus.byTier,
135
+ },
136
+ selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
137
+ committedInputs: {
138
+ corpusBaseline: committed.corpusBaseline
139
+ ? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
140
+ : null,
141
+ proofCorpus: proof
142
+ ? {
143
+ source: 'bench/proof-corpus/results/summary.json',
144
+ bundleSha: proof.bundleSha || null,
145
+ targetCount: proof.targetCount ?? null,
146
+ ok: proof.ok ?? null,
147
+ failed: proof.failed ?? null,
148
+ targets: (proof.targets || []).map(t => ({
149
+ id: t.id, commit: t.commit, status: t.status,
150
+ filesInScope: t.coverage?.totals?.inScope ?? null,
151
+ filesParsed: t.coverage?.totals?.parsed ?? null,
152
+ determinismChecked: t.determinism?.checked ?? null,
153
+ determinismIdentical: t.determinism?.identical ?? null,
154
+ resultsEmitted: t.determinism?.results ?? null,
155
+ })).sort((a, b) => String(a.id).localeCompare(String(b.id))),
156
+ }
157
+ : null,
158
+ },
159
+ };
160
+ }
161
+
162
+ function rateRow(r) {
163
+ return `| ${r.key} | ${r.entries} | ${formatRate(r.detection.n, r.detection.d)} | ${formatRate(r.silence.n, r.silence.d)} |`;
164
+ }
165
+
166
+ /** Render the human-readable scorecard. Deterministic apart from one line. */
167
+ export function renderScorecardMarkdown(m) {
168
+ const p = m.provenance || {};
169
+ const c = m.corpus;
170
+ const L = [];
171
+ L.push('# Accuracy scorecard');
172
+ L.push('');
173
+ L.push('Generated by `npm run scorecard`. Every figure below comes from a run');
174
+ L.push('performed by that command, except where a line is explicitly labelled');
175
+ L.push('*committed artifact* — those carry the timestamp and commit of the run');
176
+ L.push('that produced them.');
177
+ L.push('');
178
+ L.push('## Provenance');
179
+ L.push('');
180
+ L.push('| Field | Value |');
181
+ L.push('| --- | --- |');
182
+ L.push(`| Engine version | ${p.engineVersion || 'unknown'} |`);
183
+ L.push(`| Bundle SHA-256 | \`${p.bundleSha256 || 'unknown'}\` |`);
184
+ L.push(`| Commit | \`${p.commit || 'unknown'}\` |`);
185
+ if (p.worktreeClean !== undefined) {
186
+ L.push(`| Worktree at measurement time | ${p.worktreeClean ? 'clean' : 'DIRTY — the commit above does not fully describe what was measured'} |`);
187
+ }
188
+ L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
189
+ L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
190
+ L.push(`| ${TIMESTAMP_MARKER} | ${p.generatedAt || 'unknown'} |`);
191
+ L.push('');
192
+ L.push('## What these numbers are, and what they are not');
193
+ L.push('');
194
+ L.push('The population is a curated corpus of known-vulnerable code fixtures');
195
+ L.push('(`pre/`) each paired with its fixed counterpart (`post/`). Two rates are');
196
+ L.push('reported, always with their raw numerator and denominator:');
197
+ L.push('');
198
+ L.push('- **Detection rate** — of the vulnerable fixtures, the share where the');
199
+ L.push(' expected finding fired. This is **not** general-purpose recall against');
200
+ L.push(' real-world code: the corpus is curated, each entry was added because it');
201
+ L.push(' represents a class worth covering, and nothing here samples the');
202
+ L.push(' distribution of vulnerabilities in arbitrary repositories.');
203
+ L.push('- **Correct-silence rate** — of the fixed counterparts, the share where');
204
+ L.push(' the same finding correctly did not fire. This is **not** a general');
205
+ L.push(' false-positive rate: the denominator is one narrowly-scoped fixed file');
206
+ L.push(' per entry, not a population of real code in which any rule could');
207
+ L.push(' misfire.');
208
+ L.push('');
209
+ L.push('The corpus is also **gated**: an entry is admitted only once it scores');
210
+ L.push('detected-on-`pre` and silent-on-`post`, and a committed baseline fails the');
211
+ L.push('build on any drift. A detection rate at the ceiling is therefore expected');
212
+ L.push('by construction — it is evidence that nothing regressed, not evidence that');
213
+ L.push('there is no headroom. What moves it is adding entries for classes not yet');
214
+ L.push('covered, which is why the corpus size is reported alongside the rate.');
215
+ L.push('');
216
+ L.push('**No F1 is reported, deliberately.** An F1 requires a precision measured');
217
+ L.push('over a labelled real-world population. This project does not have one, so');
218
+ L.push('a precision computed from the corpus alone would divide by a denominator');
219
+ L.push('that does not describe the population the word implies. The precision-side');
220
+ L.push('signal reported instead is the self-scan section below: exact finding');
221
+ L.push('counts on this repository\'s own non-fixture source, where every count is');
222
+ L.push('reviewed by hand and any movement is a real change. Publishing a number we');
223
+ L.push('cannot defend would cost more credibility than the number is worth.');
224
+ L.push('');
225
+ L.push('## Corpus results (measured this run)');
226
+ L.push('');
227
+ L.push('| Population | Detected / correctly silent |');
228
+ L.push('| --- | --- |');
229
+ L.push(`| Vulnerable fixtures (\`pre/\`) — detection | ${formatRate(c.overall.detection.n, c.overall.detection.d)} |`);
230
+ L.push(`| Fixed counterparts (\`post/\`) — correct silence | ${formatRate(c.overall.silence.n, c.overall.silence.d)} |`);
231
+ L.push('');
232
+ if (c.notScored.length) {
233
+ const word = c.notScored.length === 1 ? 'entry' : 'entries';
234
+ L.push('**' + c.notScored.length + ' corpus ' + word + ' could not be scored.** Excluded from every');
235
+ L.push('denominator above rather than counted as a miss:');
236
+ L.push('');
237
+ for (const n of c.notScored) {
238
+ const why = n.error ? ' — ' + n.error : '';
239
+ L.push('- `' + n.cve + '` (' + (n.language || 'unknown') + '): ' + n.status + why);
240
+ }
241
+ L.push('');
242
+ } else {
243
+ L.push('All corpus entries scored; no entry was excluded.');
244
+ L.push('');
245
+ }
246
+ L.push('### By language');
247
+ L.push('');
248
+ L.push('| Language | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
249
+ L.push('| --- | --- | --- | --- |');
250
+ for (const r of c.byLanguage) L.push(rateRow(r));
251
+ L.push('');
252
+ L.push('### By CWE');
253
+ L.push('');
254
+ L.push('| CWE | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
255
+ L.push('| --- | --- | --- | --- |');
256
+ for (const r of c.byCwe) L.push(rateRow(r));
257
+ L.push('');
258
+ L.push('### By corpus tier');
259
+ L.push('');
260
+ L.push('| Tier | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
261
+ L.push('| --- | --- | --- | --- |');
262
+ for (const r of c.byTier) L.push(rateRow(r));
263
+ L.push('');
264
+ L.push('## Precision-side signal: self-scan (measured this run)');
265
+ L.push('');
266
+ L.push('The engine scanned this repository\'s own hand-reviewed source. These are');
267
+ L.push('absolute finding counts, not a rate — there is no labelled ground truth');
268
+ L.push('over this code, so no precision figure is derived from it. What it');
269
+ L.push('supports is a movement claim: any change in these counts between');
270
+ L.push('releases is a real change in what the engine reports on unchanged code.');
271
+ L.push('');
272
+ L.push('| Target | Findings |');
273
+ L.push('| --- | --- |');
274
+ for (const [k, v] of Object.entries(m.selfScan.targets)) L.push(`| \`${k}\` | ${v.total} |`);
275
+ L.push(`| \`polyglot\` fixture (expected 0) | ${m.selfScan.polyglot.total} |`);
276
+ L.push('');
277
+ L.push('Per-file counts are in `docs/scorecard.json`.');
278
+ L.push('');
279
+ L.push('## Committed artifacts referenced (not re-run by this command)');
280
+ L.push('');
281
+ const cb = m.committedInputs.corpusBaseline;
282
+ if (cb) {
283
+ L.push(`- **Corpus baseline** (*committed artifact*, \`${cb.source}\`, generated ${cb.generatedAt}):`);
284
+ L.push(` ${cb.passing}/${cb.total} entries recorded as passing. The gate`);
285
+ L.push(' `npm run bench:cve-replay:check` fails the build on any drift from it.');
286
+ L.push(' The rates above are computed from this run, not from this file.');
287
+ }
288
+ const pc = m.committedInputs.proofCorpus;
289
+ if (pc) {
290
+ L.push(`- **Third-party repository run** (*committed artifact*, \`${pc.source}\`,`);
291
+ L.push(` bundle \`${pc.bundleSha}\`): ${pc.ok}/${pc.targetCount} targets completed.`);
292
+ L.push(' Reported for scale and parse coverage only — these repositories have no');
293
+ L.push(' vulnerability ground truth, so no accuracy rate is derived from them.');
294
+ L.push('');
295
+ L.push('| Repository | Commit | Status | Files parsed / in scope | Results emitted | Deterministic re-run |');
296
+ L.push('| --- | --- | --- | --- | --- | --- |');
297
+ for (const t of pc.targets) {
298
+ const det = t.determinismChecked ? (t.determinismIdentical ? 'identical' : 'checked, not byte-identical') : 'not checked';
299
+ L.push(`| ${t.id} | \`${String(t.commit || '').slice(0, 12)}\` | ${t.status} | ${formatRate(t.filesParsed ?? 0, t.filesInScope ?? 0)} | ${t.resultsEmitted ?? 'n/a'} | ${det} |`);
300
+ }
301
+ }
302
+ L.push('');
303
+ L.push('## Reproducing any figure here');
304
+ L.push('');
305
+ L.push('| Figure | Command |');
306
+ L.push('| --- | --- |');
307
+ L.push('| Detection / correct-silence, and every slice | `node bench/cve-replay/runner.mjs --json` |');
308
+ L.push('| Corpus drift gate | `npm run bench:cve-replay:check` |');
309
+ L.push('| Self-scan counts | `node bench/self-scan/measure.mjs --json` |');
310
+ L.push('| Self-scan drift gate | `npm run bench:self-scan:check` |');
311
+ L.push('| This whole document | `npm run scorecard` |');
312
+ L.push('');
313
+ L.push('Running `npm run scorecard` twice on an unchanged tree produces an');
314
+ L.push('identical document apart from the generated-timestamp row above.');
315
+ L.push('');
316
+ return L.join('\n');
317
+ }
@@ -19,7 +19,7 @@
19
19
 
20
20
  import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
- import * as yaml from 'js-yaml';
22
+ import * as yaml from '../util/yaml.js';
23
23
 
24
24
  const CONTRACT_FILE_PATTERNS = [
25
25
  { glob: /(?:openapi|swagger)\.(?:ya?ml|json)$/i, kind: 'openapi' },