@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
@@ -4,66 +4,135 @@
4
4
  "publisher": "OWASP Foundation",
5
5
  "license": "Creative Commons Attribution-ShareAlike 4.0",
6
6
  "url": "https://owasp.org/www-project-top-10-for-large-language-model-applications/",
7
+ "scope": "FULL TOP-10. All ten categories are present. Each category is a broad class rather than a single testable requirement, so a mapping indicates a detector exists for part of the class, not that the class is fully covered.",
8
+ "controlsDigest": "0a0351a9536790e4",
9
+ "controlCount": 10,
7
10
  "controls": [
8
11
  {
9
12
  "id": "LLM01",
10
13
  "summary": "Prompt Injection — controls preventing injected instructions from overriding the system prompt.",
11
- "evidence": ["Zero open findings in family prompt-injection / llm-app-security.", "Prompt-template integrity check passing."],
12
- "mapsTo": ["family:prompt-injection", "family:llm-app-security", "family:prompt-integrity"]
14
+ "codeTestable": "yes",
15
+ "evidence": [
16
+ "Zero open findings in family prompt-injection / llm-app-security.",
17
+ "Prompt-template integrity check passing."
18
+ ],
19
+ "mapsTo": [
20
+ "family:prompt-injection",
21
+ "family:llm-app-security",
22
+ "family:prompt-integrity"
23
+ ]
13
24
  },
14
25
  {
15
26
  "id": "LLM02",
16
27
  "summary": "Insecure Output Handling — LLM output is not directly piped to dangerous sinks (eval/shell/SQL/HTML).",
17
- "evidence": ["Zero open findings in subfamily llm-output-untrusted-sink."],
18
- "mapsTo": ["family:llm-app-security:llm-output-untrusted-sink"]
28
+ "codeTestable": "yes",
29
+ "evidence": [
30
+ "Zero open findings in subfamily llm-output-untrusted-sink."
31
+ ],
32
+ "mapsTo": [
33
+ "family:llm-app-security:llm-output-untrusted-sink"
34
+ ]
19
35
  },
20
36
  {
21
37
  "id": "LLM03",
22
38
  "summary": "Training Data Poisoning — provenance and integrity controls on training/fine-tuning data.",
23
- "evidence": ["No streaming-dataset-url findings.", "datasets.load_dataset uses pinned revisions."],
24
- "mapsTo": ["family:streaming-dataset-url", "family:hf-datasets-rce"]
39
+ "codeTestable": "yes",
40
+ "evidence": [
41
+ "No streaming-dataset-url findings.",
42
+ "datasets.load_dataset uses pinned revisions."
43
+ ],
44
+ "mapsTo": [
45
+ "family:streaming-dataset-url",
46
+ "family:hf-datasets-rce"
47
+ ]
25
48
  },
26
49
  {
27
50
  "id": "LLM04",
28
51
  "summary": "Model Denial of Service — rate-limit / token-cap controls.",
29
- "evidence": ["LLM API calls in source carry an explicit max_tokens cap (bodyguard rule no-max-tokens)."],
30
- "mapsTo": ["rule:no-max-tokens"]
52
+ "codeTestable": "no",
53
+ "evidence": [
54
+ "LLM API calls in source carry an explicit max_tokens cap (bodyguard rule no-max-tokens)."
55
+ ],
56
+ "mapsTo": [
57
+ "rule:no-max-tokens"
58
+ ]
31
59
  },
32
60
  {
33
61
  "id": "LLM05",
34
62
  "summary": "Supply Chain — model & dataset supply-chain integrity.",
35
- "evidence": ["No mlflow-untrusted-uri / model-format / hf-endpoint-override findings.", "Sigstore provenance verification configured if opt-in."],
36
- "mapsTo": ["family:mlflow-untrusted-uri", "family:model-format", "family:hf-endpoint-override"]
63
+ "codeTestable": "yes",
64
+ "evidence": [
65
+ "No mlflow-untrusted-uri / model-format / hf-endpoint-override findings.",
66
+ "Sigstore provenance verification configured if opt-in."
67
+ ],
68
+ "mapsTo": [
69
+ "family:mlflow-untrusted-uri",
70
+ "family:model-format",
71
+ "family:hf-endpoint-override"
72
+ ]
37
73
  },
38
74
  {
39
75
  "id": "LLM06",
40
76
  "summary": "Sensitive Information Disclosure — output filters prevent leakage.",
41
- "evidence": ["Zero findings in subfamily llm-credential-in-prompt.", "Redact module covers prompt + response."],
42
- "mapsTo": ["family:llm-app-security:llm-credential-in-prompt"]
77
+ "codeTestable": "yes",
78
+ "evidence": [
79
+ "Zero findings in subfamily llm-credential-in-prompt.",
80
+ "Redact module covers prompt + response."
81
+ ],
82
+ "mapsTo": [
83
+ "family:llm-app-security:llm-credential-in-prompt"
84
+ ]
43
85
  },
44
86
  {
45
87
  "id": "LLM07",
46
88
  "summary": "Insecure Plugin / Tool Design — tools the LLM can invoke are narrowly scoped.",
47
- "evidence": ["Zero llm-app-security:llm-tool-exec findings.", "Agent tool definitions reviewed for excessive agency."],
48
- "mapsTo": ["family:agent-tool-exec", "family:llm-app-security:llm-tool-exec"]
89
+ "codeTestable": "yes",
90
+ "evidence": [
91
+ "Zero llm-app-security:llm-tool-exec findings.",
92
+ "Agent tool definitions reviewed for excessive agency."
93
+ ],
94
+ "mapsTo": [
95
+ "family:agent-tool-exec",
96
+ "family:llm-app-security:llm-tool-exec"
97
+ ]
49
98
  },
50
99
  {
51
100
  "id": "LLM08",
52
101
  "summary": "Excessive Agency — agent autonomy is bounded by explicit guards.",
53
- "evidence": ["MCP server config limits write-tools to confirm:true + reserved-write-path refusal.", "Apply-fix path requires HMAC-verified scan."],
54
- "mapsTo": ["module:mcp-tools", "module:apply-fix"]
102
+ "codeTestable": "partial",
103
+ "evidence": [
104
+ "MCP server config limits write-tools to confirm:true + reserved-write-path refusal.",
105
+ "Apply-fix path requires HMAC-verified scan."
106
+ ],
107
+ "mapsTo": [
108
+ "module:mcp-tools",
109
+ "module:apply-fix"
110
+ ]
55
111
  },
56
112
  {
57
113
  "id": "LLM09",
58
114
  "summary": "Overreliance — human review checkpoints exist.",
59
- "evidence": ["security-fixer agent has Stop-on-verify-fail loop.", "PreToolUse bodyguard exists."],
60
- "mapsTo": ["module:security-fixer", "module:pre-edit-bodyguard"]
115
+ "codeTestable": "partial",
116
+ "evidence": [
117
+ "security-fixer agent has Stop-on-verify-fail loop.",
118
+ "PreToolUse bodyguard exists."
119
+ ],
120
+ "mapsTo": [
121
+ "module:security-fixer",
122
+ "module:pre-edit-bodyguard"
123
+ ]
61
124
  },
62
125
  {
63
126
  "id": "LLM10",
64
127
  "summary": "Model Theft — model access controls.",
65
- "evidence": ["No private-key-in-frontend or rpc-key-inline findings in client code."],
66
- "mapsTo": ["family:private-key-in-frontend", "family:rpc-key-inline"]
128
+ "codeTestable": "yes",
129
+ "evidence": [
130
+ "No private-key-in-frontend or rpc-key-inline findings in client code."
131
+ ],
132
+ "mapsTo": [
133
+ "family:private-key-in-frontend",
134
+ "family:rpc-key-inline"
135
+ ]
67
136
  }
68
137
  ]
