@clear-capabilities/agentic-security-scanner 0.140.0 → 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 (62) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/dist/113.index.js +79 -3
  3. package/dist/178.index.js +1 -1
  4. package/dist/238.index.js +77 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/435.index.js +12 -0
  7. package/dist/526.index.js +79 -3
  8. package/dist/637.index.js +1 -1
  9. package/dist/agentic-security.mjs +14 -14
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/compliance-frameworks/ccpa.json +34 -7
  12. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  13. package/dist/compliance-frameworks/gdpr.json +56 -12
  14. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  15. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  16. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  17. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  18. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  19. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  20. package/package.json +16 -5
  21. package/src/dataflow/catalog.js +61 -0
  22. package/src/engine.js +262 -22
  23. package/src/mcp/tools.js +12 -0
  24. package/src/posture/accuracy-scorecard.js +57 -0
  25. package/src/posture/aibom.js +110 -1
  26. package/src/posture/auditor-walkthrough.js +56 -17
  27. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  28. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  29. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  30. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  31. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  32. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  33. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  34. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  35. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  36. package/src/posture/concurrency-checker.js +3 -3
  37. package/src/posture/coverage-strength.js +182 -0
  38. package/src/posture/epss.js +17 -1
  39. package/src/posture/family-registry.js +103 -0
  40. package/src/posture/family-resolve.js +47 -0
  41. package/src/posture/fix-coverage.js +113 -0
  42. package/src/posture/fix-metrics.js +76 -0
  43. package/src/posture/mcp-rug-pull.js +144 -0
  44. package/src/posture/poc-inprocess.js +217 -1
  45. package/src/posture/proof-coverage.js +162 -0
  46. package/src/posture/reachability-filter.js +44 -0
  47. package/src/posture/sbom.js +12 -3
  48. package/src/runScan.js +56 -5
  49. package/src/sast/CLAUDE.md +2 -2
  50. package/src/sast/claude-md-prompt-injection.js +47 -3
  51. package/src/sast/cloud-iam.js +23 -0
  52. package/src/sast/convention-deviation.js +66 -3
  53. package/src/sast/crypto-protocol.js +23 -0
  54. package/src/sast/dapp-frontend.js +20 -0
  55. package/src/sast/iac-cloud-templates.js +337 -0
  56. package/src/sast/k8s-admission.js +27 -0
  57. package/src/sast/ml-supply-chain.js +22 -0
  58. package/src/sast/ruby.js +132 -0
  59. package/src/sast/web3-advanced.js +26 -0
  60. package/src/sca/CLAUDE.md +21 -4
  61. package/src/sca/container.js +18 -1
  62. 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
  }
@@ -264,7 +264,7 @@ export function scanConcurrency(fileContents) {
264
264
  // label. Found while auditing the wrong-CWE bucket: 57% of findings
265
265
  // on a Go/Ruby sample had no cwe at all.
266
266
  cwe: 'CWE-667',
267
- family: 'concurrency-bug',
267
+ family: 'concurrency-bug', parser: 'CONCURRENCY',
268
268
  confidence: 0.5,
269
269
  remediation: bug.remediation || 'Release the lock on every exit path (defer / try-finally / context manager).',
270
270
  });
@@ -277,7 +277,7 @@ export function scanConcurrency(fileContents) {
277
277
  vuln: `Concurrency: fire-and-forget async call in ${fn.name}() — result not awaited`,
278
278
  severity: 'low',
279
279
  cwe: 'CWE-703', // Improper Check or Handling of Exceptional Conditions
280
- family: 'concurrency-bug',
280
+ family: 'concurrency-bug', parser: 'CONCURRENCY',
281
281
  confidence: 0.4,
282
282
  remediation: 'Await the promise / call .get() on the future / use asyncio.gather.',
283
283
  });
