@clear-capabilities/agentic-security-scanner 0.148.1 → 0.148.4

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 (33) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/bin/agentic-security.js +5 -1
  3. package/dist/4970.index.js +108 -2
  4. package/dist/agentic-security.mjs +2 -2
  5. package/dist/agentic-security.mjs.sha256 +1 -1
  6. package/dist/compliance-frameworks/ccpa.json +2 -0
  7. package/dist/compliance-frameworks/eu-ai-act.json +12 -16
  8. package/dist/compliance-frameworks/gdpr.json +8 -6
  9. package/dist/compliance-frameworks/hipaa-security-rule.json +9 -9
  10. package/dist/compliance-frameworks/nist-800-171-r3.json +6 -5
  11. package/dist/compliance-frameworks/nist-ai-600-1.json +6 -4
  12. package/dist/compliance-frameworks/nist-csf-2.json +6 -5
  13. package/dist/compliance-frameworks/nist-privacy-1-1.json +20 -10
  14. package/dist/compliance-frameworks/owasp-asvs-5.json +2 -0
  15. package/dist/compliance-frameworks/owasp-llm-top-10.json +10 -11
  16. package/package.json +4 -3
  17. package/src/engine.js +46 -14
  18. package/src/pipeline/assurance-mode.js +108 -2
  19. package/src/posture/accuracy-scorecard.js +34 -0
  20. package/src/posture/aibom.js +22 -0
  21. package/src/posture/artifact-registry.js +2 -0
  22. package/src/posture/auditor-walkthrough.js +91 -35
  23. package/src/posture/compliance-frameworks/ccpa.json +2 -0
  24. package/src/posture/compliance-frameworks/eu-ai-act.json +12 -16
  25. package/src/posture/compliance-frameworks/gdpr.json +8 -6
  26. package/src/posture/compliance-frameworks/hipaa-security-rule.json +9 -9
  27. package/src/posture/compliance-frameworks/nist-800-171-r3.json +6 -5
  28. package/src/posture/compliance-frameworks/nist-ai-600-1.json +6 -4
  29. package/src/posture/compliance-frameworks/nist-csf-2.json +6 -5
  30. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +20 -10
  31. package/src/posture/compliance-frameworks/owasp-asvs-5.json +2 -0
  32. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +10 -11
  33. package/src/posture/verifier.js +70 -0
@@ -51,6 +51,112 @@ function _isValidMode(mode) {
51
51
  return ASSURANCE_MODES.includes(mode);
52
52
  }
53
53
 
