@clear-capabilities/agentic-security-scanner 0.139.1 → 0.141.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 (65) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/bin/agentic-security.js +40 -11
  3. package/dist/113.index.js +79 -3
  4. package/dist/178.index.js +1 -1
  5. package/dist/238.index.js +77 -1
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +12 -0
  8. package/dist/526.index.js +79 -3
  9. package/dist/637.index.js +1 -1
  10. package/dist/agentic-security.mjs +14 -14
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/compliance-frameworks/ccpa.json +34 -7
  13. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  14. package/dist/compliance-frameworks/gdpr.json +56 -12
  15. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  16. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  17. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  18. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  19. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  20. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  21. package/package.json +16 -5
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/engine.js +281 -23
  24. package/src/mcp/tools.js +12 -0
  25. package/src/posture/accuracy-scorecard.js +57 -0
  26. package/src/posture/aibom.js +110 -1
  27. package/src/posture/auditor-walkthrough.js +137 -21
  28. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  29. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  30. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  31. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  32. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  33. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  34. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  35. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  36. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  37. package/src/posture/concurrency-checker.js +42 -5
  38. package/src/posture/coverage-strength.js +182 -0
  39. package/src/posture/epss.js +17 -1
  40. package/src/posture/family-registry.js +103 -0
  41. package/src/posture/family-resolve.js +47 -0
  42. package/src/posture/fix-coverage.js +113 -0
  43. package/src/posture/fix-metrics.js +76 -0
  44. package/src/posture/integrity.js +59 -8
  45. package/src/posture/mcp-rug-pull.js +144 -0
  46. package/src/posture/poc-generator.js +17 -1
  47. package/src/posture/poc-inprocess.js +217 -1
  48. package/src/posture/proof-coverage.js +162 -0
  49. package/src/posture/reachability-filter.js +44 -0
  50. package/src/posture/sbom.js +50 -7
  51. package/src/runScan.js +56 -5
  52. package/src/sast/CLAUDE.md +2 -2
  53. package/src/sast/claude-md-prompt-injection.js +47 -3
  54. package/src/sast/cloud-iam.js +23 -0
  55. package/src/sast/convention-deviation.js +66 -3
  56. package/src/sast/crypto-protocol.js +23 -0
  57. package/src/sast/dapp-frontend.js +20 -0
  58. package/src/sast/iac-cloud-templates.js +337 -0
  59. package/src/sast/k8s-admission.js +27 -0
  60. package/src/sast/ml-supply-chain.js +22 -0
  61. package/src/sast/ruby.js +132 -0
  62. package/src/sast/web3-advanced.js +26 -0
  63. package/src/sca/CLAUDE.md +21 -4
  64. package/src/sca/container.js +18 -1
  65. package/src/sca/dep-confusion.js +69 -3
@@ -428,6 +428,63 @@ export function renderScorecardMarkdown(m) {
428
428
  }
429
429
  L.push('Per-file counts are in `docs/scorecard.json`.');
430
430
  L.push('');
