@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.
- package/CHANGELOG.md +122 -0
- package/bin/agentic-security.js +19 -2
- package/dist/113.index.js +292 -3
- package/dist/207.index.js +7 -4
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +292 -3
- package/dist/agentic-security.mjs +23 -62
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -9
- package/src/engine.js +16 -0
- package/src/ir/parser-js.js +8 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +83 -6
- package/src/posture/attestation.js +7 -4
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +52 -0
- package/src/posture/custom-rules.js +2 -2
- package/src/posture/execution-proof.js +44 -4
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +76 -2
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/runScan.js +2 -6
- package/src/sandbox/CLAUDE.md +168 -46
- package/src/sandbox/backend-namespace.js +292 -40
- package/src/sandbox/backend-userspace.js +2 -19
- package/src/sandbox/capabilities.js +132 -4
- package/src/sandbox/limits.js +21 -0
- package/src/sandbox/result.js +1 -1
- package/src/util/glob.js +173 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
61
|
-
"
|
|
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,
|
package/src/ir/parser-js.js
CHANGED
|
@@ -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 (
|
|
26
|
-
//
|
|
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;
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -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.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
64
|
-
'
|
|
65
|
-
'
|
|
66
|
-
'
|
|
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 };
|