54
+ // A real user hit this: `agentic-security ci <a directory downloaded as a
55
+ // GitHub zip, no .git present> --assurance strict` failed with the bare
56
+ // count this function used to produce alone — "1210 finding(s) have status
57
+ // outside [complete, uncommitted]" — with no indication that all 1210
58
+ // findings failed for the exact same, simple, fixable reason
59
+ // (`coordinator.js`'s `annotateGitProvenance` already knows and records it,
60
+ // in `finding.findingProvenance.limitations[0]`, but that reason never
61
+ // reached this message). A user reading "1210 problems" reasonably assumes
62
+ // their CODE has 1210 problems, not that their DIRECTORY isn't a git
63
+ // repository. This surfaces the dominant recorded reason instead of a bare
64
+ // count, and gives the two most common, fully-fixable reasons ("not a Git
65
+ // repository" from a zip download instead of `git clone`; a shallow CI
66
+ // checkout) a one-line, specific remedy — the same specificity the
67
+ // scanHealth branch above already gives for a stale-EPSS-cache failure.
68
+ function _provenanceFailureReason(badProvenance, totalFindings) {
69
+ const counts = new Map();
70
+ for (const f of badProvenance) {
71
+ const reason = f?.findingProvenance?.limitations?.[0] || f?.findingProvenance?.status || 'unknown';
72
+ counts.set(reason, (counts.get(reason) || 0) + 1);
73
+ }
74
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
75
+ const base = `strict mode requires complete finding provenance; ${badProvenance.length}/${totalFindings} finding(s) have status outside [complete, uncommitted]`;
76
+
77
+ const gitReasons = ranked.filter(([r]) => r === 'not a Git repository' || r === 'repository state unavailable');
78
+ const gitCount = gitReasons.reduce((s, [, n]) => s + n, 0);
79
+ // engine.js's own comment on this branch: "unpinned_dep / no_lockfile...
80
+ // describe the ABSENCE of a declaration, so 'which commit introduced this
81
+ // version' is not a question that has an answer to defer ... this is a
82
+ // known, disclosed limitation, not a bug... strict mode WILL fail on
83
+ // nearly any real project that has a package.json." That disclosure lived
84
+ // only in a source comment nobody hits this error reads — the README's
85
+ // own quickstart explicitly invites pointing --assurance strict at "your
86
+ // own project," where this is the single most likely outcome. Named here
87
+ // so the person who hits it learns it is expected and permanent, not
88
+ // something to keep investigating. This prefix is deliberately narrower
89
+ // than "every non-vulnerable_dep supply-chain entry" — engine.js's
90
+ // provenance-stamping loop only uses it for unpinned_dep/no_lockfile,
91
+ // which genuinely have no origin commit; cdn_no_integrity/dynamic_require
92
+ // carry a real file:line and get a DIFFERENT string precisely so they
93
+ // never land in this "permanent, give up" bucket (adversarial premortem
94
+ // R2, 2026-09-07 — conflating the two told a user a resolvable coverage
95
+ // gap was an unfixable, by-design limitation).
96
+ const supplyChainReasons = ranked.filter(([r]) => r.startsWith('origin resolution does not apply to a'));
97
+ const supplyChainCount = supplyChainReasons.reduce((s, [, n]) => s + n, 0);
98
+ const knownReasonSet = new Set([...gitReasons, ...supplyChainReasons].map(([r]) => r));
99
+ const otherReasons = ranked.filter(([r]) => !knownReasonSet.has(r));
100
+ const otherCount = badProvenance.length - gitCount - supplyChainCount;
101
+ const knownCategoryCount = (gitCount > 0 ? 1 : 0) + (supplyChainCount > 0 ? 1 : 0);
102
+
103
+ // Exactly one KNOWN category, and nothing outside it — the shape every
104
+ // caller before this fix assumed was the only shape, and the one every
105
+ // existing test was written against. Kept as tight, single-topic prose
106
+ // rather than the multi-segment form below.
107
+ if (knownCategoryCount === 0) {
108
+ if (otherReasons.length === 1) {
109
+ return `${base} — all ${badProvenance.length} share the same reason: "${otherReasons[0][0]}".`;
110
+ }
111
+ const breakdown = otherReasons.slice(0, 5).map(([reason, n]) => `${n}× "${reason}"`).join(', ');
112
+ return `${base} — breakdown: ${breakdown}${otherReasons.length > 5 ? ', …' : ''}.`;
113
+ }
114
+ if (knownCategoryCount === 1 && otherCount === 0) {
115
+ if (gitCount > 0) {
116
+ const gitReasonNames = gitReasons.map(([r]) => `"${r}"`).join(' and ');
117
+ return `${base} — reason: ${gitCount === badProvenance.length ? 'all of them are' : `${gitCount} of them are`} ${gitReasonNames}. ` +
118
+ `strict mode resolves finding provenance from git history, so it requires a real git repository ` +
119
+ `(a GitHub "Download ZIP" extracts without one). Run \`git init && git add -A && git commit -m init\` in ` +
120
+ `the scanned directory, point the scan at a real \`git clone\`, or drop --assurance strict for standard/advisory.`;
121
+ }
122
+ return `${base} — ${supplyChainCount} of them describe an ABSENT dependency declaration ` +
123
+ `(an unpinned version, a missing lockfile) that has no origin commit to resolve, by design. This is a ` +
124
+ `known, permanent limitation: strict mode cannot pass while any are present, on any real project with ` +
125
+ `such a dependency. Fix the underlying SCA finding(s) (pin the version / add a lockfile) if you want ` +
126
+ `strict to pass, or use --assurance standard/advisory for a project you don't control the dependencies of.`;
127
+ }
128
+
129
+ // Two or more independently-blocking categories on the SAME scan — the
130
+ // defect this closes (adversarial premortem R1, 2026-09-07): the old
131
+ // code picked whichever category had the most findings and silently
132
+ // dropped every other one, so a user could "fix" the reported problem,
133
+ // rerun, and hit a second wall the first run already had full information
134
+ // about but never mentioned — the same "the tool knew and didn't tell me"
135
+ // complaint this whole function exists to fix, recurring in a milder form.
136
+ const segments = [];
137
+ if (gitCount > 0) {
138
+ const gitReasonNames = gitReasons.map(([r]) => `"${r}"`).join(' and ');
139
+ segments.push(`${gitCount} of them are ${gitReasonNames} (strict mode requires a real git repository — ` +
140
+ `run \`git init && git add -A && git commit\`, or scan a real \`git clone\`)`);
141
+ }
142
+ if (supplyChainCount > 0) {
143
+ segments.push(`${supplyChainCount} of them describe an ABSENT dependency declaration (unpinned version / ` +
144
+ `missing lockfile) with no origin commit to resolve — a known, permanent limitation, not something a ` +
145
+ `rerun will fix`);
146
+ }
147
+ if (otherCount > 0) {
148
+ if (otherReasons.length === 1) {
149
+ segments.push(`${otherCount} share the reason "${otherReasons[0][0]}"`);
150
+ } else {
151
+ const breakdown = otherReasons.slice(0, 5).map(([reason, n]) => `${n}× "${reason}"`).join(', ');
152
+ segments.push(`${otherCount} break down as: ${breakdown}${otherReasons.length > 5 ? ', …' : ''}`);
153
+ }
154
+ }
155
+ return `${base} — MULTIPLE distinct reasons, not just one: ${segments.join('; ')}. Every category above must ` +
156
+ `be resolved for strict to pass (or drop to --assurance standard/advisory) — fixing only one will surface ` +
157
+ `the next on your following run.`;
158
+ }
159
+
54
160
  /**
55
161
  * @param {string} mode - one of ASSURANCE_MODES; invalid/missing degrades to the default.
56
162
  * @param {object|null} scanHealth - the engine's computed scan.scanHealth (FR-206).
@@ -143,7 +249,7 @@ export function evaluateAssuranceMode(mode, scanHealth, findings = []) {
143
249
  return {
144
250
  ok: false,
145
251
  mode: 'strict',
146
- reason: `strict mode requires complete finding provenance; ${badProvenance.length} finding(s) have status outside [complete, uncommitted]`,
252
+ reason: _provenanceFailureReason(badProvenance, findings.length),
147
253
  conditions,
148
254
  };
149
255
  }
@@ -151,4 +257,4 @@ export function evaluateAssuranceMode(mode, scanHealth, findings = []) {
151
257
  return { ok: true, mode: 'strict', reason: null, conditions };
152
258
  }
153
259
 
154
- export const _internals = { _isValidMode };
260
+ export const _internals = { _isValidMode, _provenanceFailureReason };
@@ -266,6 +266,16 @@ export function buildScorecard(inputs) {
266
266
  }
267
267
  : null,
268
268
  },
269
+ // Adversarial premortem Q7 (2026-09-07). See mappingCoverageOf's own
270
+ // header comment (posture/auditor-walkthrough.js) for why this exists:
271
+ // measuring the trend, not gating it — no threshold, no baseline, no
272
+ // pass/fail, since a drop is sometimes the correct outcome of an honest
273
+ // fix and sometimes a real regression, and only a human reading the
274
+ // diff each release can tell which. `[]` (never omitted) when the
275
+ // caller supplies nothing, so a reader can tell "measured, zero
276
+ // frameworks" from "this scorecard predates the metric" the same way
277
+ // every other section here distinguishes absence from zero.
278
+ complianceMappingCoverage: inputs.complianceMappingCoverage || [],
269
279
  };
270
280
  }
271
281
 
@@ -530,6 +540,30 @@ export function renderScorecardMarkdown(m) {
530
540
  L.push('not a channel this measurement structurally cannot yet cover.');
531
541
  L.push('');
532
542
  }
543
+ if (Array.isArray(m.complianceMappingCoverage) && m.complianceMappingCoverage.length) {
544
+ L.push('## Compliance mapping coverage');
545
+ L.push('');
546
+ L.push('Adversarial premortem Q7 (2026-09-07): each fix to a category-error');
547
+ L.push('mapping (a control checking an artifact that evidences this scanner,');
548
+ L.push('not the target — see `03.03.08` in the NIST 800-171 coverage doc for the');
549
+ L.push('original instance) correctly SUBTRACTS a `mapsTo` entry. Nobody was');
550
+ L.push('tracking the cumulative effect release over release. This is not a');
551
+ L.push('gate — a drop is sometimes a correct, honest fix and sometimes a real');
552
+ L.push('regression, and only a human reading the diff each release can tell');
553
+ L.push('which — it exists so the trend is visible instead of assumed.');
554
+ L.push('');
555
+ L.push('| Framework | Controls with a live mapping | Share |');
556
+ L.push('| --- | --- | --- |');
557
+ for (const row of [...m.complianceMappingCoverage].sort((a, b) => String(a.id).localeCompare(String(b.id)))) {
558
+ L.push(`| ${row.id} | ${row.mappedCount}/${row.controlCount} | ${formatRate(row.mappedCount, row.controlCount)} |`);
559
+ }
560
+ L.push('');
561
+ L.push('"Live mapping" means the control carries at least one `family:`/`module:`/');
562
+ L.push('`rule:`/`graph:` entry, regardless of whether it would clear on any given');
563
+ L.push('scan — this counts what the engine CAN evidence, not what it evidenced');
564
+ L.push('this run.');
565
+ L.push('');
566
+ }
533
567
  // PRD F12.6 — the honest scorecard publishes the LIMITS too, not only the
534
568
  // rates. Three claims this project makes are only meaningful with their
535
569
  // caveat attached, and each caveat was invisible before this section:
@@ -21,6 +21,7 @@
21
21
  // a labelled fixture set.
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
+ import { statePath, safeWriteState } from './state-dir.js';
24
25
 
25
26
  // SDK / API endpoint detection — same family list as scanner/src/sast/llm.js
26
27
  const HF_FROM_PRETRAINED_RE = /(?:Auto(?:Model|Tokenizer|Config|Processor|FeatureExtractor)|[A-Z][A-Za-z]*Model|[A-Z][A-Za-z]*Tokenizer)\.from_pretrained\s*\(\s*['"]([\w./-]+)['"](?:[^)]*?revision\s*=\s*['"]([\w]+)['"])?/g;
@@ -395,3 +396,24 @@ export function validateMLBOM(doc) {
395
396
  }
396
397
  return { ok: errors.length === 0, errors, checked: 'structural (required fields + ML-BOM component shape), NOT full JSON-Schema validation' };
397
398
  }
399
+
400
+ // ─── Persistence (adversarial premortem Q2, 2026-09-07) ────────────────────
401
+ //
402
+ // `compliance-frameworks/*.json` has mapped `module:aibom` to `aibom.json`
403
+ // since those mappings were written (eu-ai-act.json Art.11, nist-800-171-r3
404
+ // .json 03.04.10, nist-ai-600-1.json MG-4.1-001), but nothing ever wrote it
405
+ // there automatically: `buildAIBOM` was only ever reachable through the CLI's
406
+ // `--format aibom`/`--format aibom-md` report emitters, which print to
407
+ // stdout (or wherever `--output` sends them) and never touch
408
+ // `.agentic-security/`. Three controls across three frameworks could never
409
+ // read 'present' via this leg, on any project, unless an operator happened
410
+ // to manually redirect `--format aibom` output to that exact path. Fixed the
411
+ // same way `license-attributions.js`'s `persistAttributions` and
412
+ // `threat-model.js`'s `persistAutoThreatModel` already are: a default-on
413
+ // (opt-out via AGENTIC_SECURITY_NO_AIBOM), best-effort write during every
414
+ // scan, wired in engine.js next to those two.
415
+ export function persistAIBOM(scanRoot, aibom) {
416
+ if (!aibom || typeof aibom !== 'object') return null;
417
+ safeWriteState(statePath(scanRoot, 'aibom.json'), JSON.stringify(aibom, null, 2));
418
+ return aibom;
419
+ }
@@ -119,6 +119,7 @@ export const ARTIFACT_REGISTRY = [
119
119
  { name: 'findings.csv', kind: 'file', classification: 'generated', retentionClass: 'scan' },
120
120
  { name: 'llm-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache' },
121
121
  { name: 'fix-history', kind: 'dir', classification: 'generated', retentionClass: 'backup' },
122
+ { name: 'verifier-runs', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'src/posture/verifier.js (recordVerifierRun)', note: 'Adversarial premortem Q1 (2026-09-07): one JSON record per `agentic-security verify` run, added so module:verifier (nist-800-171-r3.json 03.12.01, nist-csf-2.json RC.RP) has a real artifact to check for instead of a name in the ARTIFACT table nothing ever wrote.' },
122
123
  { name: 'fix-plans', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
123
124
  // The following were confirmed missing from the old hardcoded WIPE/
124
125
  // WIPE_DIRS sets (A-10) and confirmed GENERATED by reading their write
@@ -135,6 +136,7 @@ export const ARTIFACT_REGISTRY = [
135
136
  { name: 'compliance-evidence.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
136
137
  { name: 'compliance-evidence.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
137
138
  { name: 'ATTRIBUTIONS.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
139
+ { name: 'aibom.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/aibom.js (persistAIBOM)', note: 'Adversarial premortem Q2 (2026-09-07): module:aibom (eu-ai-act.json Art.11, nist-800-171-r3.json 03.04.10, nist-ai-600-1.json MG-4.1-001) mapped to this path since those mappings were written, but nothing wrote it here until now — buildAIBOM was only reachable via the CLI --format aibom emitter, which never touched .agentic-security/.' },
138
140
  { name: 'NOTICE', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
139
141
  { name: 'accepted.json', kind: 'file', classification: 'generated', source: 'posture/suppressions.js (soft-accept save path)', note: 'already self-managing per-entry expiry (FR-1004-adjacent) — no additional class-level TTL' },
140
142
  { name: 'triage.json', kind: 'file', classification: 'generated', source: 'posture/triage.js (_save)' },
@@ -328,6 +328,96 @@ function _resolveOpenFindingMinSeverity(scanRoot, frameworkId) {
328
328
  : OPEN_FINDING_MIN_SEVERITY;
329
329
  }
330
330
 
331
+ // `module:` mapping vocabulary → the on-disk artifact(s) that evidence it.
332
+ // Hoisted to module scope (was previously re-declared on every control
333
+ // evaluated, a wasted allocation with no reader) and exported so
334
+ // `test/module-artifact-liveness.test.js` can check every entry here is
335
+ // actually written somewhere in scanner/src/, without keeping a second,
336
+ // driftable copy of this list. A table entry is either one path or an array
337
+ // of acceptable ones — see the `scan-history` comment below for why an array
338
+ // means "any of these satisfies it." A `.../` prefix marks a source-relative
339
+ // artifact (resolved against the scan root itself, not the state dir).
340
+ export const MODULE_ARTIFACTS = {
341
+ 'sbom-diff': 'sbom-history/',
342
+ 'license-attributions': 'ATTRIBUTIONS.md',
343
+ 'threat-model-auto': 'threat-model.json',
344
+ 'compliance-policy': 'compliance-evidence.json',
345
+ 'fix-history': 'fix-history/log.json',
346
+ 'privacy-taint': 'dpia.md',
347
+ 'aibom': 'aibom.json',
348
+ 'attack-taxonomy': 'last-scan.json',
349
+ // Two real spellings, both live in this codebase: security-trend.js and
350
+ // router.js read `scan-history.json` (a FILE), findings-memory.js uses
351
+ // `scan-history` (a DIRECTORY). Only the directory was listed here, so
352
+ // on a normal scan — which writes the .json — every control mapped to
353
+ // module:scan-history reported the artifact missing and could never
354
+ // clear. Five bundled frameworks were affected. An array means "any of
355
+ // these satisfies it", which is the honest reading: the control asks
356
+ // whether a scan history exists, not which shape it took.
357
+ 'scan-history': ['scan-history.json', 'scan-history/'],
358
+ 'watch-mode': 'watch-status.json',
359
+ 'cve-alert-daemon': 'cve-alerts/',
360
+ 'triage': 'triage.json',
361
+ 'triage-memory': 'triage-memory.jsonl',
362
+ // Adversarial premortem Q1 (2026-09-07): this entry existed since the
363
+ // ARTIFACT table did, but nothing ever wrote verifier-runs/ — no control
364
+ // mapped to it could ever read 'present', on any project, permanently.
365
+ // `verifier.js`'s `recordVerifierRun` now writes one record per real
366
+ // `agentic-security verify` invocation, closing the gap for real.
367
+ 'verifier': 'verifier-runs/',
368
+ 'apply-fix': 'fix-history/log.json',
369
+ // REMOVED, deliberately (adversarial premortem P2.8 + Q1/Q2/Q6, 2026-09-07):
370
+ // 'integrity' (last-scan.json.sig), 'mcp-audit' (mcp-audit.log),
371
+ // 'calibration' (calibration-seed.json), 'holdout-eval'
372
+ // (holdout-eval.jsonl), 'sigstore-verify' (sigstore-attestations/, never
373
+ // written — see hipaa-security-rule.json's §164.312(c) removal note),
374
+ // 'pre-edit-bodyguard' (hooks/pre-edit-bodyguard.js), 'security-fixer'
375
+ // (agents/security-fixer.md), 'mcp-tools' (scanner/src/mcp/tools.js), and
376
+ // 'why-fired' (last-scan.json — its CONTENT is target-derived, unlike the
377
+ // others, but it evidences THIS TOOL's own detection provenance, not any
378
+ // property of the assessed system; adjudicated on eu-ai-act.json Art.13's
379
+ // actual text, see that control's evidence[] for the full reasoning) all
380
+ // evidence THIS SCANNER's own state, operation, or installed files —
381
+ // never the scanned project's — and are structurally incapable of validly
382
+ // backing any `module:` mapping, not just accidentally missing a writer.
383
+ // Every live mapsTo reference to any of them was removed and disclosed as
384
+ // an engine gap (see each affected framework file's evidence[] for the
385
+ // specific reasoning); they are removed from the vocabulary table itself,
386
+ // not merely un-referenced, so there is nothing left to copy-paste back
387
+ // in. `compliance-mapping-liveness.test.js`'s self-referential-module test
388
+ // remains as a permanent regression guard against the STRING key
389
+ // reappearing in a mapsTo array even without a table entry to source it
390
+ // from.
391
+ };
392
+
393
+ // Adversarial premortem Q7 (2026-09-07): each `03.03.08`-style fix
394
+ // (P2.8/Q1/Q2/Q6) subtracts a `mapsTo` entry to correct a category error —
395
+ // the right call every time it happened, but nobody was tracking the
396
+ // CUMULATIVE effect. "Always subtract, never invent" is correct engineering
397
+ // discipline that can still trend, unmeasured, toward a framework that
398
+ // automatically clears fewer and fewer controls each release, which looks
399
+ // to a buyer like the tool doing less over time even though every
400
+ // individual change made it more honest. This computes the number that
401
+ // makes the trend visible instead of assumed: how many of a framework's
402
+ // controls carry at least one LIVE mapsTo (family:/module:/rule:/graph:),
403
+ // regardless of whether that mapping would currently clear on any given
404
+ // scan — the question is "can this control ever be evidenced by this
405
+ // engine at all," not "did today's scan clear it." Pure and side-effect
406
+ // free, like the rest of this module's exports; the caller supplies the
407
+ // already-loaded framework object (see `scripts/scorecard.mjs` for the
408
+ // driver that loads every bundled framework and calls this once each).
409
+ export function mappingCoverageOf(fw) {
410
+ const controls = (fw && fw.controls) || [];
411
+ const controlCount = controls.length;
412
+ const mappedCount = controls.filter((c) => Array.isArray(c.mapsTo) && c.mapsTo.length > 0).length;
413
+ return {
414
+ id: fw && fw.id,
415
+ controlCount,
416
+ mappedCount,
417
+ mappedFraction: controlCount ? mappedCount / controlCount : null,
418
+ };
419
+ }
420
+
331
421
  export function evaluateFramework(scanRoot, fw, scan) {
332
422
  const minSeverity = _resolveOpenFindingMinSeverity(scanRoot, fw && fw.id);
333
423
  // CMP-2: last-scan.json (what this is actually handed in production) carries
@@ -521,46 +611,12 @@ export function evaluateFramework(scanRoot, fw, scan) {
521
611
  anySignal = true;
522
612
  } else if (m.startsWith('module:')) {
523
613
  const mod = m.slice('module:'.length);
524
- const ARTIFACT = {
525
- 'sbom-diff': 'sbom-history/',
526
- 'license-attributions': 'ATTRIBUTIONS.md',
527
- 'threat-model-auto': 'threat-model.json',
528
- 'compliance-policy': 'compliance-evidence.json',
529
- 'mcp-audit': 'mcp-audit.log',
530
- 'fix-history': 'fix-history/log.json',
531
- 'privacy-taint': 'dpia.md',
532
- 'aibom': 'aibom.json',
533
- 'attack-taxonomy': 'last-scan.json',
534
- 'why-fired': 'last-scan.json',
535
- // Two real spellings, both live in this codebase: security-trend.js and
536
- // router.js read `scan-history.json` (a FILE), findings-memory.js uses
537
- // `scan-history` (a DIRECTORY). Only the directory was listed here, so
538
- // on a normal scan — which writes the .json — every control mapped to
539
- // module:scan-history reported the artifact missing and could never
540
- // clear. Five bundled frameworks were affected. An array means "any of
541
- // these satisfies it", which is the honest reading: the control asks
542
- // whether a scan history exists, not which shape it took.
543
- 'scan-history': ['scan-history.json', 'scan-history/'],
544
- 'integrity': 'last-scan.json.sig',
545
- 'watch-mode': 'watch-status.json',
546
- 'cve-alert-daemon': 'cve-alerts/',
547
- 'triage': 'triage.json',
548
- 'triage-memory': 'triage-memory.jsonl',
549
- 'verifier': 'verifier-runs/',
550
- 'calibration': 'calibration-seed.json',
551
- 'holdout-eval': 'holdout-eval.jsonl',
552
- 'sigstore-verify': 'sigstore-attestations/',
553
- 'pre-edit-bodyguard': '.../hooks/pre-edit-bodyguard.js',
554
- 'apply-fix': 'fix-history/log.json',
555
- 'security-fixer': '.../agents/security-fixer.md',
556
- 'mcp-tools': '.../scanner/src/mcp/tools.js',
557
- };
558
614
  // A table entry is either one path or an array of acceptable ones. An
559
615
  // array means the artifact has more than one real spelling in this
560
616
  // codebase and any of them evidences the control; the FIRST is the
561
617
  // canonical name used in the observation text when none is found, so
562
618
  // the message still names something a reader can go create.
563
- const target = ARTIFACT[mod];
619
+ const target = MODULE_ARTIFACTS[mod];
564
620
  const candidates = target == null ? [] : (Array.isArray(target) ? target : [target]);
565
621
  // A '.../' sentinel marks a source-relative artifact (project source,
566
622
  // e.g. a hook or agent file) — resolve it against the scan root itself.
@@ -4,6 +4,8 @@
4
4
  "publisher": "California Legislature",
5
5
  "license": "California statute (public)",
6
6
  "url": "https://leginfo.legislature.ca.gov/faces/codes_displayText.xhtml?division=3.&part=4.&lawCode=CIV&title=1.81.5",
7
+ "sourceVerifiedAt": "2026-09-07",
8
+ "sourceVerificationNote": "Verified 2026-09-07 (WebFetch): resolves to California Civil Code Title 1.81.5 sections 1798.100-1798.145, correctly reflecting amendments through Stats. 2025, Ch. 67 (effective 2026-01-01). No content hash is pinned deliberately: this URL serves the CURRENT, continuously-amended text of codified law by design, not a fixed publication, hash-pinning would misrepresent a living legal source as a static one.",
7
9
  "scope": "SELECTIVE SUBSET. 4 of the CCPA/CPRA obligations, chosen because a code scanner can produce evidence for them. The statute is far broader; the majority of its duties (notice, consumer request handling, contracts, retention policy) are organisational and are NOT represented here. Absence of a control is not a statement of compliance.",
8
10
  "controlsDigest": "ebc1f708c329ab42",
9
11
  "controlCount": 4,
@@ -4,8 +4,10 @@
4
4
  "publisher": "European Parliament & Council",
5
5
  "license": "EU law (Official Journal)",
6
6
  "url": "https://eur-lex.europa.eu/eli/reg/2024/1689/oj",
7
+ "sourceVerificationAttempted": "2026-09-07",
8
+ "sourceVerificationNote": "Verification attempted 2026-09-07 and NOT completed: eur-lex.europa.eu returned HTTP 202 with an empty body and an x-amzn-waf-action: challenge header (AWS WAF bot challenge), both via the WebFetch tool and via a direct curl request with a standard browser User-Agent. This is recorded honestly as an unverified URL rather than a false confirmation. GDPR (the sibling eur-lex.europa.eu/eli/reg/2016/679/oj source, same domain) resolved successfully in the same session, so this appears to be per-request or per-path challenge behavior, not a durable domain-wide block. Re-attempt this check periodically; a human should confirm the URL manually until automated verification succeeds.",
7
9
  "scope": "SELECTIVE SUBSET. 7 obligations drawn from the high-risk-system and GPAI articles where a code signal exists. The Act is far broader; conformity assessment, registration, human oversight and post-market monitoring are organisational and are NOT represented here.",
8
- "controlsDigest": "16d15998a686a19b",
10
+ "controlsDigest": "f63f5763df191224",
9
11
  "controlCount": 7,
10
12
  "controls": [
11
13
  {
@@ -53,11 +55,10 @@
53
55
  "summary": "Record-keeping — automatic logging of system events for traceability.",
54
56
  "codeTestable": "partial",
55
57
  "evidence": [
56
- "MCP audit log .agentic-security/mcp-audit.log present.",
57
- "Scan history retained."
58
+ "Scan history retained.",
59
+ "Removed module:mcp-audit after a second-review pass: mcp-audit.log records calls to THIS tool's own MCP server, i.e. how an agent used this scanner — it is not evidence that the assessed AI system logs its own events. Category error (self-referential: about this tool, not the target), same class as the 03.03.08 fix in the NIST 800-171 framework."
58
60
  ],
59
61
  "mapsTo": [
60
- "module:mcp-audit",
61
62
  "module:scan-history"
62
63
  ]
63
64
  },
@@ -66,11 +67,10 @@
66
67
  "summary": "Transparency — instructions for use enable users to interpret the system's output correctly.",
67
68
  "codeTestable": "partial",
68
69
  "evidence": [
69
- "why-fired annotation surfaces detection provenance on every finding."
70
+ "Removed module:why-fired after adversarial premortem Q6 (2026-09-07, re-run): why-fired.js explains why THIS SCANNER's OWN detectors fired on a finding ('the user can see exactly what produced the finding without reading the scanner source' — its own header comment) — it is provenance for this tool's decisions, not evidence that the ASSESSED AI system gives its own users instructions to interpret ITS OWN output, which is what Article 13 actually asks. Every other control in this framework uses 'the system' to mean the assessed AI product (Art.9's risk management system, Art.12's event logging), so this reads Art.13 the same way rather than as an exception. Category error (self-referential: about this tool, not the target), same class as the 03.03.08 fix in the NIST 800-171 framework — this one was initially judged a borderline, undecided open question in the first premortem pass and is now adjudicated on a full reading of the actual control text and the actual module code.",
71
+ "Reported as an engine gap: whether an assessed AI system documents its own output for its own users is not something this engine, which never sees the assessed system's user-facing product surface, can evidence at all."
70
72
  ],
71
- "mapsTo": [
72
- "module:why-fired"
73
- ]
73
+ "mapsTo": []
74
74
  },
75
75
  {
76
76
  "id": "Art.14",
@@ -78,10 +78,9 @@
78
78
  "codeTestable": "partial",
79
79
  "evidence": [
80
80
  "Fix application requires confirm:true.",
81
- "Bodyguard hook can refuse risky edits."
81
+ "Removed module:pre-edit-bodyguard after a second-review pass: that file is this scanning tool's OWN installed hook, not an artifact of the assessed AI system. Its presence only shows this tool's plugin is installed, never that the assessed system itself permits human override or interruption. Category error (self-referential: about this tool, not the target)."
82
82
  ],
83
83
  "mapsTo": [
84
- "module:pre-edit-bodyguard",
85
84
  "module:apply-fix"
86
85
  ]
87
86
  },
@@ -90,13 +89,10 @@
90
89
  "summary": "Accuracy, robustness, cybersecurity — appropriate level of accuracy and resilience.",
91
90
  "codeTestable": "partial",
92
91
  "evidence": [
93
- "Calibration + held-out evaluation present (.agentic-security/calibration-seed.json).",
94
- "OWASP Benchmark regression gate."
92
+ "No automated signal in this engine after a second-review pass removed both prior mappings. module:calibration and module:holdout-eval pointed at THIS scanner's own ML calibration corpus and held-out evaluation labels (calibration-seed.json, holdout-eval.jsonl) — files that describe how accurately this tool's OWN detectors are calibrated, not whether the assessed AI system has an appropriate level of accuracy, robustness or cybersecurity. Category error (self-referential: about this tool, not the target), same class as the 03.03.08 fix in the NIST 800-171 framework.",
93
+ "Reported as an engine gap: an AI system's own accuracy/robustness testing is code-observable in principle (evaluation harnesses, robustness test suites in the assessed system's own repo), but no detector here looks for that in the SCANNED project."
95
94
  ],
96
- "mapsTo": [
97
- "module:calibration",
98
- "module:holdout-eval"
99
- ]
95
+ "mapsTo": []
100
96
  }
101
97
  ]
102
98
  }
@@ -4,8 +4,10 @@
4
4
  "publisher": "European Parliament & Council",
5
5
  "license": "EU law (Official Journal)",
6
6
  "url": "https://eur-lex.europa.eu/eli/reg/2016/679/oj",
7
+ "sourceVerifiedAt": "2026-09-07",
8
+ "sourceVerificationNote": "Verified 2026-09-07 (WebFetch): eur-lex.europa.eu/eli/reg/2016/679/oj resolves to the consolidated text of Regulation (EU) 2016/679 (GDPR), confirming Articles 5, 25, 32, 33, 35 and 44 (the six represented here) match the mapped summaries. No content hash is pinned deliberately: EUR-Lex serves the CURRENT consolidated text and its page template can change independently of the legal text itself, so a page hash would drift on template changes that carry no legal-content change and would need constant, meaningless re-pinning — hash-pinning would misrepresent a living legal source as a static publication.",
7
9
  "scope": "SELECTIVE SUBSET. 6 articles where a code scanner can produce evidence (security of processing, data minimisation, DPIA inputs). GDPR has 99 articles; lawful basis, data-subject rights, transfers and records of processing are organisational and are NOT represented here.",
8
- "controlsDigest": "749f9dad2b5657cb",
10
+ "controlsDigest": "cd018b0daad4fa66",
9
11
  "controlCount": 6,
10
12
  "controls": [
11
13
  {
@@ -67,11 +69,10 @@
67
69
  "codeTestable": "partial",
68
70
  "evidence": [
69
71
  "Fix history retained.",
70
- "Audit log preserves the breach-window evidence."
72
+ "Removed module:mcp-audit after a second-review pass: mcp-audit.log records calls to THIS tool's own MCP server, not the controller's own breach-detection or notification workflow. Category error (self-referential: about this tool, not the target)."
71
73
  ],
72
74
  "mapsTo": [
73
- "module:fix-history",
74
- "module:mcp-audit"
75
+ "module:fix-history"
75
76
  ]
76
77
  },
77
78
  {
@@ -79,10 +80,11 @@
79
80
  "summary": "Data protection impact assessment (DPIA) for high-risk processing.",
80
81
  "codeTestable": "partial",
81
82
  "evidence": [
82
- "DPIA artifact present at .agentic-security/dpia.md."
83
+ "DPIA artifact present at .agentic-security/dpia.md (privacy-taint.js's emitDpiaArtifact).",
84
+ "Fixed a malformed mapsTo entry (adversarial premortem Q2, 2026-09-07): 'module:privacy-taint:emitDpiaArtifact' does not match any key in the ARTIFACT vocabulary table (auditor-walkthrough.js parses everything after 'module:' as one literal key, with no ':'-suffix syntax) — this control could never read 'present' via this leg, on any project, since the mapping shipped. Corrected to the real key, 'module:privacy-taint'."
83
85
  ],
84
86
  "mapsTo": [
85
- "module:privacy-taint:emitDpiaArtifact"
87
+ "module:privacy-taint"
86
88
  ]
87
89
  }
88
90
  ]
@@ -4,8 +4,10 @@
4
4
  "publisher": "US Department of Health and Human Services",
5
5
  "license": "US Federal regulation (public)",
6
6
  "url": "https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164",
7
+ "sourceVerifiedAt": "2026-09-07",
8
+ "sourceVerificationNote": "Verified 2026-09-07: the WebFetch tool was blocked by eCFR's automated bot detection (redirected to unblock.federalregister.gov, a CAPTCHA challenge). Verified instead via a direct HTTP request (curl with a standard browser User-Agent, HTTP 200), confirming sections 164.302/304/306/308 (the HIPAA Security Rule administrative safeguards) are present at this URL. No content hash is pinned: eCFR explicitly serves the CURRENT version of the regulation by design (the URL path itself says \"current\"), so hash-pinning would treat a living regulatory text as a fixed publication.",
7
9
  "scope": "SELECTIVE SUBSET. 8 of the Security Rule technical safeguards. Administrative and physical safeguards are outside what a code scanner can observe and are NOT represented here.",
8
- "controlsDigest": "7e94038dd9a64d3d",
10
+ "controlsDigest": "4be9e2264a574e33",
9
11
  "controlCount": 8,
10
12
  "controls": [
11
13
  {
@@ -73,10 +75,10 @@
73
75
  "summary": "Audit controls — record and examine activity in systems containing PHI.",
74
76
  "codeTestable": "partial",
75
77
  "evidence": [
76
- "MCP audit log + fix history present and hash-chained."
78
+ "Fix history present and hash-chained.",
79
+ "Removed module:mcp-audit after a second-review pass: mcp-audit.log records calls to THIS tool's own MCP server, not activity in the covered entity's own systems containing PHI. Category error (self-referential: about this tool, not the target)."
77
80
  ],
78
81
  "mapsTo": [
79
- "module:mcp-audit",
80
82
  "module:fix-history"
81
83
  ]
82
84
  },
@@ -85,13 +87,11 @@
85
87
  "summary": "Integrity — PHI not altered or destroyed in an unauthorized manner.",
86
88
  "codeTestable": "partial",
87
89
  "evidence": [
88
- "last-scan.json HMAC integrity check passing.",
89
- "Sigstore provenance verified for ML model files (if opt-in)."
90
+ "Removed module:integrity after a second-review pass: last-scan.json.sig is this scanner signing its OWN scan output, not evidence that the covered entity's PHI is protected from unauthorized alteration or destruction. Category error (self-referential: about this tool, not the target), same class as the 03.03.08 fix in the NIST 800-171 framework.",
91
+ "Removed module:sigstore-verify after a second-review pass (adversarial premortem Q1, 2026-09-07): sigstore-attestations/ is never written anywhere in this codebase — the real Sigstore verification logic (scanner/src/sca/sigstore-verify.js) caches results in a per-user home-directory cache (~/.claude/agentic-security/sigstore-cache/), shared across every project scanned on that machine, by design (content-addressed by package sha256, correctly reused rather than duplicated per project). Its own annotation call is fire-and-forget async in engine.js, not awaited, so even a project-relative summary write would not reliably complete before the scan process exits — building one blind, without first fixing that completion guarantee, was judged a bigger and riskier change than this task's scope, so this mapping is disclosed as a gap instead of built unverified.",
92
+ "Reported as an engine gap: whether PHI integrity is protected against unauthorized alteration or destruction is code-observable in principle (checksums, WORM storage, tamper-evident logging in the covered entity's own application code), but no detector here evidences that in the SCANNED project."
90
93
  ],
91
- "mapsTo": [
92
- "module:integrity",
93
- "module:sigstore-verify"
94
- ]
94
+ "mapsTo": []
95
95
  },
96
96
  {
97
97
  "id": "§164.312(e)",
@@ -4,9 +4,12 @@
4
4
  "publisher": "NIST",
5
5
  "license": "public-domain (US Federal publication)",
6
6
  "url": "https://csrc.nist.gov/pubs/sp/800/171/r3/final",
7
+ "sourceDoi": "https://doi.org/10.6028/NIST.SP.800-171r3",
8
+ "sourcePdfSha256": "3e4631df8b5d61f40a6e542b52779ef30ddbbfff31e09214fa94ad6e6f5e6d08",
9
+ "sourceVerificationNote": "Verified 2026-09-07: the CSRC landing page URL resolves and states title \"Protecting Controlled Unclassified Information in Nonfederal Systems and Organizations\", Rev. 3, published May 2024. The DOI resolves (302) to the PDF at nvlpubs.nist.gov, whose own embedded XMP metadata (pdfx:DOI, pdfx:Comments) confirms NIST.SP.800-171r3. sourcePdfSha256 is the SHA-256 of that exact downloaded PDF, so a future re-check can detect if NIST republishes under the same URL/DOI with different content. This does not verify the CSV control export against the PDF text control-by-control — see scripts/nist-800-171/README for that gap.",
7
10
  "note": "Control text is generated from docs/standards/NIST_SP_800_171r3_Controls.csv by scripts/nist-800-171/build-catalog.py. The codeTestable rating is THIS ENGINE'S judgment, not NIST's. Unlike the AI 600-1 workbook, the 800-171 export rates no control for testability. Ratings and their rationales live in scripts/nist-800-171/code-testability.json.",
8
11
  "scope": "FULL CATALOGUE — all 97 requirements of NIST SP 800-171 Rev. 3 are carried, deliberately including those this engine cannot assess, because omitting them would read as coverage. Automated evidence is PARTIAL and unevenly distributed: 16 requirements are rated code-testable, 38 partial, and 43 are organisational, physical or personnel controls NOT represented by any automated signal: all of Awareness and Training, Personnel Security and Physical Protection, and most of Incident Response, Maintenance and Media Protection. Those are forced to 'partial' with an explicit disclosure and can never read as satisfied. This is not a CMMC assessment and produces no SPRS score.",
9
- "controlsDigest": "28e4a38c393e1457",
12
+ "controlsDigest": "927295dcd1594189",
10
13
  "controlCount": 97,
11
14
  "controls": [
12
15
  {
@@ -330,10 +333,8 @@
330
333
  "summary": "Protection of Audit Information: a. Protect audit information and audit logging tools from unauthorized access, modification, and deletion. b. Authorize access to management of audit logging functionality to only a subset of…",
331
334
  "codeTestable": "partial",
332
335
  "evidence": [
333
- "Artifact(s) present: integrity."
334
- ],
335
- "mapsTo": [
336
- "module:integrity"
336
+ "No automated signal in this engine — this tool can sign and verify its OWN scan output (module:integrity), but that evidences this scanner's integrity, not whether the SCANNED PROJECT protects its own audit logs from unauthorized access, modification or deletion. No detector here inspects the scanned codebase for log-signing, tamper-evident storage, or access controls on audit data. Removed after a second-review pass found the original mapping was a category error (self-referential: about this tool, not the target).",
337
+ "Reported as an engine gap: this requirement is code-observable in principle (a detector could look for tamper-evident logging libraries or access-controlled log stores in application code), but no detector here decides it."
337
338
  ]
338
339
  },
339
340
  {
@@ -4,8 +4,11 @@
4
4
  "publisher": "NIST",
5
5
  "license": "public-domain (US Federal publication)",
6
6
  "url": "https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.600-1.pdf",
7
+ "sourceDoi": "https://doi.org/10.6028/NIST.AI.600-1",
8
+ "sourcePdfSha256": "6e73620ab6b64e90ef2c04bf0e0d6246185a2f4b1b13cab0df494496cff89b6a",
9
+ "sourceVerificationNote": "Verified 2026-09-07: the doi.org DOI resolves (302) to this exact url, and the downloaded PDF's own embedded XMP metadata (pdfx:Category, dc:title) confirms \"NIST AI 600-1\" / \"Artificial Intelligence Risk Management Framework: Generative Artificial Intelligence Profile\". sourcePdfSha256 is the SHA-256 of that exact downloaded PDF, so a future re-check can detect if NIST republishes under the same URL/DOI with different content.",
7
10
  "scope": "SELECTIVE SUBSET. 6 actions from the Generative AI Profile with a code-observable signal. The profile is far larger; governance, provenance and incident-response actions are organisational and are NOT represented here. The full control catalogue is built and gated separately by scripts/nist-compliance/build-catalog.py.",
8
- "controlsDigest": "a6ad567713796b05",
11
+ "controlsDigest": "6e7cc01c5ba8ae22",
9
12
  "controlCount": 6,
10
13
  "controls": [
11
14
  {
@@ -84,11 +87,10 @@
84
87
  "summary": "Establish content provenance / authenticity controls.",
85
88
  "codeTestable": "partial",
86
89
  "evidence": [
87
- "Sigstore provenance opt-in configured for model loads.",
88
- "ATTRIBUTIONS.md generated."
90
+ "ATTRIBUTIONS.md generated.",
91
+ "Removed module:sigstore-verify after a second-review pass (adversarial premortem Q1, 2026-09-07): sigstore-attestations/ is never written anywhere in this codebase — see the identical removal note on hipaa-security-rule.json's §164.312(c) for the full reasoning (home-directory cache by design, fire-and-forget async producer, not a quick artifact-path fix). Content-provenance / authenticity controls for model loads remain only partially evidenced by this control's surviving module:license-attributions leg."
89
92
  ],
90
93
  "mapsTo": [
91
- "module:sigstore-verify",
92
94
  "module:license-attributions"
93
95
  ]
94
96
  }