431
+ // PRD F12.6 — the honest scorecard publishes the LIMITS too, not only the
432
+ // rates. Three claims this project makes are only meaningful with their
433
+ // caveat attached, and each caveat was invisible before this section:
434
+ // proof coverage — "provable" means provable by a JAVASCRIPT-ONLY harness
435
+ // calibration — the confidence surface is currently UNVERIFIED
436
+ // compliance — most controls are backed by weak or unmeasured detectors
437
+ // Publishing a rate without its ceiling is how a number comes to mean
438
+ // whatever the reader assumes.
439
+ if (m.limits) {
440
+ L.push('## Stated limits — what these capabilities cannot do');
441
+ L.push('');
442
+ if (m.limits.proof) {
443
+ const pc = m.limits.proof;
444
+ L.push('### Execution proof coverage, with its ceiling');
445
+ L.push('');
446
+ L.push('| Bucket | Share of all findings |');
447
+ L.push('| --- | --- |');
448
+ L.push(`| Provable (a proof class exists) | ${pc.provable.n}/${pc.total} |`);
449
+ L.push(`| Declined on purpose, reason stated | ${pc.indeterminate.n}/${pc.total} |`);
450
+ L.push(`| No proof class yet (backlog) | ${pc.unclassified.n}/${pc.total} |`);
451
+ L.push(`| **Out of harness scope (not JavaScript)** | **${pc.outOfScope.n}/${pc.total}** |`);
452
+ L.push('');
453
+ L.push(`**The ceiling.** The in-process proof harness only loads JavaScript. Of ${pc.total} findings`);
454
+ L.push(`measured on the CVE corpus, ${pc.reachable.n} are reachable by it at all. Proof coverage is`);
455
+ L.push(`**${pc.provable.n}/${pc.total}** of ALL findings and **${pc.provable.n}/${pc.reachable.n}** of the reachable ones.`);
456
+ L.push('Both are given because they differ by a lot, and a reader shown only one will');
457
+ L.push('draw the wrong conclusion in whichever direction that one flatters.');
458
+ L.push('');
459
+ L.push('Adding more proof classes does not move the ceiling. A Python or Java finding is');
460
+ L.push('not backlog — it is unreachable by this harness at any effort.');
461
+ L.push('');
462
+ }
463
+ if (m.limits.calibration) {
464
+ L.push('### Confidence calibration');
465
+ L.push('');
466
+ L.push(`**${m.limits.calibration}**`);
467
+ L.push('');
468
+ L.push('Every finding carries a confidence number. That number is a claim about how');
469
+ L.push('often the engine is right, and it is only worth what the evidence behind it is');
470
+ L.push('worth. `calibration-seed.json` is FITTING data, so measuring against it would');
471
+ L.push('reproduce the error it was fitted to. The release gate fails on this by default;');
472
+ L.push('a dated waiver is what currently allows a release, and it expires.');
473
+ L.push('');
474
+ }
475
+ if (m.limits.compliance) {
476
+ const c = m.limits.compliance;
477
+ L.push('### Compliance control strength');
478
+ L.push('');
479
+ L.push(`Of ${c.total} bundled controls, **${c.partiallyEvidenced} are backed by a detector that is weak or was never`);
480
+ L.push('measured against an independent corpus**, and are flagged `partiallyEvidenced`.');
481
+ L.push(`A further **${c.notCodeTestable} are organisational or artifact-existence only** and can never`);
482
+ L.push('read as evidenced by a scanner. A control appearing in a coverage map is not a');
483
+ L.push('statement that the control is satisfied.');
484
+ L.push('');
485
+ }
486
+ }
487
+
431
488
  const ind = m.committedInputs.independent;
