@clear-capabilities/agentic-security-scanner 0.130.0 → 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.
@@ -1 +1 @@
1
- dd72d7c2dcc4df5e96f24285cf531a6476881867a330000427a6a8edaf71b06e agentic-security.mjs
1
+ a1f581521eaab4f89f26aa9f06af557e9443dbea7a8411da5ecd904f3b94acd3 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.130.0",
3
+ "version": "0.132.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, SARIF ingest, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -38,9 +38,8 @@
38
38
  "@babel/core": "^8.0.1",
39
39
  "@babel/preset-react": "^8.0.1",
40
40
  "@babel/preset-typescript": "^8.0.1",
41
- "fast-glob": "^3.3.3",
42
41
  "java-parser": "^3.0.1",
43
- "js-yaml": "^5.2.2",
42
+ "js-yaml": "^5.2.3",
44
43
  "safe-regex": "^2.1.1"
45
44
  },
46
45
  "optionalDependencies": {
@@ -48,7 +47,7 @@
48
47
  "web-tree-sitter": "0.20.8"
49
48
  },
50
49
  "devDependencies": {
51
- "@types/node": "^26.1.1",
50
+ "@types/node": "^26.2.0",
52
51
  "@vercel/ncc": "^0.44.1"
53
52
  },
54
53
  "overrides": {
@@ -57,12 +56,14 @@
57
56
  },
58
57
  "scripts": {
59
58
  "build": "ncc build bin/agentic-security.js -o dist --minify -e web-tree-sitter -e tree-sitter-wasms && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
60
- "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs",
61
- "test": "npm run test:smoke && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
59
+ "prepare": "node ../scripts/pre-push-gate.mjs --install-hook",
60
+ "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs && node ../scripts/release-check.mjs",
61
+ "test": "npm run test:smoke && npm run test:glob && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
62
62
  "test:smoke": "node --test test/smoke.test.js",
63
+ "test:glob": "node --test test/glob-compat.test.js",
63
64
  "test:sast": "node --test test/llm.test.js test/llm-cost-advisor.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/file-upload.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
64
- "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/attestation.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/scan-checkpoint.test.js test/llm-redact.test.js",
65
- "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js",
65
+ "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/attestation.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/scan-checkpoint.test.js test/llm-redact.test.js",
66
+ "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/parser-js-decorators.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js",
66
67
  "test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js",
67
68
  "test:report": "node --test test/sarif-ingest.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
68
69
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
@@ -95,7 +96,14 @@
95
96
  "bench:self-scan:check": "node ../bench/self-scan/check.mjs",
96
97
  "bench:self-scan:update-baseline": "node ../bench/self-scan/check.mjs --update-baseline",
97
98
  "bench:engine-recall": "node ../bench/engine-recall/measure.mjs",
98
- "scorecard": "node ../scripts/scorecard.mjs"
99
+ "scorecard": "node ../scripts/scorecard.mjs",
100
+ "corpus:enroll": "node ../scripts/enroll-proven-finding.mjs",
101
+ "determinism:attest": "node ../scripts/attest-fixture.mjs",
102
+ "scorecard:check": "node ../scripts/scorecard-check.mjs",
103
+ "release:check": "node ../scripts/release-check.mjs",
104
+ "release:check:fast": "node ../scripts/release-check.mjs --fast",
105
+ "gate:prepush": "node ../scripts/pre-push-gate.mjs",
106
+ "gate:prepush:install": "node ../scripts/pre-push-gate.mjs --install-hook"
99
107
  },
100
108
  "author": "Ross Young <ross@clearcapabilities.com>",
101
109
  "license": "PolyForm-Internal-Use-1.0.0"
