@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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 (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  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 +5 -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 +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  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 +262 -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/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -0,0 +1,317 @@
1
+ // Accuracy scorecard (roadmap R3) — aggregation + rendering.
2
+ //
3
+ // WHY THIS LIVES IN src/posture/ RATHER THAN ENTIRELY IN scripts/:
4
+ // every figure this project publishes has to be re-derivable and testable.
5
+ // The aggregation and the rendering are pure functions of their inputs, so
6
+ // they belong where the unit-test suite already runs (tests execute against
7
+ // `src/` directly). The thin driver at `scripts/scorecard.mjs` does the
8
+ // impure half — running the corpus, running the self-scan, reading the
9
+ // bundle hash and the git commit — and hands the results here. That split is
10
+ // what makes the "hand-computed fixture" test in
11
+ // `test/accuracy-scorecard.test.js` possible at all.
12
+ //
13
+ // INTEGRITY CONTRACT (the reason the item exists — read before editing):
14
+ // · Nothing here invents a rate. Every rate is carried as {n, d} and
15
+ // rendered through formatRate(), which always prints the numerator and
16
+ // the denominator alongside the percentage. A denominator of 0 renders
17
+ // "0/0 (n/a)" — never 0% and never 100%.
18
+ // · Entries the run could not score (parser unavailable, scan error) are
19
+ // removed from every denominator AND disclosed by name. Scoring them
20
+ // either way would manufacture a number out of an environment fault.
21
+ // · No F1 is emitted. F1 needs a precision measured over a labelled
22
+ // real-world population; this corpus is curated known-vulnerable
23
+ // fixtures and their fixed counterparts, so its "precision" would be
24
+ // computed over a denominator that does not mean what the word implies.
25
+ // See renderScorecardMarkdown()'s methodology section.
26
+ // · Output is a deterministic function of the inputs apart from one
27
+ // timestamp line (TIMESTAMP_MARKER), so the document regenerates
28
+ // byte-identically on an unchanged tree.
29
+
30
+ // The single line of the rendered document that is permitted to vary between
31
+ // two runs on an unchanged tree. Tests and the regeneration check key on it.
32
+ export const TIMESTAMP_MARKER = 'Generated (UTC)';
33
+
34
+ /**
35
+ * Render a rate with its raw counts. This is the ONLY sanctioned way to put a
36
+ * percentage into the scorecard: a bare percentage with no visible
37
+ * denominator is exactly the kind of unfalsifiable claim this document exists
38
+ * to avoid.
39
+ */
40
+ export function formatRate(n, d) {
41
+ if (!d) return `${n}/${d} (n/a)`;
42
+ return `${n}/${d} (${((n / d) * 100).toFixed(1)}%)`;
43
+ }
44
+
45
+ function emptyBucket(key) {
46
+ return { key, entries: 0, detection: { n: 0, d: 0 }, silence: { n: 0, d: 0 } };
47
+ }
48
+
49
+ function accumulate(bucket, status) {
50
+ bucket.entries++;
51
+ if (status.includes('pre:TP')) { bucket.detection.n++; bucket.detection.d++; }
52
+ else if (status.includes('pre:FN')) { bucket.detection.d++; }
53
+ if (status.includes('post:TN')) { bucket.silence.n++; bucket.silence.d++; }
54
+ else if (status.includes('post:FP')) { bucket.silence.d++; }
55
+ }
56
+
57
+ function sliceBy(scored, key) {
58
+ const map = new Map();
59
+ for (const d of scored) {
60
+ const k = d[key] || 'unknown';
61
+ if (!map.has(k)) map.set(k, emptyBucket(k));
62
+ accumulate(map.get(k), d.status);
63
+ }
64
+ // Sorted by key so the document is stable across runs.
65
+ return [...map.values()].sort((a, b) => a.key.localeCompare(b.key));
66
+ }
67
+
68
+ /**
69
+ * Aggregate the per-entry detail records emitted by the corpus runner.
70
+ *
71
+ * Input records: { cve, tier, cwe, language, status, error? } where status is
72
+ * the runner's own string form, e.g. "pre:TP post:TN", "pre:FN", "env-error".
73
+ */
74
+ export function aggregateCorpus(detail) {
75
+ const all = [...(detail || [])];
76
+ const notScored = all
77
+ .filter(d => !d.status || !d.status.startsWith('pre:'))
78
+ .map(d => ({ cve: d.cve, tier: d.tier || null, language: d.language || null, status: d.status || 'unknown', error: d.error || null }))
79
+ .sort((a, b) => String(a.cve).localeCompare(String(b.cve)));
80
+ const scored = all.filter(d => d.status && d.status.startsWith('pre:'));
81
+
82
+ const overall = emptyBucket('overall');
83
+ for (const d of scored) accumulate(overall, d.status);
84
+
85
+ return {
86
+ totalEntries: all.length,
87
+ scoredEntries: scored.length,
88
+ notScored,
89
+ overall: { detection: overall.detection, silence: overall.silence },
90
+ byLanguage: sliceBy(scored, 'language'),
91
+ byCwe: sliceBy(scored, 'cwe'),
92
+ byTier: sliceBy(scored, 'tier'),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Build the machine-readable scorecard model. `inputs`:
98
+ * provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt }
99
+ * corpusDetail per-entry records from a corpus run performed THIS run
100
+ * selfScan { targets: {name:{total,byFile}}, polyglot: {total,byLanguage} }
101
+ * — measured THIS run
102
+ * committed { corpusBaseline, proofCorpus } — read from committed files,
103
+ * labelled as such in the output, never used to derive a rate
104
+ */
105
+ export function buildScorecard(inputs) {
106
+ const corpus = aggregateCorpus(inputs.corpusDetail);
107
+ const selfScan = inputs.selfScan || {};
108
+ const targets = {};
109
+ for (const k of Object.keys(selfScan.targets || {}).sort()) {
110
+ targets[k] = { total: selfScan.targets[k].total, byFile: selfScan.targets[k].byFile || {} };
111
+ }
112
+ const committed = inputs.committed || {};
113
+ const proof = committed.proofCorpus || null;
114
+ return {
115
+ schema: 'agentic-security/accuracy-scorecard@1',
116
+ provenance: { ...inputs.provenance },
117
+ methodology: {
118
+ population: 'curated known-vulnerable fixtures (pre/) and their fixed counterparts (post/)',
119
+ detectionRateMeans: 'share of known-vulnerable fixtures where the expected finding fired',
120
+ silenceRateMeans: 'share of fixed counterparts where the same finding correctly did not fire',
121
+ notGeneralRecall: true,
122
+ notGeneralFalsePositiveRate: true,
123
+ f1Emitted: false,
124
+ f1OmissionReason: 'no labelled real-world population is available to measure precision over, so any F1 would combine denominators that do not describe the same population',
125
+ },
126
+ corpus: {
127
+ measuredThisRun: true,
128
+ totalEntries: corpus.totalEntries,
129
+ scoredEntries: corpus.scoredEntries,
130
+ notScored: corpus.notScored,
131
+ overall: corpus.overall,
132
+ byLanguage: corpus.byLanguage,
133
+ byCwe: corpus.byCwe,
134
+ byTier: corpus.byTier,
135
+ },
136
+ selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
137
+ committedInputs: {
138
+ corpusBaseline: committed.corpusBaseline
139
+ ? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
140
+ : null,
141
+ proofCorpus: proof
142
+ ? {
143
+ source: 'bench/proof-corpus/results/summary.json',
144
+ bundleSha: proof.bundleSha || null,
145
+ targetCount: proof.targetCount ?? null,
146
+ ok: proof.ok ?? null,
147
+ failed: proof.failed ?? null,
148
+ targets: (proof.targets || []).map(t => ({
149
+ id: t.id, commit: t.commit, status: t.status,
150
+ filesInScope: t.coverage?.totals?.inScope ?? null,
151
+ filesParsed: t.coverage?.totals?.parsed ?? null,
152
+ determinismChecked: t.determinism?.checked ?? null,
153
+ determinismIdentical: t.determinism?.identical ?? null,
154
+ resultsEmitted: t.determinism?.results ?? null,
155
+ })).sort((a, b) => String(a.id).localeCompare(String(b.id))),
156
+ }
157
+ : null,
158
+ },
159
+ };
160
+ }
161
+
162
+ function rateRow(r) {
163
+ return `| ${r.key} | ${r.entries} | ${formatRate(r.detection.n, r.detection.d)} | ${formatRate(r.silence.n, r.silence.d)} |`;
164
+ }
165
+
166
+ /** Render the human-readable scorecard. Deterministic apart from one line. */
167
+ export function renderScorecardMarkdown(m) {
168
+ const p = m.provenance || {};
169
+ const c = m.corpus;
170
+ const L = [];
171
+ L.push('# Accuracy scorecard');
172
+ L.push('');
173
+ L.push('Generated by `npm run scorecard`. Every figure below comes from a run');
174
+ L.push('performed by that command, except where a line is explicitly labelled');
175
+ L.push('*committed artifact* — those carry the timestamp and commit of the run');
176
+ L.push('that produced them.');
177
+ L.push('');
178
+ L.push('## Provenance');
179
+ L.push('');
180
+ L.push('| Field | Value |');
181
+ L.push('| --- | --- |');
182
+ L.push(`| Engine version | ${p.engineVersion || 'unknown'} |`);
183
+ L.push(`| Bundle SHA-256 | \`${p.bundleSha256 || 'unknown'}\` |`);
184
+ L.push(`| Commit | \`${p.commit || 'unknown'}\` |`);
185
+ if (p.worktreeClean !== undefined) {
186
+ L.push(`| Worktree at measurement time | ${p.worktreeClean ? 'clean' : 'DIRTY — the commit above does not fully describe what was measured'} |`);
187
+ }
188
+ L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
189
+ L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
190
+ L.push(`| ${TIMESTAMP_MARKER} | ${p.generatedAt || 'unknown'} |`);
191
+ L.push('');
192
+ L.push('## What these numbers are, and what they are not');
193
+ L.push('');
194
+ L.push('The population is a curated corpus of known-vulnerable code fixtures');
195
+ L.push('(`pre/`) each paired with its fixed counterpart (`post/`). Two rates are');
196
+ L.push('reported, always with their raw numerator and denominator:');
197
+ L.push('');
198
+ L.push('- **Detection rate** — of the vulnerable fixtures, the share where the');
199
+ L.push(' expected finding fired. This is **not** general-purpose recall against');
200
+ L.push(' real-world code: the corpus is curated, each entry was added because it');
201
+ L.push(' represents a class worth covering, and nothing here samples the');
202
+ L.push(' distribution of vulnerabilities in arbitrary repositories.');
203
+ L.push('- **Correct-silence rate** — of the fixed counterparts, the share where');
204
+ L.push(' the same finding correctly did not fire. This is **not** a general');
205
+ L.push(' false-positive rate: the denominator is one narrowly-scoped fixed file');
206
+ L.push(' per entry, not a population of real code in which any rule could');
207
+ L.push(' misfire.');
208
+ L.push('');
209
+ L.push('The corpus is also **gated**: an entry is admitted only once it scores');
210
+ L.push('detected-on-`pre` and silent-on-`post`, and a committed baseline fails the');
211
+ L.push('build on any drift. A detection rate at the ceiling is therefore expected');
212
+ L.push('by construction — it is evidence that nothing regressed, not evidence that');
213
+ L.push('there is no headroom. What moves it is adding entries for classes not yet');
214
+ L.push('covered, which is why the corpus size is reported alongside the rate.');
215
+ L.push('');
216
+ L.push('**No F1 is reported, deliberately.** An F1 requires a precision measured');
217
+ L.push('over a labelled real-world population. This project does not have one, so');
218
+ L.push('a precision computed from the corpus alone would divide by a denominator');
219
+ L.push('that does not describe the population the word implies. The precision-side');
220
+ L.push('signal reported instead is the self-scan section below: exact finding');
221
+ L.push('counts on this repository\'s own non-fixture source, where every count is');
222
+ L.push('reviewed by hand and any movement is a real change. Publishing a number we');
223
+ L.push('cannot defend would cost more credibility than the number is worth.');
224
+ L.push('');
225
+ L.push('## Corpus results (measured this run)');
226
+ L.push('');
227
+ L.push('| Population | Detected / correctly silent |');
228
+ L.push('| --- | --- |');
229
+ L.push(`| Vulnerable fixtures (\`pre/\`) — detection | ${formatRate(c.overall.detection.n, c.overall.detection.d)} |`);
230
+ L.push(`| Fixed counterparts (\`post/\`) — correct silence | ${formatRate(c.overall.silence.n, c.overall.silence.d)} |`);
231
+ L.push('');
232
+ if (c.notScored.length) {
233
+ const word = c.notScored.length === 1 ? 'entry' : 'entries';
234
+ L.push('**' + c.notScored.length + ' corpus ' + word + ' could not be scored.** Excluded from every');
235
+ L.push('denominator above rather than counted as a miss:');
236
+ L.push('');
237
+ for (const n of c.notScored) {
238
+ const why = n.error ? ' — ' + n.error : '';
239
+ L.push('- `' + n.cve + '` (' + (n.language || 'unknown') + '): ' + n.status + why);
240
+ }
241
+ L.push('');
242
+ } else {
243
+ L.push('All corpus entries scored; no entry was excluded.');
244
+ L.push('');
245
+ }
246
+ L.push('### By language');
247
+ L.push('');
248
+ L.push('| Language | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
249
+ L.push('| --- | --- | --- | --- |');
250
+ for (const r of c.byLanguage) L.push(rateRow(r));
251
+ L.push('');
252
+ L.push('### By CWE');
253
+ L.push('');
254
+ L.push('| CWE | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
255
+ L.push('| --- | --- | --- | --- |');
256
+ for (const r of c.byCwe) L.push(rateRow(r));
257
+ L.push('');
258
+ L.push('### By corpus tier');
259
+ L.push('');
260
+ L.push('| Tier | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
261
+ L.push('| --- | --- | --- | --- |');
262
+ for (const r of c.byTier) L.push(rateRow(r));
263
+ L.push('');
264
+ L.push('## Precision-side signal: self-scan (measured this run)');
265
+ L.push('');
266
+ L.push('The engine scanned this repository\'s own hand-reviewed source. These are');
267
+ L.push('absolute finding counts, not a rate — there is no labelled ground truth');
268
+ L.push('over this code, so no precision figure is derived from it. What it');
269
+ L.push('supports is a movement claim: any change in these counts between');
270
+ L.push('releases is a real change in what the engine reports on unchanged code.');
271
+ L.push('');
272
+ L.push('| Target | Findings |');
273
+ L.push('| --- | --- |');
274
+ for (const [k, v] of Object.entries(m.selfScan.targets)) L.push(`| \`${k}\` | ${v.total} |`);
275
+ L.push(`| \`polyglot\` fixture (expected 0) | ${m.selfScan.polyglot.total} |`);
276
+ L.push('');
277
+ L.push('Per-file counts are in `docs/scorecard.json`.');
278
+ L.push('');
279
+ L.push('## Committed artifacts referenced (not re-run by this command)');
280
+ L.push('');
281
+ const cb = m.committedInputs.corpusBaseline;
282
+ if (cb) {
283
+ L.push(`- **Corpus baseline** (*committed artifact*, \`${cb.source}\`, generated ${cb.generatedAt}):`);
284
+ L.push(` ${cb.passing}/${cb.total} entries recorded as passing. The gate`);
285
+ L.push(' `npm run bench:cve-replay:check` fails the build on any drift from it.');
286
+ L.push(' The rates above are computed from this run, not from this file.');
287
+ }
288
+ const pc = m.committedInputs.proofCorpus;
289
+ if (pc) {
290
+ L.push(`- **Third-party repository run** (*committed artifact*, \`${pc.source}\`,`);
291
+ L.push(` bundle \`${pc.bundleSha}\`): ${pc.ok}/${pc.targetCount} targets completed.`);
292
+ L.push(' Reported for scale and parse coverage only — these repositories have no');
293
+ L.push(' vulnerability ground truth, so no accuracy rate is derived from them.');
294
+ L.push('');
295
+ L.push('| Repository | Commit | Status | Files parsed / in scope | Results emitted | Deterministic re-run |');
296
+ L.push('| --- | --- | --- | --- | --- | --- |');
297
+ for (const t of pc.targets) {
298
+ const det = t.determinismChecked ? (t.determinismIdentical ? 'identical' : 'checked, not byte-identical') : 'not checked';
299
+ L.push(`| ${t.id} | \`${String(t.commit || '').slice(0, 12)}\` | ${t.status} | ${formatRate(t.filesParsed ?? 0, t.filesInScope ?? 0)} | ${t.resultsEmitted ?? 'n/a'} | ${det} |`);
300
+ }
301
+ }
302
+ L.push('');
303
+ L.push('## Reproducing any figure here');
304
+ L.push('');
305
+ L.push('| Figure | Command |');
306
+ L.push('| --- | --- |');
307
+ L.push('| Detection / correct-silence, and every slice | `node bench/cve-replay/runner.mjs --json` |');
308
+ L.push('| Corpus drift gate | `npm run bench:cve-replay:check` |');
309
+ L.push('| Self-scan counts | `node bench/self-scan/measure.mjs --json` |');
310
+ L.push('| Self-scan drift gate | `npm run bench:self-scan:check` |');
311
+ L.push('| This whole document | `npm run scorecard` |');
312
+ L.push('');
313
+ L.push('Running `npm run scorecard` twice on an unchanged tree produces an');
314
+ L.push('identical document apart from the generated-timestamp row above.');
315
+ L.push('');
316
+ return L.join('\n');
317
+ }
@@ -19,7 +19,7 @@
19
19
 
20
20
  import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
- import * as yaml from 'js-yaml';
22
+ import * as yaml from '../util/yaml.js';
23
23
 
24
24
  const CONTRACT_FILE_PATTERNS = [
25
25
  { glob: /(?:openapi|swagger)\.(?:ya?ml|json)$/i, kind: 'openapi' },
@@ -0,0 +1,199 @@
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: 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.';
67
+
68
+ function _str(v) { return v === undefined || v === null ? '' : String(v); }
69
+
70
+ // Normalise a file path: separators to '/', drop a supplied absolute root,
71
+ // drop a leading './'. Everything else is left alone — guessing at a root we
72
+ // were not given would make two genuinely different files collide.
73
+ function _normPath(file, root) {
74
+ let p = _str(file).replace(/\\/g, '/');
75
+ if (root) {
76
+ let r = String(root).replace(/\\/g, '/').replace(/\/+$/, '');
77
+ if (r && p.startsWith(r + '/')) p = p.slice(r.length + 1);
78
+ else if (r && p === r) p = '';
79
+ }
80
+ return p.replace(/^\.\//, '');
81
+ }
82
+
83
+ /**
84
+ * The canonical, order-independent representation of a finding set: one
85
+ * tab-joined record per finding over the identity allowlist, sorted.
86
+ * Multiplicity is preserved (duplicates are NOT deduped).
87
+ */
88
+ function canonicaliseFindings(findings, { root } = {}) {
89
+ const list = Array.isArray(findings) ? findings : [];
90
+ const rows = [];
91
+ for (const f of list) {
92
+ if (!f || typeof f !== 'object') continue;
93
+ rows.push([
94
+ _str(f.id),
95
+ _str(f.severity),
96
+ _normPath(f.file, root),
97
+ _str(f.line),
98
+ _str(f.cwe),
99
+ _str(f.vuln),
100
+ ].join('\t'));
101
+ }
102
+ return rows.sort();
103
+ }
104
+
105
+ /**
106
+ * Compute the run attestation.
107
+ *
108
+ * @param {object[]} findings the finding set (any order)
109
+ * @param {string} engineVersion
110
+ * @param {string} rulesetVersion
111
+ * @param {string} bundleSha
112
+ * @param {string} [root] scan root, used to relativise absolute paths
113
+ * @param {boolean} [sign] attach a per-install HMAC over the digest
114
+ */
115
+ export function computeRunAttestation({
116
+ findings, engineVersion, rulesetVersion, bundleSha, root, sign = false,
117
+ } = {}) {
118
+ const entries = canonicaliseFindings(findings, { root });
119
+ const payload = JSON.stringify({
120
+ canonicalisation: ATTESTATION_CANONICALISATION,
121
+ engineVersion: _str(engineVersion),
122
+ rulesetVersion: _str(rulesetVersion),
123
+ bundleSha: _str(bundleSha),
124
+ findingCount: entries.length,
125
+ entries,
126
+ });
127
+ const digest = crypto.createHash('sha256').update(payload).digest('hex');
128
+ const att = {
129
+ digest,
130
+ algorithm: 'sha256',
131
+ findingCount: entries.length,
132
+ engineVersion: _str(engineVersion),
133
+ rulesetVersion: _str(rulesetVersion),
134
+ bundleSha: _str(bundleSha),
135
+ canonicalisation: ATTESTATION_CANONICALISATION,
136
+ proves: PROVES,
137
+ doesNotProve: DOES_NOT_PROVE,
138
+ };
139
+ if (sign) {
140
+ try {
141
+ att.signature = signLastScan(digest);
142
+ att.signatureScope = 'per-install-hmac';
143
+ } catch { /* signing is best-effort; an unsigned attestation is still valid */ }
144
+ }
145
+ return att;
146
+ }
147
+
148
+ /**
149
+ * Re-derive the attestation from a finding set and compare.
150
+ * @returns {{ok: boolean, reason: string}}
151
+ */
152
+ export function verifyRunAttestation(attestation, inputs = {}) {
153
+ if (!attestation || typeof attestation !== 'object') {
154
+ return { ok: false, reason: 'no attestation supplied' };
155
+ }
156
+ if (attestation.canonicalisation !== ATTESTATION_CANONICALISATION) {
157
+ return {
158
+ ok: false,
159
+ reason: `unknown canonicalisation "${_str(attestation.canonicalisation)}" — ` +
160
+ `this build verifies "${ATTESTATION_CANONICALISATION}" only`,
161
+ };
162
+ }
163
+ if (typeof attestation.digest !== 'string' || !/^[0-9a-f]{64}$/.test(attestation.digest)) {
164
+ return { ok: false, reason: 'attestation carries no sha256 digest' };
165
+ }
166
+ for (const k of ['engineVersion', 'rulesetVersion', 'bundleSha']) {
167
+ if (_str(inputs[k]) !== _str(attestation[k])) {
168
+ return { ok: false, reason: `${k} mismatch: attested "${_str(attestation[k])}", got "${_str(inputs[k])}"` };
169
+ }
170
+ }
171
+ const recomputed = computeRunAttestation({
172
+ findings: inputs.findings,
173
+ engineVersion: inputs.engineVersion,
174
+ rulesetVersion: inputs.rulesetVersion,
175
+ bundleSha: inputs.bundleSha,
176
+ root: inputs.root,
177
+ });
178
+ if (recomputed.findingCount !== attestation.findingCount) {
179
+ return {
180
+ ok: false,
181
+ reason: `finding count mismatch: attested ${attestation.findingCount}, got ${recomputed.findingCount}`,
182
+ };
183
+ }
184
+ if (recomputed.digest !== attestation.digest) {
185
+ return { ok: false, reason: 'digest mismatch — the finding set is not the one attested' };
186
+ }
187
+ if (attestation.signature) {
188
+ let expected;
189
+ try { expected = signLastScan(attestation.digest); }
190
+ catch { return { ok: false, reason: 'signature present but no key material available to check it' }; }
191
+ const a = Buffer.from(String(attestation.signature), 'hex');
192
+ const b = Buffer.from(expected, 'hex');
193
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
194
+ return { ok: false, reason: 'signature does not verify under this install key' };
195
+ }
196
+ return { ok: true, reason: 'digest and per-install signature verify' };
197
+ }
198
+ return { ok: true, reason: 'digest verifies (unsigned attestation)' };
199
+ }
@@ -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
 
@@ -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,7 +29,7 @@
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';
32
+ import * as yaml from '../util/yaml.js';
33
33
  import fg from 'fast-glob';
34
34
  import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
35
35