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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -0,0 +1,202 @@
1
+ // R4 — determinism as a contract.
2
+ //
3
+ // Determinism has been an implementation property of this engine (stable sorts,
4
+ // no Date.now() in ids, `--deterministic`) with no way for a third party to
5
+ // VERIFY it. This module turns it into something checkable: a stable digest
6
+ // over the finding set, bound to the engine version, ruleset version, and
7
+ // bundle hash that produced it.
8
+ //
9
+ // WHAT THE DIGEST IS INDEPENDENT OF (by construction):
10
+ // - the ORDER findings were emitted in (entries are canonicalised, then sorted)
11
+ // - run ids, timestamps, durations, and every other field not on the
12
+ // identity allowlist below — canonicalisation is an ALLOWLIST, not a
13
+ // denylist, so a new non-deterministic field cannot silently leak in
14
+ // - path separator style, and the absolute prefix when `root` is supplied
15
+ //
16
+ // WHAT IT IS NOT INDEPENDENT OF (all of these are real differences):
17
+ // - a changed severity, file, line, rule id, cwe, or vuln title
18
+ // - a finding appearing or disappearing (including a duplicate — multiplicity
19
+ // is preserved; two identical findings are not collapsed into one)
20
+ // - the engine version, ruleset version, or bundle sha
21
+ //
22
+ // WHAT THIS PROVES, AND WHAT IT DOES NOT (read before quoting it at anyone):
23
+ // PROVES — two finding sets carrying the same digest under the same
24
+ // canonicalisation are the same set of findings, modulo emission order and
25
+ // the excluded volatile fields; and (when signed) that the attestation was
26
+ // produced on an install holding this HMAC key.
27
+ // DOES NOT PROVE — cross-machine reproducibility. Nothing here demonstrates
28
+ // that a different host, OS, Node version, or toolchain produces the same
29
+ // finding set; several detectors are environment-sensitive (e.g. the Python
30
+ // AST path is used when python3 is available and falls back to regex when it
31
+ // is not, which can change what is found). Two runs on two machines agreeing
32
+ // would be evidence FOR reproducibility, but that experiment has not been
33
+ // run here and this module does not assert its outcome. The signature is a
34
+ // symmetric per-install HMAC, so it is tamper-evidence for the operator, not
35
+ // third-party non-repudiation.
36
+ //
37
+ // Identity fields deliberately EXCLUDE `parser` and `family`: `parser` records
38
+ // which analysis engine fired, which is environment-sensitive (see the python3
39
+ // case above), so including it would make the digest report an environment
40
+ // difference as a findings difference. The finding's identity — where it is
41
+ // and what it is — is fully captured without it.
42
+ //
43
+ // SIGNING: reuses `integrity.js`'s per-install HMAC key handling verbatim
44
+ // (`signLastScan`, keyed from $AGENTIC_SECURITY_HMAC_KEY or the 0600 key file
45
+ // at $XDG_CONFIG_HOME/agentic-security/scan-key). No second key mechanism is
46
+ // introduced. `integrity.verifyLastScan` is NOT reused because it verifies a
47
+ // body against a sibling `.sig` FILE; an attestation carries its signature
48
+ // inline, so verification re-signs and compares in constant time here.
49
+ //
50
+ // NO THROWING (posture/CLAUDE.md convention): malformed input yields an empty
51
+ // canonical set or an `{ok:false, reason}` refusal, never an exception.
52
+
53
+ import * as crypto from 'node:crypto';
54
+ import { signLastScan } from './integrity.js';
55
+
56
+ export const ATTESTATION_CANONICALISATION = 'agentic-security/run-attestation-canon-v1';
57
+
58
+ const PROVES =
59
+ 'Two finding sets with this digest, under this canonicalisation, are the same findings ' +
60
+ '(same rule id, severity, file, line, cwe, vuln, and multiplicity) produced by the same ' +
61
+ 'engine version, ruleset version, and bundle — regardless of emission order.';
62
+ const DOES_NOT_PROVE =
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.';
70
+
71
+ function _str(v) { return v === undefined || v === null ? '' : String(v); }
72
+
73
+ // Normalise a file path: separators to '/', drop a supplied absolute root,
74
+ // drop a leading './'. Everything else is left alone — guessing at a root we
75
+ // were not given would make two genuinely different files collide.
76
+ function _normPath(file, root) {
77
+ let p = _str(file).replace(/\\/g, '/');
78
+ if (root) {
79
+ let r = String(root).replace(/\\/g, '/').replace(/\/+$/, '');
80
+ if (r && p.startsWith(r + '/')) p = p.slice(r.length + 1);
81
+ else if (r && p === r) p = '';
82
+ }
83
+ return p.replace(/^\.\//, '');
84
+ }
85
+
86
+ /**
87
+ * The canonical, order-independent representation of a finding set: one
88
+ * tab-joined record per finding over the identity allowlist, sorted.
89
+ * Multiplicity is preserved (duplicates are NOT deduped).
90
+ */
91
+ function canonicaliseFindings(findings, { root } = {}) {
92
+ const list = Array.isArray(findings) ? findings : [];
93
+ const rows = [];
94
+ for (const f of list) {
95
+ if (!f || typeof f !== 'object') continue;
96
+ rows.push([
97
+ _str(f.id),
98
+ _str(f.severity),
99
+ _normPath(f.file, root),
100
+ _str(f.line),
101
+ _str(f.cwe),
102
+ _str(f.vuln),
103
+ ].join('\t'));
104
+ }
105
+ return rows.sort();
106
+ }
107
+
108
+ /**
109
+ * Compute the run attestation.
110
+ *
111
+ * @param {object[]} findings the finding set (any order)
112
+ * @param {string} engineVersion
113
+ * @param {string} rulesetVersion
114
+ * @param {string} bundleSha
115
+ * @param {string} [root] scan root, used to relativise absolute paths
116
+ * @param {boolean} [sign] attach a per-install HMAC over the digest
117
+ */
118
+ export function computeRunAttestation({
119
+ findings, engineVersion, rulesetVersion, bundleSha, root, sign = false,
120
+ } = {}) {
121
+ const entries = canonicaliseFindings(findings, { root });
122
+ const payload = JSON.stringify({
123
+ canonicalisation: ATTESTATION_CANONICALISATION,
124
+ engineVersion: _str(engineVersion),
125
+ rulesetVersion: _str(rulesetVersion),
126
+ bundleSha: _str(bundleSha),
127
+ findingCount: entries.length,
128
+ entries,
129
+ });
130
+ const digest = crypto.createHash('sha256').update(payload).digest('hex');
131
+ const att = {
132
+ digest,
133
+ algorithm: 'sha256',
134
+ findingCount: entries.length,
135
+ engineVersion: _str(engineVersion),
136
+ rulesetVersion: _str(rulesetVersion),
137
+ bundleSha: _str(bundleSha),
138
+ canonicalisation: ATTESTATION_CANONICALISATION,
139
+ proves: PROVES,
140
+ doesNotProve: DOES_NOT_PROVE,
141
+ };
142
+ if (sign) {
143
+ try {
144
+ att.signature = signLastScan(digest);
145
+ att.signatureScope = 'per-install-hmac';
146
+ } catch { /* signing is best-effort; an unsigned attestation is still valid */ }
147
+ }
148
+ return att;
149
+ }
150
+
151
+ /**
152
+ * Re-derive the attestation from a finding set and compare.
153
+ * @returns {{ok: boolean, reason: string}}
154
+ */
155
+ export function verifyRunAttestation(attestation, inputs = {}) {
156
+ if (!attestation || typeof attestation !== 'object') {
157
+ return { ok: false, reason: 'no attestation supplied' };
158
+ }
159
+ if (attestation.canonicalisation !== ATTESTATION_CANONICALISATION) {
160
+ return {
161
+ ok: false,
162
+ reason: `unknown canonicalisation "${_str(attestation.canonicalisation)}" — ` +
163
+ `this build verifies "${ATTESTATION_CANONICALISATION}" only`,
164
+ };
165
+ }
166
+ if (typeof attestation.digest !== 'string' || !/^[0-9a-f]{64}$/.test(attestation.digest)) {
167
+ return { ok: false, reason: 'attestation carries no sha256 digest' };
168
+ }
169
+ for (const k of ['engineVersion', 'rulesetVersion', 'bundleSha']) {
170
+ if (_str(inputs[k]) !== _str(attestation[k])) {
171
+ return { ok: false, reason: `${k} mismatch: attested "${_str(attestation[k])}", got "${_str(inputs[k])}"` };
172
+ }
173
+ }
174
+ const recomputed = computeRunAttestation({
175
+ findings: inputs.findings,
176
+ engineVersion: inputs.engineVersion,
177
+ rulesetVersion: inputs.rulesetVersion,
178
+ bundleSha: inputs.bundleSha,
179
+ root: inputs.root,
180
+ });
181
+ if (recomputed.findingCount !== attestation.findingCount) {
182
+ return {
183
+ ok: false,
184
+ reason: `finding count mismatch: attested ${attestation.findingCount}, got ${recomputed.findingCount}`,
185
+ };
186
+ }
187
+ if (recomputed.digest !== attestation.digest) {
188
+ return { ok: false, reason: 'digest mismatch — the finding set is not the one attested' };
189
+ }
190
+ if (attestation.signature) {
191
+ let expected;
192
+ try { expected = signLastScan(attestation.digest); }
193
+ catch { return { ok: false, reason: 'signature present but no key material available to check it' }; }
194
+ const a = Buffer.from(String(attestation.signature), 'hex');
195
+ const b = Buffer.from(expected, 'hex');
196
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
197
+ return { ok: false, reason: 'signature does not verify under this install key' };
198
+ }
199
+ return { ok: true, reason: 'digest and per-install signature verify' };
200
+ }
201
+ return { ok: true, reason: 'digest verifies (unsigned attestation)' };
202
+ }
@@ -161,11 +161,20 @@ export function evaluateFramework(scanRoot, fw, scan) {
161
161
  'mcp-tools': '.../scanner/src/mcp/tools.js',
162
162
  };