package/src/engine.js CHANGED
@@ -996,6 +996,14 @@ function performASTAnalysis(fp, code) {
996
996
  babelTransformSync(code, {
997
997
  filename: fp,
998
998
  presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
999
+ // Decorators are SYNTAX we must accept, never transform — without them the
1000
+ // parser rejects the whole file and every finding in it silently disappears.
1001
+ // Measured on one real target: 201 JS files unparseable, all decorator-using
1002
+ // framework code. 'decorators-legacy' covers the framework and TypeScript
1003
+ // parameter forms; 'decoratorAutoAccessors' adds the modern `accessor` field.
1004
+ // The modern 'decorators' variant was rejected: it cannot parse TS parameter
1005
+ // decorators, so it would trade one blind spot for another.
1006
+ parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
999
1007
  plugins: [astTaintTrackerPlugin],
1000
1008
  ast: false, code: false,
1001
1009
  babelrc: false, configFile: false,
@@ -4646,6 +4654,14 @@ function _buildCallGraphAST(fp, code){
4646
4654
  babelTransformSync(code, {
4647
4655
  filename: fp,
4648
4656
  presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
4657
+ // Decorators are SYNTAX we must accept, never transform — without them the
4658
+ // parser rejects the whole file and every finding in it silently disappears.
4659
+ // Measured on one real target: 201 JS files unparseable, all decorator-using
4660
+ // framework code. 'decorators-legacy' covers the framework and TypeScript
4661
+ // parameter forms; 'decoratorAutoAccessors' adds the modern `accessor` field.
4662
+ // The modern 'decorators' variant was rejected: it cannot parse TS parameter
4663
+ // decorators, so it would trade one blind spot for another.
4664
+ parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
4649
4665
  plugins: [callTrackerPlugin],
4650
4666
  ast: false, code: false,
4651
4667
  babelrc: false, configFile: false,
@@ -402,6 +402,14 @@ export function parseJsFile(file, code) {
402
402
  // regardless of extension. JSX stays enabled via preset-react, so .js files
403
403
  // containing JSX still parse — which .isTSX/.allExtensions guaranteed before.
404
404
  presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
405
+ // Decorators are SYNTAX we must accept, never transform — without them the
406
+ // parser rejects the whole file and every finding in it silently disappears.
407
+ // Measured on one real target: 201 JS files unparseable, all decorator-using
408
+ // framework code. 'decorators-legacy' covers the framework and TypeScript
409
+ // parameter forms; 'decoratorAutoAccessors' adds the modern `accessor` field.
410
+ // The modern 'decorators' variant was rejected: it cannot parse TS parameter
411
+ // decorators, so it would trade one blind spot for another.
412
+ parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
405
413
  plugins: [plugin],
406
414
  ast: false, code: false, babelrc: false, configFile: false,
407
415
  });
package/src/mcp/tools.js CHANGED
@@ -22,8 +22,8 @@ import { verifyLastScan } from '../posture/integrity.js';
22
22
  import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
23
23
  import { redactString, redactFinding } from './redact.js';
24
24
 
25
- // Lazy-loaded: these transitively pull in npm packages (fast-glob,
26
- // @babel/core) that aren't available in the plugin-cache install path
25
+ // Lazy-loaded: these transitively pull in npm packages (@babel/core and
26
+ // friends) that aren't available in the plugin-cache install path
27
27
  // (no node_modules). Deferring keeps the MCP server bootable everywhere;
28
28
  // the import only runs when a tool that needs them is actually called.
29
29
  let _runScan;
@@ -21,12 +21,33 @@ Annotators that run **after** every detector has emitted, plus state stores read
21
21
 
22
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).
23
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
+
24
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`.
25
46
 
26
47
  **Methodology additions (`docs/AGENTIC_METHODOLOGY_PRD.md`)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
27
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.
28
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`.
29
- - `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.
30
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.
31
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`.
32
53
 
@@ -102,19 +123,75 @@ timeout/crash detection, never as the proof signal itself.
102
123
 
103
124
  **The backend is recorded in every evidence object** (`proofEvidence.backend`,
104
125
  e.g. `'userspace'`) because not all confinement backends carry the same
105
- guarantee. The kernel-namespace backend is unverified on this host and, even
106
- where available, only confines network it does **not** confine writes.
107
- Treat `execution-proven` evidence from a non-`userspace` backend as weaker
108
- than the same tier from `userspace` until that backend's write confinement
109
- is independently verified. `attachProofTier()` also enforces the demotion
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
110
138
  guard: `ran:false` can never yield `execution-proven` or `proof-failed`,
111
139
  regardless of what tier was requested — it falls back to the finding's
112
140
  static standing (`proofTierOf`).
113
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
+
114
150
  `proofTier`/`proofEvidence` are copied through `report/index.js`'s
115
151
  `normalizeFindings()` only when the annotator actually attached them —
116
152
  never synthesised at the report layer.
117
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
+
118
195
  ## Scan checkpointing / resume (R8)
119
196
 
120
197
  `scan-checkpoint.js` lets an interrupted scan resume instead of restarting, which
@@ -60,10 +60,13 @@ const PROVES =
60
60
  '(same rule id, severity, file, line, cwe, vuln, and multiplicity) produced by the same ' +
61
61
  'engine version, ruleset version, and bundle — regardless of emission order.';
62
62
  const DOES_NOT_PROVE =
63
- 'It does not prove cross-machine reproducibility: no run on a second machine, OS, or Node ' +
64
- 'version is compared here, and some detectors are environment-sensitive. A signature, when ' +
65
- 'present, is a symmetric per-install HMAC — tamper-evidence for this install, not ' +
66
- 'third-party non-repudiation.';
63
+ 'It does not prove cross-machine reproducibility: this attestation is one run on one machine, ' +
64
+ 'nothing here compares a second machine, OS, or Node version, and some detectors are ' +
65
+ 'environment-sensitive. That property is tested separately by the determinism-attest / ' +
66
+ 'determinism-compare CI jobs, which run the same commit on two operating systems and fail ' +
67
+ 'unless the digests match — evidence about the ENGINE, not about this attestation. ' +
68
+ 'A signature, when present, is a symmetric per-install HMAC — tamper-evidence for this ' +
69
+ 'install, not third-party non-repudiation.';
67
70
 
68
71
  function _str(v) { return v === undefined || v === null ? '' : String(v); }
69
72
 
@@ -0,0 +1,303 @@
1
+ // R2's differentiator — auto-enrol an execution-proven finding as a permanent
2
+ // CVE-replay corpus entry.
3
+ //
4
+ // The compounding asset: a finding that was PROVEN by execution becomes a
5
+ // regression test that the baseline gate defends forever. Every exploit the
6
+ // pipeline proves once, it can never silently stop detecting.
7
+ //
8
+ // THE CENTRAL RULE: nothing is written to the corpus that has not been scored.
9
+ // The v0.106.0 failure — fixtures committed without verifying they actually
10
+ // score, which then broke the gate for everyone — is the exact mistake this
11
+ // module must not automate. So enrolment builds the entry in a TEMPORARY
12
+ // directory, scans `pre/` and `post/` with the same matcher the gate uses
13
+ // (`corpus-match.js`), and moves it into the corpus only on `pre:TP post:TN`.
14
+ // A candidate that does not score is discarded and the reason returned. There
15
+ // is no force flag and no "probably fine" path.
16
+ //
17
+ // WHY A FIX IS MANDATORY. An entry needs a `post/` that scores TN, and the
18
+ // only honest source of one is a real fix. Enrolment therefore refuses a
19
+ // finding with no fixed content rather than synthesising a `post/` by deleting
20
+ // the vulnerable line — that would produce an entry that passes for a reason
21
+ // unrelated to the vulnerability, which is worse than no entry.
22
+ //
23
+ // WHY `capability/` AND NOT `regression/`. `regression/` is the CI-gated tier
24
+ // and graduation into it is a human decision with a stated policy (five
25
+ // consecutive passing snapshots — see bench/cve-replay/CONTRIBUTING.md). An
26
+ // automated writer promoting straight into the gated tier would let a machine
27
+ // decide what blocks everyone's build. New entries land in `capability/`,
28
+ // already passing, and graduate on the existing policy.
29
+ //
30
+ // NOTHING THROWS (posture convention): every path returns
31
+ // `{ok:false, refused:true, reason}` instead.
32
+
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+ import { preHit, postHit, matcherFor } from './corpus-match.js';
37
+
38
+ const DEFAULT_TIER = 'capability';
39
+
40
+ // Entry ids must be safe to use as a directory name and stable across runs.
41
+ const ID_SAFE = /^[A-Za-z0-9._-]+$/;
42
+
43
+ function refuse(reason) { return { ok: false, refused: true, reason }; }
44
+
45
+ // A finding is enrollable only if the pipeline actually RAN its exploit. This
46
+ // re-checks the evidence rather than trusting `proofTier` alone: the tier is a
47
+ // string on an object that may have crossed a process boundary, and the
48
+ // consequence of trusting a forged one is a permanent corpus entry.
49
+ export function isEnrollable(finding) {
50
+ if (!finding || typeof finding !== 'object') return refuse('no finding supplied');
51
+ if (finding.proofTier !== 'execution-proven') {
52
+ return refuse(
53
+ `only execution-proven findings may enrol; this one is '${finding.proofTier || 'untiered'}'. `
54
+ + 'A statically-reasoned finding has not earned a permanent regression entry.',
55
+ );
56
+ }
57
+ const ev = finding.proofEvidence;
58
+ if (!ev || ev.ran !== true) {
59
+ return refuse('proofEvidence does not record a run (ran !== true) — the tier is not backed by evidence');
60
+ }
61
+ if (ev.tier !== 'execution-proven') {
62
+ return refuse(`proofEvidence.tier ('${ev.tier}') disagrees with proofTier — refusing rather than picking one`);
63
+ }
64
+ if (!ev.observed) {
65
+ return refuse('proofEvidence records no observed effect — an execution-proven tier with nothing observed is not evidence');
66
+ }
67
+ return { ok: true };
68
+ }
69
+
70
+ function _slug(s, fallback) {
71
+ const out = String(s || '').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
72
+ return out || fallback;
73
+ }
74
+
75
+ /**
76
+ * The entry id. Derived from the finding so re-enrolling the same finding is
77
+ * idempotent (it will be refused as a duplicate) rather than accumulating
78
+ * near-identical entries.
79
+ */
80
+ export function entryIdFor(finding) {
81
+ const fam = _slug(finding?.family || finding?.cwe, 'finding');
82
+ const sid = _slug(finding?.stableId || finding?.id, 'unknown');
83
+ return `proven-${fam}-${sid}`.slice(0, 120);
84
+ }
85
+
86
+ /**
87
+ * Build the manifest + file map for a candidate entry, without writing it.
88
+ *
89
+ * @param {object} finding an execution-proven finding
90
+ * @param {object} opts
91
+ * @param {object} opts.preFiles rel→content, the VULNERABLE tree
92
+ * @param {object} opts.postFiles rel→content, the FIXED tree
93
+ */
94
+ export function buildCandidate(finding, { preFiles, postFiles, addedAt } = {}) {
95
+ const gate = isEnrollable(finding);
96
+ if (!gate.ok) return gate;
97
+
98
+ if (!finding.cwe) return refuse('finding has no cwe — the manifest matcher would be meaningless');
99
+ if (!finding.vuln) return refuse('finding has no vuln — nothing to match on');
100
+ if (!finding.file) return refuse('finding has no file — cannot name the expected file');
101
+
102
+ const pre = preFiles && typeof preFiles === 'object' ? preFiles : null;
103
+ const post = postFiles && typeof postFiles === 'object' ? postFiles : null;
104
+ if (!pre || !Object.keys(pre).length) return refuse('no pre/ content supplied — nothing to prove the detector fires on');
105
+ if (!post || !Object.keys(post).length) {
106
+ return refuse(
107
+ 'no post/ content supplied. An entry with no fixed tree cannot score post:TN, and '
108
+ + 'synthesising one by deleting the vulnerable code would pass for the wrong reason.',
109
+ );
110
+ }
111
+
112
+ // A `post` identical to `pre` cannot be a fix. Catching it here turns a
113
+ // guaranteed post:FP into a clear refusal.
114
+ const same = Object.keys(pre).length === Object.keys(post).length
115
+ && Object.entries(pre).every(([k, v]) => post[k] === v);
116
+ if (same) return refuse('post/ is byte-identical to pre/ — no fix was applied, so the entry cannot score post:TN');
117
+
118
+ for (const [label, files] of [['pre', pre], ['post', post]]) {
119
+ for (const [rel, content] of Object.entries(files)) {
120
+ if (typeof content !== 'string') return refuse(`${label}/${rel} content is not a string`);
121
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
122
+ return refuse(`${label}/${rel} escapes the entry directory`);
123
+ }
124
+ }
125
+ }
126
+
127
+ const id = entryIdFor(finding);
128
+ if (!ID_SAFE.test(id)) return refuse(`derived entry id '${id}' is not a safe directory name`);
129
+
130
+ const expectedFile = path.basename(String(finding.file));
131
+ if (!Object.keys(pre).some(rel => path.basename(rel) === expectedFile)) {
132
+ return refuse(`the finding's file '${expectedFile}' is not among the pre/ files — the entry would not test the finding`);
133
+ }
134
+
135
+ const manifest = {
136
+ cve: id,
137
+ cwe: finding.cwe,
138
+ family: finding.family || 'unknown',
139
+ language: finding.language || _languageOf(expectedFile),
140
+ summary: `execution-proven ${finding.family || finding.cwe}: ${String(finding.vuln).slice(0, 120)}`,
141
+ expected: {
142
+ file: expectedFile,
143
+ // Match on the exact vuln string this finding carried. A broader regex
144
+ // would let an unrelated detector satisfy the entry.
145
+ vuln_match: _escapeRegex(String(finding.vuln)),
146
+ },
147
+ source: 'execution-proven',
148
+ added_at: addedAt || new Date().toISOString().slice(0, 10),
149
+ provenance: {
150
+ stableId: finding.stableId || null,
151
+ proofBackend: finding.proofEvidence?.backend || null,
152
+ observed: finding.proofEvidence?.observed || null,
153
+ provenAt: finding.proofEvidence?.at || null,
154
+ },
155
+ };
156
+
157
+ return { ok: true, id, manifest, preFiles: pre, postFiles: post };
158
+ }
159
+
160
+ function _escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
161
+
162
+ function _languageOf(file) {
163
+ const ext = path.extname(file).toLowerCase();
164
+ return {
165
+ '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
166
+ '.ts': 'typescript', '.tsx': 'typescript', '.jsx': 'javascript',
167
+ '.py': 'python', '.java': 'java', '.go': 'go', '.rb': 'ruby',
168
+ '.php': 'php', '.cs': 'csharp', '.rs': 'rust',
169
+ }[ext] || 'unknown';
170
+ }
171
+
172
+ function _writeTree(dir, files) {
173
+ for (const [rel, content] of Object.entries(files)) {
174
+ const abs = path.join(dir, rel);
175
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
176
+ fs.writeFileSync(abs, content, 'utf8');
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Materialise a candidate into a staging directory and SCORE it, using the
182
+ * same matcher the corpus gate uses.
183
+ *
184
+ * @param {function} runScan injected so this module stays free of an engine
185
+ * import cycle and so tests can drive it without a full scan.
186
+ * @returns {{ok:boolean, status:string, preHit:boolean, postHit:boolean, reason?:string}}
187
+ */
188
+ async function scoreCandidate(candidate, runScan, { stagingDir } = {}) {
189
+ const dir = stagingDir || fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-cand-'));
190
+ try {
191
+ const preDir = path.join(dir, 'pre');
192
+ const postDir = path.join(dir, 'post');
193
+ fs.mkdirSync(preDir, { recursive: true });
194
+ fs.mkdirSync(postDir, { recursive: true });
195
+ _writeTree(preDir, candidate.preFiles);
196
+ _writeTree(postDir, candidate.postFiles);
197
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(candidate.manifest, null, 2) + '\n', 'utf8');
198
+
199
+ const matcher = matcherFor(candidate.manifest);
200
+ let pre, post;
201
+ try {
202
+ ({ scan: pre } = await runScan(preDir));
203
+ } catch (e) {
204
+ return { ok: false, status: 'scan-error', preHit: false, postHit: false, reason: `pre/ scan failed: ${e.message}` };
205
+ }
206
+ try {
207
+ ({ scan: post } = await runScan(postDir));
208
+ } catch (e) {
209
+ return { ok: false, status: 'scan-error', preHit: false, postHit: false, reason: `post/ scan failed: ${e.message}` };
210
+ }
211
+
212
+ const hitPre = preHit(pre, candidate.manifest, matcher);
213
+ const hitPost = postHit(post, candidate.manifest, matcher);
214
+ const status = `pre:${hitPre ? 'TP' : 'FN'} post:${hitPost ? 'FP' : 'TN'}`;
215
+
216
+ if (!hitPre) {
217
+ return {
218
+ ok: false, status, preHit: hitPre, postHit: hitPost, dir,
219
+ reason: 'the detector does not fire on pre/ — the entry would be committed already failing. '
220
+ + 'A PoC proved this finding at runtime but the minimised fixture does not reproduce it statically.',
221
+ };
222
+ }
223
+ if (hitPost) {
224
+ return {
225
+ ok: false, status, preHit: hitPre, postHit: hitPost, dir,
226
+ reason: 'the detector still fires on post/ — the fix does not clear the finding, so the entry cannot score TN.',
227
+ };
228
+ }
229
+ return { ok: true, status, preHit: hitPre, postHit: hitPost, dir };
230
+ } catch (e) {
231
+ return { ok: false, status: 'error', preHit: false, postHit: false, reason: e.message };
232
+ }
233
+ }
234
+
235
+ /**
236
+ * The full path: gate → build → score → commit.
237
+ *
238
+ * Writes into `<corpusRoot>/<tier>/<id>/` ONLY when the candidate scored
239
+ * `pre:TP post:TN`. Anything else leaves the corpus untouched.
240
+ */
241
+ export async function enrollProvenFinding(finding, {
242
+ corpusRoot, preFiles, postFiles, runScan, tier = DEFAULT_TIER, addedAt, dryRun = false,
243
+ } = {}) {
244
+ if (!corpusRoot) return refuse('no corpusRoot supplied');
245
+ if (typeof runScan !== 'function') return refuse('no runScan supplied — an entry may not be committed unscored');
246
+
247
+ const candidate = buildCandidate(finding, { preFiles, postFiles, addedAt });
248
+ if (!candidate.ok) return candidate;
249
+
250
+ const dest = path.join(corpusRoot, tier, candidate.id);
251
+ if (fs.existsSync(dest)) {
252
+ return refuse(`entry '${candidate.id}' already exists in ${tier}/ — refusing to overwrite a corpus entry`);
253
+ }
254
+
255
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-cand-'));
256
+ try {
257
+ const scored = await scoreCandidate(candidate, runScan, { stagingDir: staging });
258
+ if (!scored.ok) {
259
+ return {
260
+ ok: false, refused: true, id: candidate.id, status: scored.status,
261
+ reason: `not enrolled (${scored.status}): ${scored.reason}`,
262
+ };
263
+ }
264
+ if (dryRun) {
265
+ return { ok: true, dryRun: true, id: candidate.id, status: scored.status, dir: null, manifest: candidate.manifest };
266
+ }
267
+
268
+ // Scan state accumulated inside the staged trees must not be committed —
269
+ // it would be scanned as part of the fixture on the next run.
270
+ _stripState(staging);
271
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
272
+ fs.renameSync(staging, dest);
273
+ return {
274
+ ok: true,
275
+ id: candidate.id,
276
+ tier,
277
+ status: scored.status,
278
+ dir: dest,
279
+ manifest: candidate.manifest,
280
+ // Said explicitly because a caller that stops here leaves the repo in a
281
+ // state where the gate reports a nudge rather than a pass.
282
+ followUp: 'run `npm run bench:cve-replay:update-baseline` and commit the regenerated corpus-baseline.json',
283
+ };
284
+ } catch (e) {
285
+ return refuse(`enrolment failed: ${e.message}`);
286
+ } finally {
287
+ // If the rename happened, staging no longer exists and this is a no-op.
288
+ try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
289
+ }
290
+ }
291
+
292
+ function _stripState(dir) {
293
+ for (const sub of ['pre', 'post']) {
294
+ const s = path.join(dir, sub, '.agentic-security');
295
+ try { fs.rmSync(s, { recursive: true, force: true }); } catch { /* best effort */ }
296
+ }
297
+ }
298
+
299
+ // `scoreCandidate` is deliberately NOT exported: an external caller could
300
+ // score a candidate and then write it by some other route, which is exactly
301
+ // the unscored-write path this module exists to make unavailable. Enrolment
302
+ // scores and writes as one operation or not at all.
303
+ export const _internals = { DEFAULT_TIER, scoreCandidate, _languageOf, _escapeRegex, _stripState };
@@ -0,0 +1,52 @@
1
+ // How a CVE-replay corpus entry is scored against a scan result.
2
+ //
3
+ // Extracted from `bench/cve-replay/runner.mjs` so the corpus GATE and corpus
4
+ // ENROLLMENT (`corpus-enroll.js`) cannot drift apart. That drift is not
5
+ // hypothetical: enrollment only writes an entry it has verified scores
6
+ // `pre:TP post:TN`, and if it verified that with a different matcher than the
7
+ // gate uses, it would cheerfully commit entries that fail CI. One
8
+ // implementation, two callers.
9
+ //
10
+ // THE PRE/POST ASYMMETRY IS DELIBERATE AND PRESERVED VERBATIM. The `pre`
11
+ // matcher accepts a hit on `vuln` OR `family` and regex-tests `cwe`; the
12
+ // `post` matcher is strict on `vuln` and requires an exact `cwe`. This means
13
+ // an entry faces a looser bar to score a TP than an FP, which
14
+ // `bench/cve-replay/CONTRIBUTING.md` records as known imprecision to resolve
15
+ // before the corpus grows toward 500. It is reproduced here rather than
16
+ // quietly fixed: changing it would silently re-verdict entries across the
17
+ // whole committed baseline, which is a corpus migration, not a refactor.
18
+ //
19
+ // The scanner emits into several arrays — `findings` (SAST), `secrets`,
20
+ // `supplyChain` (SCA) and `logicVulns` (business-logic + behavioural) — and a
21
+ // CVE can land in any of them, so all four are consulted.
22
+
23
+ const CHANNELS = ['findings', 'secrets', 'supplyChain', 'logicVulns'];
24
+
25
+ /** The regex an entry's manifest scores with. */
26
+ export function matcherFor(manifest) {
27
+ return new RegExp(manifest?.expected?.vuln_match || manifest?.family || manifest?.cwe || '(?!)', 'i');
28
+ }
29
+
30
+ function _any(scan, predicate) {
31
+ for (const channel of CHANNELS) {
32
+ const arr = scan?.[channel];
33
+ if (Array.isArray(arr) && arr.some(predicate)) return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /** Did the vulnerable (`pre/`) tree produce a matching finding? */
39
+ export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
40
+ return _any(scan, f =>
41
+ (matcher.test(f.vuln || '') || matcher.test(f.family || '')) &&
42
+ (manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true));
43
+ }
44
+
45
+ /** Did the fixed (`post/`) tree still produce a matching finding? */
46
+ export function postHit(scan, manifest, matcher = matcherFor(manifest)) {
47
+ return _any(scan, f =>
48
+ matcher.test(f.vuln || '') &&
49
+ (manifest?.cwe ? f.cwe === manifest.cwe : true));
50
+ }
51
+
52
+ export const _internals = { CHANNELS };