@@ -292,7 +292,7 @@ export function scanConcurrency(fileContents) {
292
292
  vuln: `Concurrency: potential deadlock — ${bug.order}`,
293
293
  severity: 'high',
294
294
  cwe: 'CWE-833', // Deadlock
295
- family: 'concurrency-bug',
295
+ family: 'concurrency-bug', parser: 'CONCURRENCY',
296
296
  confidence: 0.4,
297
297
  remediation: 'Acquire locks in a consistent global order across all call sites.',
298
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
+
@@ -0,0 +1,113 @@
1
+ // PRD F6.5 — publish the proportion of findings this engine DECLINES to fix.
2
+ //
3
+ // A remediation feature that silently attempts everything is less trustworthy
4
+ // than one that declines 40% and says so. Before this, the only visible number
5
+ // was about fixes that were attempted; a finding for which synthesis was never
6
+ // even tried simply did not appear, so the denominator quietly excluded every
7
+ // hard case.
8
+ //
9
+ // THE BUCKETS
10
+ //
11
+ // deterministic — a context-independent literal swap exists (md5 -> sha256,
12
+ // TLS verify off -> on). Highest confidence: the patch does
13
+ // not depend on reading intent.
14
+ // model — no deterministic patch, but the finding is a shape a model
15
+ // can be asked to fix. Counted as ATTEMPTABLE, not as fixed:
16
+ // whether the attempt succeeds is fix-metrics.js's question.
17
+ // declined — synthesis refuses, with a reason. Not a failure; a limit
18
+ // stated up front.
19
+ //
20
+ // `declined` and `model` are kept apart for the same reason proof-coverage
21
+ // separates `indeterminate` from `unclassified`: "we will not try" and "we will
22
+ // try and might fail" are different promises, and merging them lets the weaker
23
+ // one borrow the stronger one's credibility.
24
+ import { synthesizeDeterministicPatch } from './deterministic-fix.js';
25
+
26
+ // Families where a patch cannot be synthesised from the finding alone, with the
27
+ // reason. Stated as DATA so a report can print why, rather than leaving a reader
28
+ // to assume the engine simply has not got round to it.
29
+ export const DECLINED_TO_FIX = Object.freeze({
30
+ 'broken-access-control': 'the correct authorisation rule is a product decision — a scanner that invents one is guessing at intent, and a wrong authz patch fails open.',
31
+ 'idor': 'same as broken-access-control: which identity may read which record is not recoverable from the code.',
32
+ 'broken-authz': 'the rule that was checked wrongly is a product decision; patching it from the code alone guesses at which roles may do what, and guessing fails open.',
33
+ 'business-logic': 'by definition the defect is a mismatch with intent, and intent is not in the file.',
34
+ 'concurrency-bug': 'the correct lock discipline depends on the whole call graph; a local patch can deadlock rather than fix.',
35
+ 'license-graph': 'a licence conflict is resolved by a policy or a dependency decision, not by editing code.',
36
+ 'vulnerable-dep': 'resolved by an upgrade, which is apply_sca_upgrade\'s job, not a source patch.',
37
+ });
38
+
39
+ /** Bucket a finding: 'deterministic' | 'model' | 'declined'. */
40
+ export function fixBucketOf(finding, fileContent) {
41
+ const fam = (finding && finding.family) || '';
42
+ for (const key of Object.keys(DECLINED_TO_FIX)) {
43
+ if (fam === key || fam.startsWith(`${key}-`)) return 'declined';
44
+ }
45
+ if (typeof fileContent === 'string' && fileContent) {
46
+ try {
47
+ const p = synthesizeDeterministicPatch(finding, fileContent);
48
+ if (p && p.ok !== false && (p.patch || p.replacement)) return 'deterministic';
49
+ } catch { /* fall through — an erroring synthesiser is not a fix */ }
50
+ }
51
+ return 'model';
52
+ }
53
+
54
+ /**
55
+ * Fix coverage over a finding set.
56
+ *
57
+ * Every share carries {n, d}. `fileContents` is optional: without it the
58
+ * deterministic check cannot run, and rather than guessing, those findings fall
59
+ * to `model` and `deterministicChecked` reports false so a reader knows the
60
+ * split is a lower bound on deterministic coverage.
61
+ */
62
+ export function fixCoverage(findings, fileContents = null) {
63
+ const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
64
+ const d = list.length;
65
+ const buckets = { deterministic: [], model: [], declined: [] };
66
+ for (const f of list) {
67
+ const src = fileContents && f.file ? fileContents[f.file] : null;
68
+ buckets[fixBucketOf(f, src)].push(f);
69
+ }
70
+
71
+ const declinedByFamily = {};
72
+ for (const f of buckets.declined) {
73
+ const fam = f.family || '(none)';
74
+ const key = Object.keys(DECLINED_TO_FIX).find(k => fam === k || fam.startsWith(`${k}-`)) || fam;
75
+ if (!declinedByFamily[key]) declinedByFamily[key] = { n: 0, reason: DECLINED_TO_FIX[key] || 'declined' };
76
+ declinedByFamily[key].n += 1;
77
+ }
78
+
79
+ return {
80
+ total: d,
81
+ deterministic: { n: buckets.deterministic.length, d },
82
+ model: { n: buckets.model.length, d },
83
+ declined: { n: buckets.declined.length, d, byFamily: declinedByFamily },
84
+ deterministicChecked: !!fileContents,
85
+ meaning: 'deterministic = a context-independent patch exists; model = attemptable by a model, NOT known to succeed; declined = synthesis refuses with a stated reason.',
86
+ };
87
+ }
88
+
89
+ /** Markdown for the scorecard. Denominators always attached. */
90
+ export function renderFixCoverage(cov) {
91
+ if (!cov || !cov.total) return '_No findings to report fix coverage over._\n';
92
+ const pct = (n) => `${n}/${cov.total} (${Math.round((n / cov.total) * 100)}%)`;
93
+ const lines = [
94
+ '| Bucket | Share | Meaning |',
95
+ '|---|---|---|',
96
+ `| Deterministic patch | ${pct(cov.deterministic.n)} | context-independent literal swap |`,
97
+ `| Model-attemptable | ${pct(cov.model.n)} | can be attempted; success not claimed here |`,
98
+ `| Declined | ${pct(cov.declined.n)} | synthesis refuses — reasons below |`,
99
+ '',
100
+ ];
101
+ if (!cov.deterministicChecked) {
102
+ lines.push('_Source was not supplied, so the deterministic check could not run: the'
103
+ + ' deterministic share is a LOWER bound and those findings are counted as'
104
+ + ' model-attemptable._', '');
105
+ }
106
+ const entries = Object.entries(cov.declined.byFamily).sort((a, b) => b[1].n - a[1].n);
107
+ if (entries.length) {
108
+ lines.push('**Why each family is declined**', '');
109
+ for (const [fam, { n, reason }] of entries) lines.push(`- \`${fam}\` (${n}): ${reason}`);
110
+ lines.push('');
111
+ }
112
+ return lines.join('\n');
113
+ }