69
138
  }
@@ -106,6 +106,13 @@ function grabBody(text, openBraceIdx) {
106
106
  return null;
107
107
  }
108
108
 
109
+ // Lock names come from a `\w+` capture so they cannot currently carry regex
110
+ // metacharacters, but the guards below interpolate them into a pattern — escape
111
+ // so that stays true if a capture ever widens.
112
+ function escapeRe(s) {
113
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
+ }
115
+
109
116
  function findMissedUnlocks(fn, lang) {
110
117
  const out = [];
111
118
  const p = PATTERNS[lang];
@@ -128,10 +135,32 @@ function findMissedUnlocks(fn, lang) {
128
135
  // Lock+unlock both present, but check that the function has a defer/
129
136
  // try-finally guarantee. Go: `defer`; Java/Py: try/finally; otherwise
130
137
  // early-return-before-unlock is a risk.
138
+ //
139
+ // The guard must accept a QUALIFIED receiver (`defer s.mu.Unlock()`,
140
+ // `with self.lock:`), not just a bare one. A mutex is usually a struct
141
+ // field or an instance attribute, so the qualified form is the common
142
+ // shape in real code — and the acquire patterns already matched it
143
+ // (`\b(\w+)\.Lock\(\)` captures `mu` from `s.mu.Lock()`). While the two
144
+ // halves disagreed, correct idiomatic code was the most likely to be
145
+ // reported: on a 12-entry Go sample this was the bulk of the module's
146
+ // output.
147
+ //
148
+ // It is also matched PER LOCK NAME rather than body-wide. A function
149
+ // holding two mutexes must not have its second lock cleared just because
150
+ // the first one is deferred.
151
+ // The receiver prefix is `[\w$.]*` — a single character class — rather
152
+ // than the more obvious `(?:[\w$]+\.)*`, which nests a quantifier inside
153
+ // a quantifier and is exactly the catastrophic-backtracking shape this
154
+ // project's own CWE-1333 detector flags (it caught this line in the
155
+ // self-scan). `\b` before the lock name is what keeps the flat form
156
+ // precise: without it, `[\w$.]*` would happily match `s.not` and let
157
+ // `defer s.notmu.Unlock()` guard a lock named `mu`.
158
+ const q = escapeRe(name);
131
159
  const guarded =
132
- (lang === 'go' && /defer\s+\w+\.Unlock\(\)/.test(fn.body)) ||
160
+ (lang === 'go' && new RegExp(`defer\\s+[\\w$.]*\\b${q}\\.Unlock\\(\\)`).test(fn.body)) ||
133
161
  (lang === 'java' && /try\s*\{[\s\S]*finally\s*\{[\s\S]*\.unlock\(\)/m.test(fn.body)) ||
134
- (lang === 'py' && (/with\s+\w+:/.test(fn.body) || /try\s*:[\s\S]*finally\s*:[\s\S]*\.release\(\)/m.test(fn.body)));
162
+ (lang === 'py' && (new RegExp(`with\\s+[^:\\n]*\\b${q}\\b[^:\\n]*:`).test(fn.body)
163
+ || /try\s*:[\s\S]*finally\s*:[\s\S]*\.release\(\)/m.test(fn.body)));
135
164
  if (!guarded && /\breturn\b/.test(fn.body)) {
136
165
  out.push({
137
166
  kind: 'unguarded-lock',
@@ -229,7 +258,13 @@ export function scanConcurrency(fileContents) {
229
258
  ? `Concurrency: ${fn.name}() acquires ${bug.lock} but no matching unlock`
230
259
  : `Concurrency: ${fn.name}() can return without releasing ${bug.lock}`,
231
260
  severity: 'medium',
232
- family: 'concurrency-bug',
261
+ // CWE-667 Improper Locking. The findings schema in CLAUDE.md requires a
262
+ // cwe on every finding, and these carried none — so they arrived in
263
+ // every CWE-keyed report as null and could never match an advisory
264
+ // label. Found while auditing the wrong-CWE bucket: 57% of findings
265
+ // on a Go/Ruby sample had no cwe at all.
266
+ cwe: 'CWE-667',
267
+ family: 'concurrency-bug', parser: 'CONCURRENCY',
233
268
  confidence: 0.5,
234
269
  remediation: bug.remediation || 'Release the lock on every exit path (defer / try-finally / context manager).',
235
270
  });
@@ -241,7 +276,8 @@ export function scanConcurrency(fileContents) {
241
276
  line: bug.startLine,
242
277
  vuln: `Concurrency: fire-and-forget async call in ${fn.name}() — result not awaited`,
243
278
  severity: 'low',
244
- family: 'concurrency-bug',
279
+ cwe: 'CWE-703', // Improper Check or Handling of Exceptional Conditions
280
+ family: 'concurrency-bug', parser: 'CONCURRENCY',
245
281
  confidence: 0.4,
246
282
  remediation: 'Await the promise / call .get() on the future / use asyncio.gather.',
247
283
  });
@@ -255,7 +291,8 @@ export function scanConcurrency(fileContents) {
255
291
  line: bug.startLineA,
256
292
  vuln: `Concurrency: potential deadlock — ${bug.order}`,
257
293
  severity: 'high',
258
- family: 'concurrency-bug',
294
+ cwe: 'CWE-833', // Deadlock
295
+ family: 'concurrency-bug', parser: 'CONCURRENCY',
259
296
  confidence: 0.4,
260
297
  remediation: 'Acquire locks in a consistent global order across all call sites.',
261
298
  });
@@ -0,0 +1,182 @@
1
+ // Measured evidence strength for a compliance control (PRD F10.2).
2
+ //
3
+ // THE PROBLEM THIS EXISTS FOR
4
+ // ---------------------------
5
+ // A control mapped to a detector with 5% recall is COVERED in the coverage map
6
+ // and UNCOVERED in reality. The map said "family:sql-injection → checked" and
7
+ // stopped there, so a reader could not tell a control backed by a detector that
8
+ // finds nearly everything from one backed by a detector that finds almost
9
+ // nothing. The PRD calls closing that its highest-integrity change, and this is
10
+ // the closing.
11
+ //
12
+ // THE JOIN
13
+ // --------
14
+ // control → mapped family
15
+ // → producing detector (posture/family-registry.js)
16
+ // → the CWEs that family carries (bench/family-producers/OBSERVED.json)
17
+ // → measured recall for those CWEs (bench/family-producers/RECALL.json)
18
+ //
19
+ // The recall numbers come from bench/independent: advisories mined from public
20
+ // sources, NOT authored here. That matters more than the numbers themselves —
21
+ // a control cannot look well-evidenced because the engine graded its own
22
+ // homework.
23
+ //
24
+ // WHAT "UNMEASURED" MEANS, AND WHY IT IS NOT "FINE"
25
+ // ------------------------------------------------
26
+ // A CWE the independent corpus never exercised has NO recall figure. That is
27
+ // unmeasured, and unmeasured is reported as unmeasured — never silently treated
28
+ // as passing, and never given a default number. Roughly half the observed
29
+ // families are in that state today. Saying so is the honest output; inventing a
30
+ // figure for them would be the failure this module exists to prevent.
31
+ //
32
+ // Every rate is carried as {n, d} and rendered through the caller's formatter,
33
+ // following posture/accuracy-scorecard.js: a percentage must never appear
34
+ // without its denominator, because "50% recall" over two samples is noise.
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { producersOf } from './family-registry.js';
39
+ import { resolveFamilyKeys } from './family-resolve.js';
40
+
41
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
42
+ const BENCH = path.join(HERE, '..', '..', '..', 'bench', 'family-producers');
43
+
44
+ // Thresholds. Deliberately coarse — the underlying denominators are small, and a
45
+ // finer scale would imply a precision the measurement does not have.
46
+ const WEAK_BELOW = 0.25;
47
+ const PARTIAL_BELOW = 0.60;
48
+
49
+ // Below this many scored entries a rate is reported but flagged as indicative,
50
+ // mirroring accuracy-scorecard.js's `reliable:false` treatment.
51
+ const RELIABLE_MIN_D = 5;
52
+
53
+ let _cache = null;
54
+
55
+ function _load() {
56
+ if (_cache) return _cache;
57
+ const read = (f, fallback) => {
58
+ try { return JSON.parse(fs.readFileSync(path.join(BENCH, f), 'utf8')); }
59
+ catch { return fallback; }
60
+ };
61
+ // A missing artifact must degrade to "unmeasured", never to a default rate —
62
+ // same reasoning as the unmeasured tier itself.
63
+ const observed = read('OBSERVED.json', { families: {} });
64
+ const recall = read('RECALL.json', { byCwe: {} });
65
+ _cache = { families: observed.families || {}, byCwe: recall.byCwe || {}, population: recall.population || null };
66
+ return _cache;
67
+ }
68
+
69
+ /** Reset the memo. Tests only. */
70
+ export function _resetCache() { _cache = null; }
71
+
72
+ /**
73
+ * The CWEs a family mapping was observed to carry, most frequent first.
74
+ *
75
+ * Resolves the mapping the SAME way the evaluator does — exact, alias, or
76
+ * `<base>-<rule-slug>` suffix — via the shared resolver. Looking `family` up as
77
+ * a literal key reported "unmeasured" for every aliased mapping: ASVS V5.1 maps
78
+ * to `family:sqli`, nothing emits `sqli`, and the real family `sql-injection` IS
79
+ * measured. That would be a false "no evidence" verdict on a control that has
80
+ * evidence.
81
+ */
82
+ export function cwesFor(family) {
83
+ const { families } = _load();
84
+ const keys = resolveFamilyKeys(family, Object.keys(families));
85
+ const counts = new Map();
86
+ for (const k of keys) {
87
+ for (const [cwe, n] of Object.entries((families[k] || {}).cwes || {})) {
88
+ counts.set(cwe, (counts.get(cwe) || 0) + n);
89
+ }
90
+ }
91
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c);
92
+ }
93
+
94
+ /**
95
+ * Measured strength of the detector(s) behind `family`.
96
+ *
97
+ * Returns { tier, recall:{n,d}|null, cwes, measuredCwes, reliable, producers, reason }.
98
+ * `tier` ∈ 'measured' | 'partial' | 'weak' | 'unmeasured'.
99
+ */
100
+ export function strengthOf(family) {
101
+ const { byCwe } = _load();
102
+ const cwes = cwesFor(family);
103
+ const producers = producersOf(family);
104
+
105
+ // Aggregate across every CWE this family carries that the independent corpus
106
+ // actually exercised. Summing {n,d} weights by how much evidence each CWE has,
107
+ // which is what we want — averaging the percentages would let a 1-of-1 CWE
108
+ // outvote a 40-of-100 one.
109
+ let n = 0, d = 0;
110
+ const measuredCwes = [];
111
+ for (const c of cwes) {
112
+ const m = byCwe[c];
113
+ if (!m || !m.recall || !m.recall.d) continue;
114
+ n += m.recall.n; d += m.recall.d;
115
+ measuredCwes.push(c);
116
+ }
117
+
118
+ if (!d) {
119
+ return {
120
+ tier: 'unmeasured',
121
+ recall: null,
122
+ cwes, measuredCwes: [], reliable: false, producers,
123
+ reason: cwes.length
124
+ ? 'no CWE this family carries was exercised by the independent corpus'
125
+ : 'no CWE observed for this family — nothing to measure against',
126
+ };
127
+ }
128
+
129
+ const rate = n / d;
130
+ const tier = rate < WEAK_BELOW ? 'weak' : rate < PARTIAL_BELOW ? 'partial' : 'measured';
131
+ return {
132
+ tier,
133
+ recall: { n, d },
134
+ cwes, measuredCwes,
135
+ reliable: d >= RELIABLE_MIN_D,
136
+ producers,
137
+ reason: `measured over ${d} independent advisor${d === 1 ? 'y' : 'ies'}`,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Strength for a control, taken across every family it maps to.
143
+ *
144
+ * The WEAKEST backing family wins. A control is only as evidenced as its
145
+ * flimsiest leg: claiming the average would let a strong mapping hide a
146
+ * mapping that finds nothing.
147
+ */
148
+ export function strengthOfControl(control) {
149
+ const families = (control && Array.isArray(control.mapsTo) ? control.mapsTo : [])
150
+ .filter(m => typeof m === 'string' && m.startsWith('family:'))
151
+ .map(m => m.slice('family:'.length).split(':')[0]);
152
+
153
+ if (!families.length) return { tier: 'unmeasured', families: [], legs: [], reason: 'control has no family: mapping' };
154
+
155
+ const legs = families.map(f => ({ family: f, ...strengthOf(f) }));
156
+ const ORDER = { unmeasured: 0, weak: 1, partial: 2, measured: 3 };
157
+ const weakest = legs.reduce((a, b) => (ORDER[b.tier] < ORDER[a.tier] ? b : a));
158
+ return {
159
+ tier: weakest.tier,
160
+ families, legs,
161
+ reason: `weakest backing family "${weakest.family}": ${weakest.reason}`,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * True when a control should NOT be presented as cleanly evidenced, because the
167
+ * detector behind it is weak or was never measured. This is the flag the
168
+ * coverage map carries.
169
+ */
170
+ export function isPartiallyEvidenced(control) {
171
+ const s = strengthOfControl(control);
172
+ return s.tier === 'weak' || s.tier === 'unmeasured';
173
+ }
174
+
175
+ /** Human-readable, denominator-carrying label for a report. */
176
+ export function formatStrength(s) {
177
+ if (!s || s.tier === 'unmeasured') return 'unmeasured (no independent evidence)';
178
+ const { n, d } = s.recall || (s.legs && s.legs.length ? s.legs[0].recall : null) || {};
179
+ if (n == null) return `${s.tier} (no rate)`;
180
+ const pct = Math.round((n / d) * 100);
181
+ return `${s.tier} — recall ${n}/${d} (${pct}%)${s.reliable === false ? ', indicative only' : ''}`;
182
+ }
@@ -38,12 +38,28 @@ function cachePath(cveListKey) {
38
38
  return path.join(CACHE_DIR, h + '.json');
39
39
  }
40
40
 
41
+ // PRD F3.4 — record how old the EPSS data actually is.
42
+ //
43
+ // EPSS scores decay in relevance: a probability computed months ago describes a
44
+ // threat landscape that has moved. Unlike KEV, a stale EPSS score can err in
45
+ // EITHER direction, so the age is reported rather than the value suppressed.
46
+ let _epssMeta = { source: 'not-loaded', ageDays: null, stale: null };
47
+ const EPSS_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
48
+ export function epssCacheMeta() { return { ..._epssMeta }; }
49
+
41
50
  function readCache(key) {
42
51
  const fp = cachePath(key);
43
52
  if (!fs.existsSync(fp)) return null;
44
53
  try {
45
54
  const stat = fs.statSync(fp);
46
- if (Date.now() - stat.mtimeMs > TTL_MS) return null;
55
+ const age = Date.now() - stat.mtimeMs;
56
+ if (age > TTL_MS) return null;
57
+ _epssMeta = {
58
+ source: 'cache',
59
+ ageDays: Math.floor(age / 86400000),
60
+ stale: age > EPSS_STALE_AFTER_MS,
61
+ meaning: 'EPSS is a decaying probability; an old score describes a threat landscape that has moved.',
62
+ };
47
63
  return JSON.parse(fs.readFileSync(fp, 'utf8'));
48
64
  } catch { return null; }
49
65
  }
@@ -0,0 +1,103 @@
1
+ // Producer registry (PRD F10.2) — which detector emits which finding family.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // ---------------
5
+ // Compliance controls map to families (`family:sql-injection`). Answering "is
6
+ // this control backed by a real detector, and how good is that detector?"
7
+ // requires knowing the family vocabulary. Nothing could answer that:
8
+ //
9
+ // - A textual search cannot. Detectors set `family` positionally
10
+ // (`_shape(file, line, ruleId, vuln, fam, …)`) or from a rule table
11
+ // (`family: rule.family`), so grepping `family:` misses them entirely. A
12
+ // search-derived list was used once to declare four families "unevidenceable";
13
+ // all four had producers, and 15 working controls were suppressed as a result.
14
+ // - A corpus sweep cannot. It reports only what some fixture triggered — a
15
+ // LOWER BOUND. `k8s-pod-security-privileged` was absent from a 116-root sweep
16
+ // and present in a 331-root one; nothing about the engine changed.
17
+ //
18
+ // So the detector declares, in the file that emits: `export const EMITS = [...]`.
19
+ // Colocation is the point — the rule and its declaration are edited together, and
20
+ // the gate below catches the case where they are not.
21
+ //
22
+ // SCOPE, STATED HONESTLY
23
+ // ----------------------
24
+ // Registration is per-module and incremental. 287 detector modules exist; the
25
+ // ones listed here are registered so far, chosen because they set `family`
26
+ // positionally (invisible to every other method) and back compliance mappings.
27
+ //
28
+ // A module that is registered is held to COMPLETENESS: a scan producing a family
29
+ // it did not declare fails the gate. A module that is not registered is not
30
+ // silently passing — it is absent, and `test/family-registry.test.js` reports the
31
+ // undeclared remainder as a ratchet that must not grow. That is the difference
32
+ // between a gate that expands over time and one that gets switched off in week
33
+ // two for failing on day one.
34
+ import { EMITS as CLOUD_IAM } from '../sast/cloud-iam.js';
35
+ import { EMITS as CRYPTO_PROTOCOL } from '../sast/crypto-protocol.js';
36
+ import { EMITS as DAPP_FRONTEND } from '../sast/dapp-frontend.js';
37
+ import { EMITS as K8S_ADMISSION } from '../sast/k8s-admission.js';
38
+ import { EMITS as ML_SUPPLY_CHAIN } from '../sast/ml-supply-chain.js';
39
+ import { EMITS as WEB3_ADVANCED } from '../sast/web3-advanced.js';
40
+
41
+ // module path -> declared families. Path is the identity a human greps for.
42
+ export const REGISTERED = Object.freeze({
43
+ 'sast/cloud-iam.js': CLOUD_IAM,
44
+ 'sast/crypto-protocol.js': CRYPTO_PROTOCOL,
45
+ 'sast/dapp-frontend.js': DAPP_FRONTEND,
46
+ 'sast/k8s-admission.js': K8S_ADMISSION,
47
+ 'sast/ml-supply-chain.js': ML_SUPPLY_CHAIN,
48
+ 'sast/web3-advanced.js': WEB3_ADVANCED,
49
+ });
50
+
51
+ // The `parser` field each registered module stamps on its findings. This is how a
52
+ // finding is attributed back to its module at runtime.
53
+ //
54
+ // NOT every parser identifies a module: `REGEX` alone covers 80 distinct
55
+ // families, and `STRUCTURAL` covers 23. Those are shared labels, so a module
56
+ // using one cannot be registered for completeness until it stamps something
57
+ // specific. Only module-specific parsers appear here, and the gate checks
58
+ // attribution only for these.
59
+ export const PARSER_OF = Object.freeze({
60
+ 'sast/cloud-iam.js': 'CLOUD-IAM',
61
+ 'sast/crypto-protocol.js': 'CRYPTO-PROTO',
62
+ 'sast/dapp-frontend.js': 'DAPP-FRONTEND',
63
+ 'sast/k8s-admission.js': 'K8S-ADM',
64
+ 'sast/ml-supply-chain.js': 'ML-SUPPLY',
65
+ 'sast/web3-advanced.js': 'WEB3-ADV',
66
+ });
67
+
68
+ /** Every family any registered module declares. */
69
+ export function declaredFamilies() {
70
+ const out = new Set();
71
+ for (const list of Object.values(REGISTERED)) for (const f of list) out.add(f);
72
+ return out;
73
+ }
74
+
75
+ /** Module paths declaring `family`, or [] if none does. */
76
+ export function producersOf(family) {
77
+ return Object.entries(REGISTERED)
78
+ .filter(([, list]) => list.includes(family))
79
+ .map(([mod]) => mod);
80
+ }
81
+
82
+ /** The module registered for `parser`, or null when the parser is not module-specific. */
83
+ export function moduleForParser(parser) {
84
+ const hit = Object.entries(PARSER_OF).find(([, p]) => p === parser);
85
+ return hit ? hit[0] : null;
86
+ }
87
+
88
+ /**
89
+ * Families a registered module emitted but never declared — the drift this whole
90
+ * mechanism exists to catch. `findings` is any array carrying {family, parser}.
91
+ */
92
+ export function undeclaredFrom(findings) {
93
+ const out = new Map(); // module -> Set(family)
94
+ for (const f of findings || []) {
95
+ if (!f || !f.family || !f.parser) continue;
96
+ const mod = moduleForParser(f.parser);
97
+ if (!mod) continue; // parser is not module-specific
98
+ if (REGISTERED[mod].includes(f.family)) continue;
99
+ if (!out.has(mod)) out.set(mod, new Set());
100
+ out.get(mod).add(f.family);
101
+ }
102
+ return out;
103
+ }
@@ -0,0 +1,47 @@
1
+ // Shared family-name resolution for compliance mappings.
2
+ //
3
+ // Extracted into its own module to break an import CYCLE: the evaluator
4
+ // (auditor-walkthrough.js) needs the resolver to count findings, and
5
+ // coverage-strength.js needs the SAME resolver to measure detector strength. When
6
+ // the resolver lived in the evaluator and the evaluator imported strength back,
7
+ // ESM handed one side an undefined binding and a defensive try/catch turned that
8
+ // into a silently missing `evidence` field — a wrong answer wearing the costume
9
+ // of a working one.
10
+ //
11
+ // One definition, no cycle, both callers import from here.
12
+
13
+ export const COMPLIANCE_FAMILY_ALIAS = {
14
+ // ASVS spells it `sqli`; every detector emits `sql-injection` (or a
15
+ // language-prefixed variant, which the suffix rule below does NOT cover
16
+ // because the prefix is on the wrong end).
17
+ 'sqli': ['sql-injection', 'dart-sql-injection', 'laravel-sql-injection'],
18
+ 'auth-missing': ['broken-access-control', 'fastapi-missing-auth', 'springboot-missing-authz', 'laravel-missing-auth', 'quarkus-missing-authz'],
19
+ 'authz': ['broken-access-control', 'idor', 'springboot-missing-authz', 'quarkus-missing-authz'],
20
+ 'k8s-pod-security-privileged': ['k8s-pod-privileged'],
21
+ };
22
+
23
+ /**
24
+ * The emitted families a `family:X` mapping resolves to, given the families a
25
+ * scan actually produced.
26
+ *
27
+ * Exported because TWO callers must agree: this file's evaluator (does the
28
+ * control have open findings?) and posture/coverage-strength.js (how good is the
29
+ * detector behind it?). When only the evaluator knew the rule, the strength
30
+ * module reported "unmeasured" for every aliased or suffixed mapping — a false
31
+ * "no evidence" verdict on controls that are in fact measured, which is the
32
+ * mirror image of the vacuous-pass bug this file already fixes. One rule, one
33
+ * definition.
34
+ *
35
+ * Resolution is exact match, alias, or `<base>-<rule-slug>` suffix. The `-`
36
+ * separator is load-bearing: without it `nosql-injection` would satisfy a
37
+ * `sql-injection` mapping.
38
+ */
39
+ export function resolveFamilyKeys(fam, availableKeys) {
40
+ const bases = [fam, ...(COMPLIANCE_FAMILY_ALIAS[fam] || [])];
41
+ const out = [];
42
+ for (const key of availableKeys) {
43
+ if (bases.some(b => key === b || key.startsWith(`${b}-`))) out.push(key);
44
+ }
45
+ return out;
46
+ }
47
+