@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9

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 (107) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/bin/agentic-security.js +186 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +183 -14
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +14 -14
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +18 -10
  19. package/src/dataflow/CLAUDE.md +10 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/engine.js +324 -60
  22. package/src/dataflow/implicit-flow.js +68 -36
  23. package/src/dataflow/incremental.js +18 -3
  24. package/src/dataflow/index.js +2 -1
  25. package/src/dataflow/proven-clean.js +41 -0
  26. package/src/dataflow/sanitizer-gate.js +35 -9
  27. package/src/dataflow/sanitizer-proof.js +21 -3
  28. package/src/dataflow/stub-aware-filter.js +36 -13
  29. package/src/dataflow/summaries.js +21 -2
  30. package/src/engine.js +202 -42
  31. package/src/ir/CLAUDE.md +4 -1
  32. package/src/ir/balanced-call.js +55 -0
  33. package/src/ir/parser-cs.js +62 -9
  34. package/src/ir/parser-go.js +29 -11
  35. package/src/ir/parser-java.js +96 -19
  36. package/src/ir/parser-js.js +151 -20
  37. package/src/ir/parser-php.js +44 -9
  38. package/src/ir/parser-rb.js +37 -7
  39. package/src/ir/ssa.js +6 -1
  40. package/src/lsp/server.js +28 -2
  41. package/src/mcp/CLAUDE.md +9 -2
  42. package/src/mcp/redact.js +26 -0
  43. package/src/mcp/tools.js +155 -14
  44. package/src/posture/CLAUDE.md +19 -7
  45. package/src/posture/accuracy-scorecard.js +9 -1
  46. package/src/posture/aibom.js +12 -8
  47. package/src/posture/auditor-walkthrough.js +102 -3
  48. package/src/posture/autopilot.js +8 -1
  49. package/src/posture/calibration-drift.js +11 -5
  50. package/src/posture/calibration.js +24 -2
  51. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  52. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  53. package/src/posture/compliance-policy.js +33 -1
  54. package/src/posture/confidence.js +44 -10
  55. package/src/posture/corpus-enroll.js +9 -5
  56. package/src/posture/corpus-match.js +19 -0
  57. package/src/posture/csharp-analysis.js +62 -3
  58. package/src/posture/deploy-platform.js +4 -1
  59. package/src/posture/drift.js +7 -1
  60. package/src/posture/epss.js +13 -1
  61. package/src/posture/evidence-bundle.js +36 -6
  62. package/src/posture/exploitability-probability.js +13 -1
  63. package/src/posture/falsification.js +23 -2
  64. package/src/posture/fix-metrics.js +1 -1
  65. package/src/posture/fix-verify-loop.js +10 -1
  66. package/src/posture/iac-reachability.js +14 -8
  67. package/src/posture/integrity.js +25 -7
  68. package/src/posture/model-rescan.js +65 -0
  69. package/src/posture/mttr.js +5 -0
  70. package/src/posture/poc-inprocess.js +27 -8
  71. package/src/posture/regression-test-gen.js +23 -8
  72. package/src/posture/reverse-blast-radius.js +5 -1
  73. package/src/posture/risk-dollars.js +18 -1
  74. package/src/posture/secret-history.js +20 -11
  75. package/src/posture/security-trend.js +7 -1
  76. package/src/posture/stack-playbook.js +22 -1
  77. package/src/posture/threat-model-grounding.js +2 -2
  78. package/src/posture/validator-metrics.js +10 -3
  79. package/src/posture/verifier.js +32 -57
  80. package/src/report/index.js +183 -14
  81. package/src/runScan.js +1 -1
  82. package/src/sast/_comment-strip.js +15 -4
  83. package/src/sast/_secret-entropy.js +1 -1
  84. package/src/sast/authz.js +6 -4
  85. package/src/sast/bench-shape/index.js +2 -7
  86. package/src/sast/claude-md-prompt-injection.js +14 -3
  87. package/src/sast/cloud-iam.js +60 -7
  88. package/src/sast/cpp-bench-extras.js +1 -1
  89. package/src/sast/csrf.js +7 -5
  90. package/src/sast/env-hygiene.js +5 -2
  91. package/src/sast/iac-terraform.js +25 -0
  92. package/src/sast/java-bench-extras.js +1 -1
  93. package/src/sast/java-constant-fold.js +5 -5
  94. package/src/sast/llm-owasp.js +4 -2
  95. package/src/sast/mcp-audit.js +7 -0
  96. package/src/sast/pipeline.js +8 -0
  97. package/src/sast/prompt-template.js +8 -6
  98. package/src/sast/prototype-pollution.js +6 -2
  99. package/src/sast/redos-nfa.js +6 -6
  100. package/src/sast/secret-concat.js +13 -2
  101. package/src/sast/ssrf-cloud-metadata.js +6 -3
  102. package/src/sast/xss-reflected-multilang.js +1 -1
  103. package/src/sast/xxe.js +1 -1
  104. package/src/sca/CLAUDE.md +3 -4
  105. package/src/sca/container.js +35 -3
  106. package/src/sca/dep-confusion.js +7 -0
  107. package/src/sca/sarif-ingest.js +0 -187