163
163
  const target = ARTIFACT[mod];
164
- if (target && fs.existsSync(path.join(scanRoot, STATE, target))) {
165
- obs.push(`✓ ${mod}: ${target} present.`);
164
+ // A '.../' sentinel marks a source-relative artifact (project source,
165
+ // e.g. a hook or agent file) — resolve it against the scan root itself.
166
+ // Everything else is a runtime artifact under the STATE dir. Without
167
+ // this, `path.join(scanRoot, STATE, '.../x')` never resolves and the
168
+ // control falsely reads "not present" for every project.
169
+ const resolved = !target ? null
170
+ : target.startsWith('.../') ? path.join(scanRoot, target.slice(4))
171
+ : path.join(scanRoot, STATE, target);
172
+ const label = target ? target.replace(/^\.\.\.\//, '') : '(unmapped)';
173
+ if (resolved && fs.existsSync(resolved)) {
174
+ obs.push(`✓ ${mod}: ${label} present.`);
166
175
  anySignal = true;
167
176
  } else {
168
- obs.push(`✗ ${mod}: expected ${target || '(unmapped)'} not present.`);
177
+ obs.push(`✗ ${mod}: expected ${label} not present.`);
169
178
  allCleared = false;
170
179
  }
171
180
  } else if (m.startsWith('rule:')) {
@@ -39,7 +39,7 @@
39
39
 
40
40
  import * as fs from 'node:fs';
41
41
  import * as path from 'node:path';
42
- import * as yaml from 'js-yaml';
42
+ import * as yaml from '../util/yaml.js';
43
43
 
44
44
  const POLICY_FILE = 'compliance.policy.yml';
45
45
 
@@ -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 };
@@ -16,7 +16,7 @@ import { isChainWorthy, familyForBoundary } from './cross-lang-meta.js';
16
16
  // Out of scope (deferred to a follow-up): gRPC .proto introspection, GraphQL
17
17
  // resolver-to-resolver tracking, SQL/ORM round-trip, message queues.
18
18
 
19
- import * as yaml from 'js-yaml';
19
+ import * as yaml from '../util/yaml.js';
20
20
 
21
21
  function loadOpenAPI(fileContents) {
22
22
  for (const [fp, c] of Object.entries(fileContents || {})) {
@@ -29,8 +29,8 @@
29
29
 
30
30
  import * as fs from 'node:fs';
31
31
  import * as path from 'node:path';
32
- import * as yaml from 'js-yaml';
33
- import fg from 'fast-glob';
32
+ import * as yaml from '../util/yaml.js';
33
+ import { globFiles } from '../util/glob.js';
34
34
  import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
35
35
 
36
36
  const LANG_EXTS = {
@@ -337,7 +337,7 @@ export async function runRuleTests(scanRoot, fixtureGlob) {
337
337
  console.log(`No custom rules found in ${rulesDir(scanRoot)}`);
338
338
  return { ok: true, rules: 0, fired: 0 };
339
339
  }
340
- const files = await fg(fixtureGlob, { dot: false, onlyFiles: true });
340
+ const files = await globFiles(fixtureGlob);
341
341
  console.log(`Loaded ${rules.length} rule(s); testing against ${files.length} file(s).\n`);
342
342
  let fired = 0;
343
343
  for (const fp of files) {