432
489
  if (ind && ind.overall) {
433
490
  L.push('## Independent evaluation population — the number that matters');
@@ -201,7 +201,19 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
201
201
  return {
202
202
  aibomFormat: 'agentic-security AI-BOM',
203
203
  version: '1',
204
- cyclonedxCompatible: '1.7-ml-bom',
204
+ // PRD F5.5 — this document is PROPRIETARY and now says so.
205
+ //
206
+ // It previously carried `cyclonedxCompatible: '1.7-ml-bom'`, which was an
207
+ // unverified claim: nothing validated it, and the document has no
208
+ // `bomFormat`, no `specVersion` and no CycloneDX `components` array, so it
209
+ // would not have parsed as CycloneDX at all. A consumer reading that field
210
+ // and feeding this to a CycloneDX tool would have got an error, not a BOM.
211
+ //
212
+ // The honest options F5.5 gives are "validate mechanically, or be labelled
213
+ // proprietary". Both are taken: the field below states the truth, and
214
+ // `toCycloneDXMLBOM()` emits a REAL ML-BOM that `validateMLBOM()` checks.
215
+ proprietary: true,
216
+ cyclonedxMlBom: 'not this document — call toCycloneDXMLBOM() for a CycloneDX 1.6 ML-BOM view',
205
217
  generatedAt: meta.startedAt || new Date().toISOString(),
206
218
  models,
207
219
  promptTemplates,
@@ -286,3 +298,100 @@ export function aibomToMarkdown(aibom) {
286
298
 
287
299
  return out.join('\n');
288
300
  }
301
+
302
+
303
+ // ── CycloneDX ML-BOM (PRD F5.5) ────────────────────────────────────────────
304
+ //
305
+ // A real CycloneDX 1.6 document describing the models this scan found, using the
306
+ // ML-BOM shape: `components[].type: 'machine-learning-model'` carrying a
307
+ // `modelCard`. Emitted separately from the proprietary AI-BOM rather than
308
+ // pretending the proprietary one already conforms.
309
+ //
310
+ // The serial number derives from content under --deterministic, for the same
311
+ // reason toCycloneDX's does: an attestation over a BOM is meaningless if the BOM
312
+ // changes on every run.
313
+ import * as _crypto from 'node:crypto';
314
+ import { isDeterministic as _isDet } from './deterministic.js';
315
+
316
+ function _mlbomSerial(seed) {
317
+ if (!_isDet()) return `urn:uuid:${_crypto.randomUUID()}`;
318
+ const h = _crypto.createHash('sha256').update(String(seed)).digest('hex');
319
+ return `urn:uuid:${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
320
+ }
321
+
322
+ export function toCycloneDXMLBOM(aibom, meta = {}) {
323
+ const models = (aibom && aibom.models) || [];
324
+ const components = models.map((m) => ({
325
+ type: 'machine-learning-model',
326
+ 'bom-ref': `model:${m.provider || 'unknown'}/${m.name || 'unknown'}${m.version ? `@${m.version}` : ''}`,
327
+ name: m.name || 'unknown',
328
+ ...(m.version ? { version: m.version } : {}),
329
+ modelCard: {
330
+ modelParameters: {
331
+ ...(m.provider ? { approach: { type: 'supervised' } } : {}),
332
+ ...(m.task ? { task: m.task } : {}),
333
+ },
334
+ // `considerations` is where CycloneDX expects known risks to live. Pinning
335
+ // status is a real supply-chain property of a model reference: an unpinned
336
+ // model can change under you between runs.
337
+ considerations: {
338
+ technicalLimitations: m.pinned
339
+ ? []
340
+ : ['model reference is not pinned to a version; the served model can change between runs'],
341
+ },
342
+ },
343
+ properties: [
344
+ ...(m.provider ? [{ name: 'agentic-security:provider', value: String(m.provider) }] : []),
345
+ ...(m.file ? [{ name: 'agentic-security:declaredIn', value: String(m.file) }] : []),
346
+ { name: 'agentic-security:pinned', value: String(!!m.pinned) },
347
+ ],
348
+ }));
349
+
350
+ return {
351
+ bomFormat: 'CycloneDX',
352
+ specVersion: '1.6',
353
+ serialNumber: _mlbomSerial(JSON.stringify(components.map((c) => c['bom-ref']))),
354
+ version: 1,
355
+ metadata: {
356
+ timestamp: meta.startedAt || (aibom && aibom.generatedAt) || new Date().toISOString(),
357
+ tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: meta.engineVersion || 'dev' }],
358
+ component: { type: 'application', name: 'scan-target', version: '1.0.0' },
359
+ },
360
+ components,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Mechanical validation of an ML-BOM.
366
+ *
367
+ * STRUCTURAL, not full JSON-Schema validation: fetching the official CycloneDX
368
+ * schema at scan time would break the no-runtime-network rule, and vendoring it
369
+ * would add a file that silently rots against upstream. So this checks the
370
+ * required fields and the ML-BOM-specific shape, and SAYS that is what it does —
371
+ * a check labelled "validates against CycloneDX" that only tests a few keys
372
+ * would be the same unverified claim this item exists to remove.
373
+ */
374
+ export function validateMLBOM(doc) {
375
+ const errors = [];
376
+ const req = (cond, msg) => { if (!cond) errors.push(msg); };
377
+
378
+ req(doc && typeof doc === 'object', 'not an object');
379
+ if (!doc || typeof doc !== 'object') return { ok: false, errors, checked: 'structural' };
380
+
381
+ req(doc.bomFormat === 'CycloneDX', `bomFormat must be "CycloneDX", got ${JSON.stringify(doc.bomFormat)}`);
382
+ req(/^1\.[4-9]$/.test(String(doc.specVersion || '')), `specVersion must be 1.4-1.9, got ${JSON.stringify(doc.specVersion)}`);
383
+ req(Number.isInteger(doc.version) && doc.version >= 1, 'version must be a positive integer');
384
+ req(/^urn:uuid:[0-9a-f-]{36}$/i.test(String(doc.serialNumber || '')), 'serialNumber must be a urn:uuid');
385
+ req(doc.metadata && typeof doc.metadata === 'object', 'metadata is required');
386
+ req(Array.isArray(doc.components), 'components must be an array');
387
+
388
+ for (const [i, c] of (Array.isArray(doc.components) ? doc.components : []).entries()) {
389
+ req(typeof c.name === 'string' && c.name, `components[${i}].name is required`);
390
+ req(typeof c.type === 'string' && c.type, `components[${i}].type is required`);
391
+ if (c.type === 'machine-learning-model') {
392
+ req(c.modelCard && typeof c.modelCard === 'object',
393
+ `components[${i}] is a machine-learning-model but carries no modelCard — that is the whole ML-BOM extension`);
394
+ }
395
+ }
396
+ return { ok: errors.length === 0, errors, checked: 'structural (required fields + ML-BOM component shape), NOT full JSON-Schema validation' };
397
+ }
@@ -35,6 +35,11 @@ import * as fs from 'node:fs';
35
35
  import * as path from 'node:path';
36
36
 
37
37
  import { statePath, stateWritesEnabled } from './state-dir.js';
38
+ import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
39
+ import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
40
+
41
+ // Re-exported so existing callers/tests keep importing these from here.
42
+ export { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys };
38
43
  const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
39
44
  function _readJson(fp) {
40
45
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
@@ -118,11 +123,6 @@ export function loadFramework(scanRoot, id) {
118
123
  // `k8s-pod-privileged`; `nist-csf-2.json`/`hipaa-security-rule.json` map to
119
124
  // the compliance-side spelling `k8s-pod-security-privileged`, which no
120
125
  // detector ever emitted.
121
- const COMPLIANCE_FAMILY_ALIAS = {
122
- 'auth-missing': ['broken-access-control', 'fastapi-missing-auth', 'springboot-missing-authz', 'laravel-missing-auth', 'quarkus-missing-authz'],
123
- 'authz': ['broken-access-control', 'idor', 'springboot-missing-authz', 'quarkus-missing-authz'],
124
- 'k8s-pod-security-privileged': ['k8s-pod-privileged'],
125
- };
126
126
 
127
127
  // CMP-1 audit trail: every `family:` string referenced by the bundled
128
128
  // compliance-frameworks/*.json files was cross-checked against real
@@ -130,18 +130,63 @@ const COMPLIANCE_FAMILY_ALIAS = {
130
130
  // real detector to alias, and `mcp-audit.js`/`sca/dep-confusion.js` were
131
131
  // fixed at the SOURCE (they now set `family` explicitly) rather than
132
132
  // aliased, since the finding constructors themselves were the root cause.
133
- // Four references have no matching detector at all — not a naming
134
- // mismatch, a genuine coverage gap that would need a new rule, out of
135
- // scope for an alias table: `crypto-tls-version` (crypto-protocol.js checks
136
- // verify-disabled, not minimum-TLS-versionthe file's own header comment
137
- // even names the never-implemented `crypto-tls-min-version` rule id),
138
- // `nosql-injection` (referenced by cross-lang-meta.js's chain-detection
139
- // list, but no dedicated NoSQL-injection detector exists), `pii-exposure`
140
- // and `data-exposure` (both referenced only by consumers — threat-model-
141
- // auto.js's classifier and a Juliet-benchmark answer-key label
142
- // respectively with no producer). Every control mapped to one of these
143
- // four reads `manual`/`engine-gap` rather than a false `present`, which is
144
- // the safe failure mode this whole mechanism exists to guarantee.
133
+ //
134
+ // CORRECTION (measured, not read): this comment used to claim four families
135
+ // had no producer at all `crypto-tls-version`, `nosql-injection`,
136
+ // `pii-exposure` and `data-exposure`and that every control mapped to one
137
+ // of them therefore read `manual`/`engine-gap` rather than a false `present`.
138
+ // BOTH halves were wrong. Nothing implemented the engine-gap treatment (see
139
+ // COMPLIANCE_FAMILY_GAPS below), and all four families DO have producers:
140
+ // a sweep of 331 real scan roots (bench/family-producers/OBSERVED.json)
141
+ // observed crypto-tls-version, nosql-injection and data-exposure twice each,
142
+ // and `pii-exposure` is emitted by dataflow/privacy-taint.js:129.
143
+ //
144
+ // The lesson is recorded because it cost a wrong commit: a family is not a
145
+ // gap because a grep or a source comment says so. Several detectors pass
146
+ // `family` POSITIONALLY (`_shape(file, line, ruleId, vuln, fam, …)` in
147
+ // cloud-iam.js, crypto-protocol.js, k8s-admission.js, ml-supply-chain.js),
148
+ // so no textual search enumerates the real vocabulary. Only running the
149
+ // engine does.
150
+
151
+ // ...which the prose above ASSERTED but nothing enforced. A `family:` mapping
152
+ // with no detector behind it produces an empty bucket, and an empty bucket was
153
+ // reported as `✓ no open critical/high findings` — identical to a genuine pass.
154
+ // So the four known-unevidenceable families read as fully evidenced controls,
155
+ // which is precisely the outcome the comment says is avoided.
156
+ //
157
+ // Declaring them here makes the claim load-bearing: the evaluator consults this
158
+ // map (see the `family:` branch below) and caps such a control at `partial`
159
+ // with an explicit disclosure, and `test/compliance-mapping-liveness.test.js`
160
+ // fails if an entry loses its reason, duplicates an alias, or stops being
161
+ // referenced by any framework.
162
+ //
163
+ // Membership is deliberately conservative — these four come from the CMP-1
164
+ // audit recorded above, each confirmed to be referenced only by CONSUMER
165
+ // tables (attack-taxonomy, risk-dollars, threat-model classifiers) with no
166
+ // producing detector. A family is NOT added here merely because a static grep
167
+ // or a fixture sweep failed to observe it: several detectors pass `family`
168
+ // positionally (`_shape(file, line, ruleId, vuln, fam, …)` in cloud-iam.js,
169
+ // crypto-protocol.js, k8s-admission.js, ml-supply-chain.js), so neither source
170
+ // enumerates the real vocabulary on its own. Wrongly declaring a live family a
171
+ // gap would suppress a control that does work.
172
+ export const COMPLIANCE_FAMILY_GAPS = {
173
+ // Deliberately EMPTY. Every family currently mapped by a bundled framework
174
+ // has a producer, verified against bench/family-producers/OBSERVED.json.
175
+ //
176
+ // The mechanism below is retained because the hazard is real and structural:
177
+ // the evaluator resolves `family:X` against a Map keyed by finding.family, so
178
+ // a mapping nothing produces yields an empty bucket, and an empty bucket used
179
+ // to render as `✓ no open critical/high findings` — a pass nothing checked.
180
+ // An entry here caps such a control at `partial` with an explicit disclosure
181
+ // instead.
182
+ //
183
+ // Adding an entry requires EVIDENCE that no detector emits the family — a
184
+ // sweep that does not observe it is a lower bound, not proof (SCA families
185
+ // need network access, and some rules need shapes no corpus entry has).
186
+ // Wrongly declaring a live family a gap silently suppresses a control that
187
+ // works, which is the mirror image of the bug this prevents.
188
+ };
189
+
145
190
  export function evaluateFramework(scanRoot, fw, scan) {
146
191
  // CMP-2: last-scan.json (what this is actually handed in production) carries
147
192
  // findings across four separate channels — SAST (`findings`), secrets,
@@ -174,7 +219,19 @@ export function evaluateFramework(scanRoot, fw, scan) {
174
219
 
175
220
  if (maps.length === 0) {
176
221
  obs.push('No automated mapping — requires manual evidence collection.');
177
- results.push({ control: c, status, observations: obs });
222
+ // PRD F10.2: carry the MEASURED strength of the backing detector, so a
223
+ // control mapped to a detector that finds 3 of 18 independent advisories
224
+ // cannot read the same as one backed by a detector that finds nearly
225
+ // everything. Import is lazy so the evaluator keeps working if the bench
226
+ // artifacts are absent (they degrade to `unmeasured`, never to a default).
227
+ let evidence = null;
228
+ try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
229
+ results.push({
230
+ control: c,
231
+ status,
232
+ observations: obs,
233
+ ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
234
+ });
178
235
  continue;
179
236
  }
180
237
 
@@ -207,8 +264,32 @@ export function evaluateFramework(scanRoot, fw, scan) {
207
264
  // filtering is safe; findings with no subfamily set still count
208
265
  // (recall-preserving default — same precedent as relevance.js).
209
266
  const [fam, subfam] = m.slice('family:'.length).split(':');
210
- const aliasFams = COMPLIANCE_FAMILY_ALIAS[fam] || [];
211
- const candidates = [fam, ...aliasFams].flatMap(k => families.get(k) || []);
267
+ // No detector produces this family, so its bucket is empty on EVERY
268
+ // scan. Reporting that as "no open findings" is a pass nothing checked
269
+ // — disclose it and cap the control at 'partial' (the same treatment a
270
+ // `rule:` mapping gets, and for the same reason: unverifiable is not
271
+ // evidence). Deliberately does NOT clear allCleared — the control is
272
+ // unknown, not failing.
273
+ if (COMPLIANCE_FAMILY_GAPS[fam]) {
274
+ obs.push(`⚠ ${fam}: no detector can evidence this control (engine-gap) — ${COMPLIANCE_FAMILY_GAPS[fam]}`);
275
+ anySignal = true;
276
+ hasUnverifiableMapping = true;
277
+ continue;
278
+ }
279
+ // Several detectors emit the family as `<family>-<rule-slug>` — the
280
+ // observed vocabulary holds `prompt-injection-http-user-input-in-llm-`,
281
+ // `xpath-injection-query-built-via-string-c` and similar. This lookup
282
+ // used to be an exact Map key read, so `family:prompt-injection`
283
+ // matched none of them and the control read as evidenced whatever the
284
+ // scan found. That silenced LLM01 (Prompt Injection — the FIRST control
285
+ // of the OWASP LLM Top 10), ASVS V5.1 and NIST AI 600-1 MG-3.2-005.
286
+ //
287
+ // The `-` separator is load-bearing, not cosmetic: a bare substring or
288
+ // prefix test would let `nosql-injection` satisfy a `sql-injection`
289
+ // mapping, silently merging two different vulnerability classes. A test
290
+ // pins that boundary in the failing direction.
291
+ const candidates = resolveFamilyKeys(fam, families.keys())
292
+ .flatMap(k => families.get(k) || []);
212
293
  const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
213
294
  const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
214
295
  if (open.length) {
@@ -282,13 +363,48 @@ export function evaluateFramework(scanRoot, fw, scan) {
282
363
  // any control that already reads 'partial' because of an inherently
283
364
  // unverifiable rule: mapping — that precedent (rule: caps at 'partial',
284
365
  // never reaching 'present' OR 'absent') is unchanged.
366
+ // PRD F10.3: a control NIST/the framework rates as not code-testable must
367
+ // never read as evidenced by this tool. Two shapes were reaching 'present'
368
+ // that should not have:
369
+ //
370
+ // codeTestable:'no' — organisational (policy, training, governance).
371
+ // Nothing a scanner observes can satisfy it.
372
+ // codeTestable:'partial' — the only mappings are `module:` artifact
373
+ // EXISTENCE checks. "threat-model.json is
374
+ // present" is not evidence that threat modelling
375
+ // happened; it is evidence a file exists.
376
+ //
377
+ // Both are capped at 'partial' via the same unverifiable-mapping path a
378
+ // `rule:` mapping already uses, and the reason is stated in the
379
+ // observations so a reader is told WHY rather than left to infer it.
380
+ if (c.codeTestable === 'no') {
381
+ obs.push('⚠ this control is organisational, not code-testable — a scanner cannot evidence it (codeTestable: no).');
382
+ hasUnverifiableMapping = true;
383
+ anySignal = true;
384
+ } else if (c.codeTestable === 'partial' && maps.every(m => !m.startsWith('family:'))) {
385
+ obs.push('⚠ backed only by artifact-existence checks — a present file is weaker evidence than a detector finding nothing (codeTestable: partial).');
386
+ hasUnverifiableMapping = true;
387
+ }
388
+
285
389
  if (!anySignal) status = 'manual';
286
390
  else if (hasUnverifiableMapping) status = 'partial';
287
391
  else if (allCleared) status = 'present';
288
392
  else if (!anyCleared) status = 'absent';
289
393
  else status = 'partial';
290
394
 
291
- results.push({ control: c, status, observations: obs });
395
+ // PRD F10.2: carry the MEASURED strength of the backing detector, so a
396
+ // control mapped to a detector that finds 3 of 18 independent advisories
397
+ // cannot read the same as one backed by a detector that finds nearly
398
+ // everything. Import is lazy so the evaluator keeps working if the bench
399
+ // artifacts are absent (they degrade to `unmeasured`, never to a default).
400
+ let evidence = null;
401
+ try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
402
+ results.push({
403
+ control: c,
404
+ status,
405
+ observations: obs,
406
+ ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
407
+ });
292
408
  }
293
409
  return results;
294
410
  }
@@ -4,29 +4,56 @@
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
+ "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
+ "controlsDigest": "ebc1f708c329ab42",
9
+ "controlCount": 4,
7
10
  "controls": [
8
11
  {
9
12
  "id": "§1798.100",
10
13
  "summary": "Consumer right to know — businesses inform consumers about categories of personal info collected.",
11
- "evidence": ["DPIA artifact lists every PII field with file:line provenance."],
12
- "mapsTo": ["module:privacy-taint"]
14
+ "codeTestable": "partial",
15
+ "evidence": [
16
+ "DPIA artifact lists every PII field with file:line provenance."
17
+ ],
18
+ "mapsTo": [
19
+ "module:privacy-taint"
20
+ ]
13
21
  },
14
22
  {
15
23
  "id": "§1798.105",
16
24
  "summary": "Right to delete — businesses comply with verified deletion requests.",
17
- "evidence": ["No data-exposure findings on PII storage paths."]
25
+ "codeTestable": "no",
26
+ "evidence": [
27
+ "No data-exposure findings on PII storage paths."
28
+ ]
18
29
  },
19
30
  {
20
31
  "id": "§1798.150",
21
32
  "summary": "Civil action for security breach affecting personal information.",
22
- "evidence": ["Crypto findings cleared on PII handling code.", "TLS pinning checks pass."],
23
- "mapsTo": ["family:crypto-weak-cipher", "family:crypto-tls-version", "family:crypto-tls-no-verify"]
33
+ "codeTestable": "yes",
34
+ "evidence": [
35
+ "Crypto findings cleared on PII handling code.",
36
+ "TLS pinning checks pass."
37
+ ],
38
+ "mapsTo": [
39
+ "family:crypto-weak-cipher",
40
+ "family:crypto-tls-version",
41
+ "family:crypto-tls-no-verify"
42
+ ]
24
43
  },
25
44
  {
26
45
  "id": "§1798.81.5",
27
46
  "summary": "Reasonable security procedures and practices.",
28
- "evidence": ["No critical findings on user-data endpoints.", "Auth-missing zero on PII paths."],
29
- "mapsTo": ["family:auth-missing", "family:authz", "family:hardcoded-secret"]
47
+ "codeTestable": "yes",
48
+ "evidence": [
49
+ "No critical findings on user-data endpoints.",
50
+ "Auth-missing zero on PII paths."
51
+ ],
52
+ "mapsTo": [
53
+ "family:auth-missing",
54
+ "family:authz",
55
+ "family:hardcoded-secret"
56
+ ]
30
57
  }
31
58
  ]
32
59
  }
@@ -4,48 +4,99 @@
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
+ "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",
9
+ "controlCount": 7,
7
10
  "controls": [
8
11
  {
9
12
  "id": "Art.9",
10
13
  "summary": "Risk management system — continuous, iterative risk identification and mitigation.",
11
- "evidence": ["Threat model artifact (.agentic-security/threat-model.{json,md}).", "Continuous scan history present."],
12
- "mapsTo": ["module:threat-model-auto", "module:scan-history"]
14
+ "codeTestable": "partial",
15
+ "evidence": [
16
+ "Threat model artifact (.agentic-security/threat-model.{json,md}).",
17
+ "Continuous scan history present."
18
+ ],
19
+ "mapsTo": [
20
+ "module:threat-model-auto",
21
+ "module:scan-history"
22
+ ]
13
23
  },
14
24
  {
15
25
  "id": "Art.10",
16
26
  "summary": "Data governance — training/validation data is relevant, representative, and bias-checked.",
17
- "evidence": ["No training-data-pii findings.", "DPIA artifact for any PII handling."],
18
- "mapsTo": ["family:training-data-pii", "family:pii-exposure"]
27
+ "codeTestable": "yes",
28
+ "evidence": [
29
+ "No training-data-pii findings.",
30
+ "DPIA artifact for any PII handling."
31
+ ],
32
+ "mapsTo": [
33
+ "family:training-data-pii",
34
+ "family:pii-exposure"
35
+ ]
19
36
  },
20
37
  {
21
38
  "id": "Art.11",
22
39
  "summary": "Technical documentation — system architecture, components, training procedures documented.",
23
- "evidence": ["AIBOM artifact at .agentic-security/aibom.json.", "Threat model + DPIA + compliance-evidence present."],
24
- "mapsTo": ["module:aibom", "module:threat-model-auto", "module:compliance-policy"]
40
+ "codeTestable": "partial",
41
+ "evidence": [
42
+ "AIBOM artifact at .agentic-security/aibom.json.",
43
+ "Threat model + DPIA + compliance-evidence present."
44
+ ],
45
+ "mapsTo": [
46
+ "module:aibom",
47
+ "module:threat-model-auto",
48
+ "module:compliance-policy"
49
+ ]
25
50
  },
26
51
  {
27
52
  "id": "Art.12",
28
53
  "summary": "Record-keeping — automatic logging of system events for traceability.",
29
- "evidence": ["MCP audit log .agentic-security/mcp-audit.log present.", "Scan history retained."],
30
- "mapsTo": ["module:mcp-audit", "module:scan-history"]
54
+ "codeTestable": "partial",
55
+ "evidence": [
56
+ "MCP audit log .agentic-security/mcp-audit.log present.",
57
+ "Scan history retained."
58
+ ],
59
+ "mapsTo": [
60
+ "module:mcp-audit",
61
+ "module:scan-history"
62
+ ]
31
63
  },
32
64
  {
33
65
  "id": "Art.13",
34
66
  "summary": "Transparency — instructions for use enable users to interpret the system's output correctly.",
35
- "evidence": ["why-fired annotation surfaces detection provenance on every finding."],
36
- "mapsTo": ["module:why-fired"]
67
+ "codeTestable": "partial",
68
+ "evidence": [
69
+ "why-fired annotation surfaces detection provenance on every finding."
70
+ ],
71
+ "mapsTo": [
72
+ "module:why-fired"
73
+ ]
37
74
  },
38
75
  {
39
76
  "id": "Art.14",
40
77
  "summary": "Human oversight — system permits humans to override / interrupt.",
41
- "evidence": ["Fix application requires confirm:true.", "Bodyguard hook can refuse risky edits."],
42
- "mapsTo": ["module:pre-edit-bodyguard", "module:apply-fix"]
78
+ "codeTestable": "partial",
79
+ "evidence": [
80
+ "Fix application requires confirm:true.",
81
+ "Bodyguard hook can refuse risky edits."
82
+ ],
83
+ "mapsTo": [
84
+ "module:pre-edit-bodyguard",
85
+ "module:apply-fix"
86
+ ]
43
87
  },
44
88
  {
45
89
  "id": "Art.15",
46
90
  "summary": "Accuracy, robustness, cybersecurity — appropriate level of accuracy and resilience.",
47
- "evidence": ["Calibration + held-out evaluation present (.agentic-security/calibration-seed.json).", "OWASP Benchmark regression gate."],
48
- "mapsTo": ["module:calibration", "module:holdout-eval"]
91
+ "codeTestable": "partial",
92
+ "evidence": [
93
+ "Calibration + held-out evaluation present (.agentic-security/calibration-seed.json).",
94
+ "OWASP Benchmark regression gate."
95
+ ],
96
+ "mapsTo": [
97
+ "module:calibration",
98
+ "module:holdout-eval"
99
+ ]
49
100
  }
50
101
  ]
51
102
  }