@@ -32,6 +32,7 @@
32
32
  // → new state with the implicit-tainted vars added at confidence 0.5
33
33
 
34
34
  import { addPath } from './access-paths.js';
35
+ import { computeDominators } from '../ir/ssa.js';
35
36
 
36
37
  export function isImplicitFlowEnabled() {
37
38
  // OPT-IN (default OFF): implicit/control-dependence flow is famously noisy, so
@@ -47,48 +48,79 @@ export function isImplicitFlowEnabled() {
47
48
  * cfg: the function CFG
48
49
  * exprTaint: callback (expr) -> bool, using the current taint state
49
50
  *
50
- * Heuristic: walk forward from entry, and when we hit an `if` whose
51
- * condition is tainted, mark all nodes reachable from the consequent (and,
52
- * if present, the alternate) until we exit those branches.
51
+ * Correctness (Stage 6 audit): this used to be a path-dependent DFS that
52
+ * incremented a "depth" counter on entering a tainted `if`'s successors and
53
+ * only ever decremented it at a loop-header there was no mechanism to
54
+ * detect a branch's natural JOIN point, so once a path entered depth 1,
55
+ * every node reachable afterward (including everything textually AFTER the
56
+ * if/else closes, for the rest of the function) stayed marked
57
+ * `tainted: true` forever. A first fix attempt (reset depth at the join)
58
+ * was reverted because it broke the equally-real, intentional pattern
59
+ * `if (tainted) { p = x; } eval(p)`: `p`'s taint must survive past the
60
+ * branch on the VARIABLE (via markImplicitTaint → the normal forward-taint
61
+ * state, entirely independent of this map), even though `eval(p)` itself is
62
+ * not lexically inside the branch.
63
+ *
64
+ * The actual invariant this map must encode is narrower than "reachable
65
+ * after a tainted if": a node N is genuinely INSIDE the branch rooted at
66
+ * successor B if and only if B DOMINATES N — every path from the function
67
+ * entry to N passes through B. A join point (reachable via the other
68
+ * branch, or via any path that bypasses the if) is by definition NOT
69
+ * dominated by B. This is exactly what `computeDominators` (shared with
70
+ * ir/ssa.js's SSA φ-node placement) computes, so reuse it instead of a
71
+ * path-dependent walk that structurally cannot distinguish "inside" from
72
+ * "after".
73
+ *
74
+ * One more wrinkle the CFG shape forces on us: parser-js.js's IfStatement
75
+ * lowering models a missing `else` as a DIRECT edge from the `if` node to
76
+ * the post-if join node (`if (!path.node.alternate) linkCfg(fn, condId,
77
+ * joinId)`), so `n.succ` for an else-less `if` is `[branchEntry, joinId]`
78
+ * — the join node is literally one of the "successors" too. Naively
79
+ * dominance-checking every successor as if it were a branch root treats
80
+ * `joinId` itself as something to dominate through, and `joinId` trivially
81
+ * dominates everything after it (there's no other way to reach that code),
82
+ * which resurrects the exact over-attribution this fix exists to close.
83
+ * Distinguish a genuine branch entry from a join/skip target structurally:
84
+ * a real branch entry has EXACTLY ONE predecessor — this `if` node itself.
85
+ * A join node always has a second predecessor (the branch's own tail, or —
86
+ * for the else-less case — this same direct skip edge), so it never
87
+ * qualifies.
53
88
  */
54
89
  export function buildImplicitContext(cfg, exprTaint) {
55
90
  const ctxByNid = new Map();
56
91
  if (!cfg || !cfg.nodes) return ctxByNid;
57
- // Walk forward; track "depth" of how many tainted-branches we're nested in.
58
- const visited = new Set();
59
- const stack = [{ nid: cfg.entry, depth: 0, label: null }];
60
- while (stack.length) {
61
- const { nid, depth, label } = stack.pop();
62
- if (visited.has(nid)) continue;
63
- visited.add(nid);
64
- if (depth > 0) ctxByNid.set(nid, { tainted: true, conditionLabel: label });
65
- const n = cfg.nodes[nid];
66
- if (!n) continue;
67
- if (n.kind === 'if' && n.cond && exprTaint(n.cond)) {
68
- // Config-constant filter: if condition is `ident === literal` where
69
- // ident is NOT tainted, skip (it's a config check, not a taint branch).
70
- if (n.cond.kind === 'binary' && (n.cond.op === '===' || n.cond.op === '==' || n.cond.op === 'Eq') &&
71
- n.cond.right && n.cond.right.kind === 'literal' &&
72
- n.cond.left && n.cond.left.kind === 'ident' &&
73
- !exprTaint(n.cond.left)) {
74
- for (const s of (n.succ || [])) {
75
- stack.push({ nid: s, depth, label });
76
- }
77
- } else {
78
- for (const s of (n.succ || [])) {
79
- stack.push({ nid: s, depth: depth + 1, label: _formatCondLabel(n.cond) });
92
+ const dom = computeDominators(cfg);
93
+ const predCount = new Map();
94
+ const soleParent = new Map();
95
+ for (const [pid, p] of Object.entries(cfg.nodes)) {
96
+ for (const s of (p?.succ || [])) {
97
+ predCount.set(s, (predCount.get(s) || 0) + 1);
98
+ if (!soleParent.has(s)) soleParent.set(s, pid);
99
+ }
100
+ }
101
+ for (const [nid, n] of Object.entries(cfg.nodes)) {
102
+ if (!n || n.kind !== 'if' || !n.cond || !exprTaint(n.cond)) continue;
103
+ // Config-constant filter: if condition is `ident === literal` where
104
+ // ident is NOT tainted, skip (it's a config check, not a taint branch).
105
+ if (n.cond.kind === 'binary' && (n.cond.op === '===' || n.cond.op === '==' || n.cond.op === 'Eq') &&
106
+ n.cond.right && n.cond.right.kind === 'literal' &&
107
+ n.cond.left && n.cond.left.kind === 'ident' &&
108
+ !exprTaint(n.cond.left)) {
109
+ continue;
110
+ }
111
+ const label = _formatCondLabel(n.cond);
112
+ for (const branchRoot of (n.succ || [])) {
113
+ if (branchRoot === nid) continue; // guard against a malformed self-loop
114
+ // Not a genuine branch entry it's a join/skip target reachable
115
+ // some other way too (see header). Marking it (or anything only
116
+ // reachable through it) would leak taint past the branch's real scope.
117
+ if ((predCount.get(branchRoot) || 0) !== 1 || soleParent.get(branchRoot) !== nid) continue;
118
+ for (const [candidate, domSet] of dom) {
119
+ if (candidate === nid) continue;
120
+ if (domSet && domSet.has(branchRoot) && !ctxByNid.has(candidate)) {
121
+ ctxByNid.set(candidate, { tainted: true, conditionLabel: label });
80
122
  }
81
123
  }
82
- } else if (n.kind === 'loop-header' && depth > 0) {
83
- // Loop-body exclusion: don't escalate implicit depth inside loops —
84
- // loop iteration count is not a taint channel for most vuln classes.
85
- for (const s of (n.succ || [])) {
86
- stack.push({ nid: s, depth: Math.max(depth - 1, 0), label });
87
- }
88
- } else {
89
- for (const s of (n.succ || [])) {
90
- stack.push({ nid: s, depth, label });
91
- }
92
124
  }
93
125
  }
94
126
  return ctxByNid;
@@ -162,9 +162,24 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
162
162
  }
163
163
 
164
164
  /**
165
- * Serialize a SummaryCache for persistence. Only persists summaries with
166
- * `_persistable: true` (set by the engine when the summary is independent
167
- * of an entry taint-state typically pure functions or terminal sinks).
165
+ * Serialize a SummaryCache for persistence.
166
+ *
167
+ * ACTUAL current filter: excludes `_budgetExceeded` and `_recursive`
168
+ * summaries, and caps at MAX_PERSISTED_SUMMARIES. There is no
169
+ * context-independence filter — nothing in this codebase ever sets a
170
+ * `_persistable` flag (this comment previously claimed one existed and gated
171
+ * on it; grep confirms no such flag is set anywhere). Every non-recursive,
172
+ * non-budget-exceeded summary is persisted regardless of whether it depends
173
+ * on the entry taint-state it was computed under.
174
+ *
175
+ * OPEN QUESTION, not resolved by this comment fix: whether the reseeding
176
+ * side (`pickReusableSummaries`/`seedSummaryCache`) re-validates a persisted
177
+ * summary against the CALLER's actual entry taint-state before reuse, or
178
+ * trusts it as-is. If it trusts it as-is, a summary computed under one
179
+ * tainted-arg shape could be replayed for a call site with a different shape
180
+ * next scan — a real correctness question for the k-CFA context-sensitivity
181
+ * this cache exists to provide, worth a dedicated investigation rather than
182
+ * a guess made while reconciling documentation.
168
183
  *
169
184
  * Returns a plain object `{ qid: summary }` safe for JSON.stringify.
170
185
  */
@@ -137,7 +137,8 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
137
137
  // stub signatures (loaded by ir/type-stubs.js when AGENTIC_SECURITY_TYPE_STUBS=1).
138
138
  // If a finding's source type is provably non-stringy (number, boolean,
139
139
  // Date, RegExp) AND the sink class can't be triggered by that type,
140
- // demote the finding's severity.
140
+ // demote the finding's confidence/tier (recall-preserving — severity
141
+ // is never touched).
141
142
  if (process.env.AGENTIC_SECURITY_TYPE_STUBS === '1' && opts.scanRoot) {
142
143
  try {
143
144
  const stubs = loadProjectStubs(opts.scanRoot);
@@ -110,17 +110,58 @@ function _findFunction(finding, perFileIR) {
110
110
  return chosen;
111
111
  }
112
112
 
113
+ // Backward BFS via `pred` edges from `startId` — every node that can reach
114
+ // `startId` by following the CFG forward from itself.
115
+ function _backwardReachable(fn, startId) {
116
+ const visited = new Set();
117
+ const stack = [startId];
118
+ while (stack.length) {
119
+ const id = stack.pop();
120
+ if (visited.has(id)) continue;
121
+ visited.add(id);
122
+ const node = fn.cfg.nodes[id];
123
+ if (!node) continue;
124
+ for (const p of node.pred || []) stack.push(p);
125
+ }
126
+ return visited;
127
+ }
128
+
113
129
  function _allCallNodesBetween(fn, trace, sinkLine) {
114
130
  if (!fn || !fn.cfg || !fn.cfg.nodes) return [];
115
131
  const earliestSrcLine = Math.min(
116
132
  ...trace.map(t => (typeof t.line === 'number' ? t.line : sinkLine))
117
133
  );
134
+ // Stage 3 correctness audit (detection depth): this used to be a pure
135
+ // line-number-range filter — ANY call node between the earliest source
136
+ // line and the sink line, anywhere in the function, regardless of
137
+ // whether it could ever actually execute before the sink. A
138
+ // parameterizer call inside an unrelated `if` branch that RETURNS
139
+ // before ever reaching the sink (a completely different, non-converging
140
+ // path) satisfied that filter and could falsely "prove" a genuinely
141
+ // vulnerable SQL concatenation on a different branch was clean — the
142
+ // dangerous direction for a proof gate, since `provenClean` de-emphasizes
143
+ // the finding in reports and risk scoring.
144
+ //
145
+ // Restrict candidates to nodes CFG-reachable as a predecessor of the
146
+ // sink's own node (backward BFS via `pred`): a call that cannot execute
147
+ // on ANY path leading to the sink is excluded. This is still not a full
148
+ // per-path proof (a parameterizer reachable via SOME but not ALL paths
149
+ // to the sink, or one that sanitizes an unrelated variable, still
150
+ // passes) — that's the v2 SMT work the module header already scopes
151
+ // out — but it closes the specific "unrelated branch that never reaches
152
+ // the sink at all" false-proof case.
153
+ const sinkNodeId = Object.keys(fn.cfg.nodes).find(id => {
154
+ const n = fn.cfg.nodes[id];
155
+ return n && n.kind === 'call' && n.line === sinkLine;
156
+ });
157
+ const reachable = sinkNodeId ? _backwardReachable(fn, sinkNodeId) : null;
118
158
  const out = [];
119
159
  for (const id of Object.keys(fn.cfg.nodes)) {
120
160
  const node = fn.cfg.nodes[id];
121
161
  if (!node || node.kind !== 'call') continue;
122
162
  if (typeof node.line !== 'number') continue;
123
163
  if (node.line < earliestSrcLine || node.line > sinkLine) continue;
164
+ if (reachable && !reachable.has(id)) continue;
124
165
  out.push({ line: node.line, callee: node.callee });
125
166
  }
126
167
  return out;
@@ -1,9 +1,12 @@
1
1
  // Generalised, recall-preserving sanitizer gate.
2
2
  //
3
- // The catalog carries 65 sanitizer entries tagged by family via `appliesTo`
4
- // (sql, xss, url, cmd). Before this module only `appliesTo: ['sql']` was ever
5
- // consumed by proven-clean.js so a correctly sanitized xss/url/cmd flow was
6
- // still reported at full confidence. That is a pure false-positive source.
3
+ // The catalog carries 381 sanitizer entries (all languages, all kinds; count
4
+ // via CATALOG.filter(e => e.kind === 'sanitizer').length re-derive rather
5
+ // than trust a hardcoded number here, since this comment has been wrong
6
+ // before) tagged by family via `appliesTo` (sql, xss, url, cmd, *, …). Before
7
+ // this module only `appliesTo: ['sql']` was ever consumed — by
8
+ // proven-clean.js — so a correctly sanitized xss/url/cmd flow was still
9
+ // reported at full confidence. That is a pure false-positive source.
7
10
  //
8
11
  // This gate NEVER removes a finding. It sets `sanitized` plus a proof object and
9
12
  // lets the existing proof gate in engine.js do the demotion, exactly as
@@ -15,20 +18,38 @@ import { CATALOG } from './catalog.js';
15
18
 
16
19
  // Map a finding to a sanitizer family using its CWE first (stable) and its vuln
17
20
  // text second (human-authored, so only a fallback).
21
+ //
22
+ // CWE-22 (path traversal) maps to the catalog's real 'path' family
23
+ // (py-pathlib-resolve / cs-path-getfullpath / kt-path-canonical, all
24
+ // appliesTo:['path']) — it used to collapse to 'url' alongside CWE-918/601,
25
+ // which meant those genuine path-containment sanitizers could never match a
26
+ // CWE-22 finding at all (permanently inert gate for that CWE), AND any
27
+ // url-family sanitizer (URL-percent-encoding: encodeURIComponent etc.)
28
+ // satisfied the family check for CWE-22/918/601 findings even though
29
+ // percent-encoding neutralizes none of them. SSRF (CWE-918) and open
30
+ // redirect (CWE-601) are host/scheme allow-list problems, not encoding
31
+ // problems, and the catalog has no dedicated family for either — mapping
32
+ // them to a family with zero real catalog matches (rather than borrowing
33
+ // 'url') means this gate correctly never fires for them, leaving that
34
+ // containment to engine.js's separate dropGuardedFindings host-allowlist
35
+ // recognition instead of a false "proven clean" from an unrelated encoder.
18
36
  const _CWE_FAMILY = {
19
37
  'CWE-89': 'sql',
20
38
  'CWE-79': 'xss',
21
39
  'CWE-78': 'cmd',
22
- 'CWE-22': 'url',
23
- 'CWE-918': 'url',
24
- 'CWE-601': 'url',
40
+ 'CWE-22': 'path',
41
+ 'CWE-918': 'ssrf-host-allowlist-only', // no catalog family — see above
42
+ 'CWE-601': 'redirect-allowlist-only', // no catalog family — see above
25
43
  };
26
44
 
27
45
  const _TEXT_FAMILY = [
28
46
  [/sql/i, 'sql'],
29
47
  [/xss|cross-site scripting/i, 'xss'],
30
48
  [/command injection/i, 'cmd'],
31
- [/path traversal|ssrf|redirect/i, 'url'],
49
+ [/path traversal/i, 'path'],
50
+ // No catalog family for ssrf/redirect — deliberately no fallback entry
51
+ // (see the _CWE_FAMILY comment above); familyOfFinding returns null and
52
+ // applySanitizerGate's `if (!fam) continue;` safely no-ops.
32
53
  ];
33
54
 
34
55
  export function familyOfFinding(f) {
@@ -78,7 +99,12 @@ export function applySanitizerGate(findings, ctx) {
78
99
  if (!Array.isArray(observed) || !observed.length) continue;
79
100
  const matching = observed.filter(name => {
80
101
  const fams = index.get(name);
81
- return fams && fams.has(fam);
102
+ // `*` is the catalog's universal tag, carried by the 17 type-coercion
103
+ // entries (parseInt/intval/Atoi/TryParse/…) that neutralise every
104
+ // injection family by making the value non-stringy. Matching it
105
+ // literally against 'sql'/'xss' never succeeds, which left that whole
106
+ // tier inert.
107
+ return fams && (fams.has(fam) || fams.has('*'));
82
108
  });
83
109
  if (!matching.length) continue;
84
110
  // Label only. The proof gate decides what to do with the label.
@@ -117,11 +117,29 @@ export function verifyProjectSanitizers(perFileIR, catalog) {
117
117
  if (!calleeName) continue;
118
118
  const fns = fnByName.get(calleeName);
119
119
  if (!fns || !fns.length) continue; // not a project-local sanitizer
120
+ // Stage 3 correctness audit (detection depth): this used to take only
121
+ // `appliesTo[0]`, so a multi-family entry (`appliesTo: ['sql', 'xss']`)
122
+ // was only ever verified against its FIRST tag — silently skipping
123
+ // whether the project's local function actually behaves as a
124
+ // sanitizer for the OTHER families it claims to cover. It also fell
125
+ // back to the literal string `'*'` when `appliesTo` was absent or
126
+ // wildcard-only, and `'*'` has no entry in `_SHAPE_RULES` — every
127
+ // wildcard-tagged sanitizer (the 17 catalog type-coercion entries:
128
+ // parseInt, Number(), etc.) was unconditionally verdicted
129
+ // `trusted: false, reason: "no shape rule for family '*'"`, even
130
+ // though `'*'` legitimately covers every family by design (see
131
+ // sanitizer-gate.js's own header comment on the same wildcard tag).
132
+ // Fixed: verify against every concrete (non-'*') family the entry
133
+ // claims; a wildcard-only entry has no per-family shape to check
134
+ // against and contributes no verdict rather than a misleading false one.
135
+ const families = (Array.isArray(entry.appliesTo) ? entry.appliesTo : []).filter(f => f !== '*');
136
+ if (!families.length) continue;
120
137
  for (const fn of fns) {
121
138
  const bodyText = _stringifyCfgBody(fn);
122
- const family = (entry.appliesTo && entry.appliesTo[0]) || '*';
123
- const verdict = isValidSanitizerFor(bodyText, family);
124
- out.push({ fnQid: fn.qid, family, trusted: verdict.trusted, reason: verdict.reason });
139
+ for (const family of families) {
140
+ const verdict = isValidSanitizerFor(bodyText, family);
141
+ out.push({ fnQid: fn.qid, family, trusted: verdict.trusted, reason: verdict.reason });
142
+ }
125
143
  }
126
144
  }
127
145
  return out;
@@ -17,10 +17,14 @@
17
17
  // Cmd inj (CWE-78): source type ∈ {number, boolean} → demote
18
18
  // Path trav (CWE-22): source type ∈ {number, boolean} → demote
19
19
  //
20
- // Demotion lowers severity by one tier and sets `_stubTypeDemoted: true`
21
- // with a `_stubTypeReason`. We never DROP findings — the stub-aware
22
- // reason is shown to the operator so they can override if the stub is
23
- // wrong or out of date.
20
+ // Demotion lowers confidence + confidenceTier + exploitabilityTier by one
21
+ // step and sets `_stubTypeDemoted: true` with a `_stubTypeReason` — the
22
+ // same recall-preserving shape every other precision annotator in this
23
+ // directory uses (proof-gate.js, verification-separation.js). Severity is
24
+ // never touched and findings are never DROPPED — the stub-aware reason is
25
+ // shown to the operator so they can override if the stub is wrong or out
26
+ // of date. (This module used to mutate severity directly; that violated
27
+ // the convention and was flagged, not designed — see dataflow/CLAUDE.md.)
24
28
 
25
29
  const FAMILY_SAFE_TYPES = {
26
30
  'CWE-79': new Set(['number', 'boolean', 'Date', 'RegExp', 'bigint']),
@@ -64,10 +68,33 @@ function _normalizeType(t) {
64
68
  return trimmed;
65
69
  }
66
70
 
71
+ // Tier ladders, lowest → highest — identical shape to proof-gate.js's, kept
72
+ // as a local copy rather than importing that module's test-only `_internals`
73
+ // across a module boundary.
74
+ const CONFIDENCE_TIERS = ['very-low', 'low', 'medium', 'high'];
75
+ const EXPLOITABILITY_TIERS = ['low', 'medium', 'high', 'critical'];
76
+ const CONFIDENCE_DEMOTE_FACTOR = 0.4;
77
+
78
+ function _demoteTier(tier, ladder) {
79
+ const i = ladder.indexOf(tier);
80
+ if (i <= 0) return ladder[0];
81
+ return ladder[i - 1];
82
+ }
83
+
84
+ function _demote(f) {
85
+ if (typeof f.confidence === 'number') {
86
+ f._confidenceBeforeStubFilter = f.confidence;
87
+ f.confidence = Math.max(0.01, Number((f.confidence * CONFIDENCE_DEMOTE_FACTOR).toFixed(4)));
88
+ }
89
+ if (f.confidenceTier) f.confidenceTier = _demoteTier(f.confidenceTier, CONFIDENCE_TIERS);
90
+ if (f.exploitabilityTier) f.exploitabilityTier = _demoteTier(f.exploitabilityTier, EXPLOITABILITY_TIERS);
91
+ }
92
+
67
93
  /**
68
94
  * Post-pass entry. Mutates findings in place: adds `_stubTypeDemoted`,
69
- * `_stubTypeReason`, downgrades `severity` by one tier when the source
70
- * type is in the family-safe set for the finding's CWE.
95
+ * `_stubTypeReason`, demotes confidence/confidenceTier/exploitabilityTier
96
+ * by one step when the source type is in the family-safe set for the
97
+ * finding's CWE. Severity is never touched (recall-preserving).
71
98
  *
72
99
  * Returns the (mutated) findings array with `_stubFilterStats` non-
73
100
  * enumerable sidecar.
@@ -131,9 +158,7 @@ export function applyStubAwareFilter(findings, stubs, perFileIR) {
131
158
  if (sourceType && safeSet.has(sourceType)) {
132
159
  f._stubTypeDemoted = true;
133
160
  f._stubTypeReason = `source type ${sourceType} cannot carry ${f.cwe} metacharacters`;
134
- f._stubTypeOriginalSeverity = f.severity;
135
- const downgrade = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
136
- if (downgrade[f.severity]) f.severity = downgrade[f.severity];
161
+ _demote(f);
137
162
  demoted++;
138
163
  continue;
139
164
  }
@@ -142,9 +167,7 @@ export function applyStubAwareFilter(findings, stubs, perFileIR) {
142
167
  if (guardType && safeSet.has(guardType)) {
143
168
  f._stubTypeDemoted = true;
144
169
  f._stubTypeReason = `type guard narrows to ${guardType}, safe for ${f.cwe}`;
145
- f._stubTypeOriginalSeverity = f.severity;
146
- const downgrade = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
147
- if (downgrade[f.severity]) f.severity = downgrade[f.severity];
170
+ _demote(f);
148
171
  demoted++;
149
172
  }
150
173
  }
@@ -155,4 +178,4 @@ export function applyStubAwareFilter(findings, stubs, perFileIR) {
155
178
  return findings;
156
179
  }
157
180
 
158
- export const _internal = { FAMILY_SAFE_TYPES, _sourceTypeFromStubs, _normalizeType };
181
+ export const _internal = { FAMILY_SAFE_TYPES, _sourceTypeFromStubs, _normalizeType, CONFIDENCE_TIERS, EXPLOITABILITY_TIERS, _demoteTier };
@@ -28,7 +28,13 @@
28
28
  // - Field sensitivity is at the parameter granularity only (not arbitrary
29
29
  // access paths). `f(obj)` with obj.foo tainted is treated the same as
30
30
  // obj.bar tainted.
31
- // - No higher-order tracking callbacks passed as args aren't analyzed.
31
+ // - Higher-order tracking IS modelled, in engine.js (not here): a callback
32
+ // passed as an argument is recorded via `_higherOrderInvocations`, then
33
+ // resolved and analyzed with the callback's own tainted parameter, with
34
+ // its findings merged back into the caller (`higher-order.js`). This
35
+ // summary cache is unaware of it; the higher-order path is a separate
36
+ // mechanism layered on top by the caller. (This bullet previously said
37
+ // no such tracking existed at all, which was true only of THIS module.)
32
38
  // - Recursion: when we'd recurse into a function already on the analysis
33
39
  // stack, we return the bottom summary (no-taint) and rely on fixed-point
34
40
  // iteration. With k=1 this converges in ≤2 iterations for typical code.
@@ -185,10 +191,23 @@ export class SummaryCache {
185
191
  clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; }
186
192
  }
187
193
 
194
+ // Stage 3 correctness audit (detection depth): this compared mutatedParams
195
+ // by SIZE only, not membership — two summaries with the same cardinality
196
+ // but different members (e.g. {'fieldA'} vs {'fieldB'}, plausible across
197
+ // fixed-point iterations of a recursive helper whose mutated-target
198
+ // identity depends on recursion depth) were treated as equal. compute()'s
199
+ // fixed-point loop (above) then `break`s WITHOUT caching the just-computed
200
+ // `summary` — but still returns it to its immediate caller. Any OTHER,
201
+ // later call site doing a plain cache `.get(qid, sameEntry)` would then
202
+ // silently receive the stale `prev` summary the loop declared "equal to"
203
+ // the fresher one it never cached.
188
204
  function _summaryEq(a, b) {
189
205
  if (!a || !b) return a === b;
190
206
  if (!!a.returnTainted !== !!b.returnTainted) return false;
191
- if ((a.mutatedParams?.size || 0) !== (b.mutatedParams?.size || 0)) return false;
207
+ const am = a.mutatedParams || new Set();
208
+ const bm = b.mutatedParams || new Set();
209
+ if (am.size !== bm.size) return false;
210
+ for (const p of am) if (!bm.has(p)) return false;
192
211
  return true;
193
212
  }
194
213