@clear-capabilities/agentic-security-scanner 0.152.0 → 0.153.1

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.
@@ -22,7 +22,7 @@
22
22
  //
23
23
  // We do NOT attempt to prove ARBITRARY satisfiability — the v2 use case.
24
24
 
25
- import { provablyMatches } from './string-domain.js';
25
+ import { provablyMatches, safeSanitizerOutputRegex } from './string-domain.js';
26
26
 
27
27
  // CWE → canonical exploit input. Conservative payloads safe to display.
28
28
  const EXPLOIT_INPUTS = {
@@ -62,6 +62,31 @@ const FAMILY_METACHARS = {
62
62
 
63
63
  // Try to load z3-solver. Returns null if not installed — that's the common
64
64
  // case (we don't bundle the WASM blob).
65
+ //
66
+ // SCOPE NOTE (next-gen taint capability #5 rebuild): loading z3-solver here
67
+ // is real — it is a genuine `optionalDependencies` entry in package.json,
68
+ // and `_maybeLoadZ3` performs a real dynamic `import()`, verified against
69
+ // an actual installed copy. What it does NOT yet do is discharge any query
70
+ // TO Z3: `proveExploits` below still routes every feasibility verdict
71
+ // through `smtLiteInfeasibilityCheck`'s JS-native regex check regardless of
72
+ // whether z3 loaded; a present z3 module only labels `_exploitInputSource`
73
+ // on the FEASIBLE branch. Making Z3 do real work here would mean
74
+ // translating each sanitizer's `outputRegexSource` (a JS RegExp source —
75
+ // anchors, character classes, quantifiers, alternation, optional groups;
76
+ // see `string-domain.js`'s `SANITIZER_OUTPUT_REGEX`) into Z3's own
77
+ // string/regex theory and asking it "does any string matching this pattern
78
+ // also contain this metacharacter?" That translator is a real, standalone
79
+ // undertaking, and a translation bug in it is uniquely dangerous for this
80
+ // specific mechanism: it would manufacture a false `feasible: false`
81
+ // (a false safety proof) rather than merely a missed detection. The
82
+ // existing JS-native check (`_backwardSliceExcludesMetachars`) is already
83
+ // SOUND for every regex currently in the hand-vetted catalog (all of them
84
+ // are anchored, closed-under-repetition character classes or fixed-format
85
+ // literals — verified by direct reading of `SANITIZER_OUTPUT_REGEX`), so
86
+ // there is no live soundness gap this would close today. Left as a
87
+ // deliberate, documented boundary rather than an oversight: build the
88
+ // translator as its own reviewed capability if/when the sanitizer catalog
89
+ // grows regex shapes the JS probe can no longer safely reason about.
65
90
  let _z3 = null;
66
91
  let _z3Loaded = false;
67
92
  async function _maybeLoadZ3() {
@@ -90,20 +115,30 @@ export function smtLiteInfeasibilityCheck(finding) {
90
115
  if (!metacharsNeeded || metacharsNeeded.length === 0) {
91
116
  return { feasible: 'unknown', reason: 'no-metachar-model' };
92
117
  }
93
- // Walk the trace/chain for sanitizer regex constraints. The `string-domain`
94
- // produces a regex abstract value when known sanitizers are on the path;
95
- // the soft-taint table independently labels sanitizers. For v1 we check
96
- // the chain entries against the known sanitizer-output regexes.
118
+ // Next-gen taint capability #5 (SMT path-feasibility rebuild): prefer
119
+ // `finding.backwardSlice` (backward.js's REAL, ordered source-to-sink
120
+ // path, opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1) over the
121
+ // trace/chain fallback below. Confirmed live, during this rebuild, that
122
+ // `finding.trace`/`finding.chain` are NOT a sequential path at all — they
123
+ // are the list of SOURCES reaching the sink (see dataflow/engine.js's own
124
+ // `_sourcesReachingExpr`), so a `.callee` never actually names a
125
+ // sanitizer in real production data; this function's own pre-rebuild
126
+ // unit tests only ever exercised a HAND-FABRICATED `chain` shape
127
+ // (`[{callee: 'htmlspecialchars'}]`) that the real engine never produces.
128
+ const slicePath = _backwardSliceExcludesMetachars(finding.backwardSlice, metacharsNeeded);
129
+ if (slicePath) return slicePath;
130
+ // Fallback for callers that never ran the (opt-in) backward-slice pass —
131
+ // kept for backward compatibility, though per the finding above it can
132
+ // realistically only ever return 'unknown' against real finding.trace/
133
+ // chain data; a caller that wants a real feasibility answer needs
134
+ // AGENTIC_SECURITY_BACKWARD_SLICE=1 set.
97
135
  const trace = Array.isArray(finding.trace) ? finding.trace : [];
98
136
  const chain = Array.isArray(finding.chain) ? finding.chain : [];
99
137
  const all = [...trace, ...chain];
100
138
  for (const step of all) {
101
139
  const callee = step.callee || step.label || '';
102
- // Heuristic: an encodeURIComponent / parseInt / quote_plus on the path
103
- // produces output that cannot contain the family's metacharacters.
104
140
  const regex = _sanitizerRegexFor(callee);
105
141
  if (!regex) continue;
106
- // Test if ALL required metacharacters are excluded by this regex.
107
142
  const excludesAll = metacharsNeeded.every(mc => !regex.test(mc.repeat(8)));
108
143
  if (excludesAll) {
109
144
  return { feasible: false, reason: `sanitizer-excludes-metacharacters:${callee}` };
@@ -112,22 +147,75 @@ export function smtLiteInfeasibilityCheck(finding) {
112
147
  return { feasible: 'unknown', reason: 'no-sanitizer-on-path' };
113
148
  }
114
149
 
150
+ // Parameterized-query API calls: a DIFFERENT kind of "safe" than a
151
+ // regex-constrained output — the value is never concatenated into the
152
+ // query TEXT at all, so no charset restriction on the value itself makes
153
+ // it safe regardless of content. Kept as its own small, explicit,
154
+ // unambiguous list (real database-driver method names, not generic
155
+ // English words) rather than folded into `safeSanitizerOutputRegex`,
156
+ // which models a genuinely different property (output charset).
157
+ const _PARAMETERIZED_CALL_NAMES = new Set(['setString', 'AddWithValue', 'bindParam', 'parameterize']);
158
+
159
+ // Walk `backwardSlice` (backward.js's source-first ordered path) from the
160
+ // SINK end backward, looking for the CLOSEST sanitize/parameterize step —
161
+ // stopping (refusing to conclude anything) the moment it crosses an OPAQUE
162
+ // 'call' step it cannot reason about, since such a call could have undone
163
+ // or bypassed whatever a sanitizer further upstream proved. Only a
164
+ // sanitizer/parameterization with NOTHING but pure 'assign' aliasing
165
+ // between it and the sink is trusted — this is the conservative,
166
+ // sound-by-construction direction: a missed proof (returning 'unknown')
167
+ // costs nothing but a demotion opportunity, while a WRONG proof would
168
+ // manufacture a false safety claim for a real vulnerability.
169
+ function _backwardSliceExcludesMetachars(backwardSlice, metacharsNeeded) {
170
+ if (!Array.isArray(backwardSlice) || !backwardSlice.length) return null;
171
+ // Reverse to walk sink-first (the array itself is stored source-first).
172
+ for (const step of [...backwardSlice].reverse()) {
173
+ if (step.kind === 'sink') continue;
174
+ if (step.kind === 'assign') continue; // pure alias — keep looking upstream
175
+ if (step.kind === 'sanitize') {
176
+ if (_PARAMETERIZED_CALL_NAMES.has(step.callee)) {
177
+ return { feasible: false, reason: `parameterized-call:${step.callee}` };
178
+ }
179
+ if (step.outputRegexSource) {
180
+ // `outputRegexSource` is never attacker-influenced despite flowing
181
+ // through a `backwardSlice` step object: `backward.js`'s own
182
+ // `_safeSanitizerOutputRegex` is its ONLY producer, and that
183
+ // function returns either `null` or one of `string-domain.js`'s
184
+ // SMALL, HARDCODED `SANITIZER_OUTPUT_REGEX` pattern literals
185
+ // (encodeURIComponent, parseInt, htmlspecialchars, …) — confirmed
186
+ // by direct code reading, never a value derived from scanned
187
+ // source text, a finding, or any other runtime input. This
188
+ // project's own self-scan correctly cannot see that far through
189
+ // the call chain and flags this defensively — a genuine false
190
+ // positive, not a suppressed real risk.
191
+ const regex = new RegExp(step.outputRegexSource, step.outputRegexFlags || ''); // agentic-security-ignore: CWE-1333
192
+ const excludesAll = metacharsNeeded.every((mc) => !regex.test(mc.repeat(8)));
193
+ if (excludesAll) {
194
+ return { feasible: false, reason: `sanitizer-excludes-metacharacters:${step.callee}` };
195
+ }
196
+ }
197
+ // A recognized-by-catalog-name sanitizer with no PROVEN output
198
+ // regex (e.g. a project-local function merely NAMED like a known
199
+ // sanitizer) cannot be trusted for a sound proof — refuse rather
200
+ // than guess, matching backward.js's own `outputRegexSource`
201
+ // header comment on exactly this hazard.
202
+ return { feasible: 'unknown', reason: `sanitizer-without-proven-output-shape:${step.callee || 'unknown'}` };
203
+ }
204
+ if (step.kind === 'call') {
205
+ // An opaque, unmodeled call between here and the sink — refuse. We
206
+ // cannot know whether it further transforms (or un-does a real
207
+ // sanitizer's effect on) the value.
208
+ return { feasible: 'unknown', reason: `opaque-call-on-path:${step.callee || 'unknown'}` };
209
+ }
210
+ if (step.kind === 'source') break; // reached the origin with no sanitizer found
211
+ }
212
+ return { feasible: 'unknown', reason: 'no-sanitizer-on-path' };
213
+ }
214
+
115
215
  function _sanitizerRegexFor(callee) {
116
216
  if (!callee) return null;
117
- const tail = String(callee).split('.').pop();
118
- const table = {
119
- encodeURIComponent: /^[A-Za-z0-9\-_.!~*'()%]*$/,
120
- parseInt: /^-?\d+$/,
121
- parseFloat: /^-?\d+(?:\.\d+)?$/,
122
- digest: /^[0-9a-f]+$/,
123
- htmlspecialchars: /^[^<>&"']*$/,
124
- escapeHtml: /^[^<>&"']*$/,
125
- setString: /^.*$/, // parameterized → infeasible regardless of content
126
- AddWithValue: /^.*$/,
127
- bindParam: /^.*$/,
128
- parameterize: /^.*$/,
129
- };
130
- return table[tail] || null;
217
+ if (_PARAMETERIZED_CALL_NAMES.has(String(callee).split('.').pop())) return /^.*$/;
218
+ return safeSanitizerOutputRegex(callee);
131
219
  }
132
220
 
133
221
  /**
@@ -180,7 +268,7 @@ export async function proveExploits(findings, opts = {}) {
180
268
 
181
269
  export const _internal = {
182
270
  EXPLOIT_INPUTS, FAMILY_METACHARS, _sanitizerRegexFor, smtLiteInfeasibilityCheck,
183
- _maybeLoadZ3,
271
+ _maybeLoadZ3, _backwardSliceExcludesMetachars,
184
272
  };
185
273
 
186
274
  // Re-export provablyMatches so callers don't have to thread imports.
@@ -98,7 +98,7 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
98
98
  // every consumer degrades to today's behavior when it finds no useful type.
99
99
  let classHierarchy = null;
100
100
  try {
101
- classHierarchy = buildClassHierarchy(perFileIR);
101
+ classHierarchy = buildClassHierarchy(perFileIR, callGraph);
102
102
  // PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — RTA-annotate so
103
103
  // `engine.js`'s `_resolveMemberCalleeViaCHA` can resolve a
104
104
  // parameter-typed (or otherwise non-`new`-tracked) receiver against
@@ -163,7 +163,17 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
163
163
  });
164
164
  }
165
165
  // P1.4 — backward slice (opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1).
166
- if (process.env.AGENTIC_SECURITY_BACKWARD_SLICE === '1') {
166
+ // Next-gen taint capability #5 (SMT path-feasibility rebuild): ALSO run
167
+ // this when AGENTIC_SECURITY_SYMEXEC=1 is set, even if the operator never
168
+ // separately set AGENTIC_SECURITY_BACKWARD_SLICE — `proveExploits`'s own
169
+ // infeasibility check now depends on `finding.backwardSlice` for a sound
170
+ // verdict (see exploit-prover.js's own header), and requiring an operator
171
+ // to know to independently set a SECOND, differently-named flag just to
172
+ // get a real answer out of the first is exactly the kind of silent,
173
+ // undiscoverable degradation this codebase's own verification discipline
174
+ // warns against — SYMEXEC's own soundness must not be a hidden function
175
+ // of an unrelated flag.
176
+ if (process.env.AGENTIC_SECURITY_BACKWARD_SLICE === '1' || process.env.AGENTIC_SECURITY_SYMEXEC === '1') {
167
177
  findings = annotateBackwardSlices(findings, perFileIR, callGraph);
168
178
  }
169
179
  // Roadmap #6 — flow-proof: prove SQL sinks reached only through a
@@ -146,8 +146,44 @@ const SANITIZER_OUTPUT_REGEX = {
146
146
  // (We can't distinguish overloads from regex name alone; conservative listing.)
147
147
  // PHP htmlspecialchars / htmlentities — HTML-entity escape.
148
148
  htmlspecialchars: /^[^<>&"']*(?:&(?:lt|gt|amp|quot|#039);)*[^<>&"']*$/,
149
+ // JS-ecosystem HTML escapers (he/escape-html/lodash.escape and similar
150
+ // libraries all converge on the same five-entity escape set) — same
151
+ // shape as htmlspecialchars above, different ecosystem name. Added
152
+ // during the SMT path-feasibility rebuild: this exact name was already
153
+ // trusted by exploit-prover.js's own (now-superseded) duplicate table,
154
+ // so omitting it here would have been a real regression, not a
155
+ // deliberate narrowing.
156
+ escapeHtml: /^[^<>&"']*(?:&(?:lt|gt|amp|quot|#039);)*[^<>&"']*$/,
149
157
  };
150
158
 
159
+ // Next-gen taint capability #5 (SMT path-feasibility rebuild) — a SAFE,
160
+ // bare-tail lookup against the SAME catalog `abstract()` above already
161
+ // uses, exported so a soundness-critical consumer (the SMT/exploit-prover
162
+ // infeasibility check) can reuse this exact table rather than maintaining
163
+ // its own, inevitably-drifting copy — confirmed live that THREE separate,
164
+ // slightly different sanitizer-regex tables existed across this codebase
165
+ // before this fix (this one, exploit-prover.js's own `_sanitizerRegexFor`,
166
+ // and smt-feasibility.js's now-superseded encoder), each with subtly
167
+ // different entries.
168
+ //
169
+ // Deliberately EXCLUDES `toString` from what this function will return:
170
+ // `abstract()`'s own use of the FULL table above is fine with the
171
+ // over-approximation (worst case, a coarser Regex/Unknown classification —
172
+ // never a false SAFETY claim on its own, since nothing downstream of
173
+ // `abstract()` treats its answer as license to PROVE a finding infeasible).
174
+ // A soundness-critical consumer is different in kind: treating "this
175
+ // callee's output matches the regex" as grounds to demote a finding to
176
+ // provably-unreachable must not trust a name as ambiguous as `toString`
177
+ // (matches ANY object's own stringifier, not just a Buffer's base64
178
+ // encoding specifically) — an incorrect match there would manufacture a
179
+ // false "provably safe" verdict for a genuinely exploitable finding.
180
+ export function safeSanitizerOutputRegex(calleeName) {
181
+ if (!calleeName) return null;
182
+ const tail = String(calleeName).split('.').pop();
183
+ if (tail === 'toString') return null;
184
+ return SANITIZER_OUTPUT_REGEX[tail] || null;
185
+ }
186
+
151
187
  /**
152
188
  * SAFE-CHARSET PROOF — does the abstract value provably fit the given regex?
153
189
  *
package/src/engine.js CHANGED
@@ -239,7 +239,6 @@ import { runCrossServiceTaint } from './dataflow/cross-service-taint.js';
239
239
  import { annotateRuntimeCorrelation } from './posture/runtime-correlation.js';
240
240
  import { applyLearnedCalibration } from './posture/triage-learning.js';
241
241
  import { annotateFormalVerification } from './dataflow/formal-verify.js';
242
- import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
243
242
  import { annotateProofGate } from './dataflow/proof-gate.js';
244
243
  import { applySanitizerGate } from './dataflow/sanitizer-gate.js';
245
244
  import { annotateFalsification } from './posture/falsification.js';
@@ -9839,11 +9838,16 @@ function _deterministicFileTimings(timings) {
9839
9838
  if (process.env.AGENTIC_SECURITY_FORMAL === '1') {
9840
9839
  await _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
9841
9840
  }
9842
- // SMT path feasibility: Z3-backed proof of reachability. Opt-in via
9843
- // AGENTIC_SECURITY_SMT_FEASIBILITY=1.
9844
- if (process.env.AGENTIC_SECURITY_SMT_FEASIBILITY === '1') {
9845
- await _runAnnotator("annotatePathFeasibility", async () => { await annotatePathFeasibility(finalFindings, {}); });
9846
- }
9841
+ // SMT path feasibility (`AGENTIC_SECURITY_SMT_FEASIBILITY`) was retired:
9842
+ // its predicate encoder read `f.chain`/`f.taintPath` as a sequential
9843
+ // source-to-sink path, but those fields are actually a flat list of
9844
+ // SOURCES reaching the sink (`_sourcesReachingExpr`), never a path —
9845
+ // confirmed by direct code reading, never fixed by real data. It also
9846
+ // mutated `f.severity` directly on an 'unsat' verdict, violating this
9847
+ // codebase's own recall-preserving-demotion convention that every other
9848
+ // proof gate here follows (see `dataflow/CLAUDE.md`). Superseded by the
9849
+ // real backward-slice-driven mechanism: `dataflow/exploit-prover.js`'s
9850
+ // `proveExploits`, opt-in via `AGENTIC_SECURITY_SYMEXEC=1`.
9847
9851
  // Privacy / PII taint: emits pii-exposure findings + DPIA artifact.
9848
9852
  // FR-405 (assurance-hardening PRD): _privacyIrBacked is declared at
9849
9853
  // function scope above the AGENTIC_SECURITY_NO_INTEGRATION block, not
@@ -39,8 +39,15 @@ const _AST_CACHE = new WeakMap();
39
39
  * Build the CHA over a perFileIR map (file → parsed IR with raw AST attached
40
40
  * under `_ast`). When AST isn't attached, fall back to the IR's own
41
41
  * structural hints (class names appearing in qids).
42
+ *
43
+ * `callGraph` (optional, from `../ir/callgraph.js#buildCallGraph`) enables
44
+ * one additional, otherwise-skipped pass: cross-call-site parameter type
45
+ * inference from the argument a caller actually passes (see that pass's own
46
+ * header comment below for the full rationale). Every existing caller that
47
+ * only ever passed `perFileIR` keeps working unchanged — the pass simply
48
+ * doesn't run without it.
42
49
  */
43
- export function buildClassHierarchy(perFileIR) {
50
+ export function buildClassHierarchy(perFileIR, callGraph) {
44
51
  const classes = new Map(); // className -> { file, line, methods, extends }
45
52
  const methodOwners = new Map(); // qid -> className
46
53
  const typeOfVar = new Map(); // file::scope::var -> className
@@ -261,6 +268,158 @@ export function buildClassHierarchy(perFileIR) {
261
268
  }
262
269
  }
263
270
 
271
+ // Next-gen taint capability #4b — factory-function RETURN-type inference,
272
+ // the sibling gap this file's OWN header comment names and explicitly
273
+ // marks unimplemented ("`function buildFoo(): Foo { ... }` typed-return
274
+ // inference — NOT implemented. `const x = buildFoo()` is untyped
275
+ // (correctly: it is a plain call, not a `new`)"). Confirmed still real via
276
+ // a direct reproduction: `function makeHelper() { return new Helper(); }
277
+ // const h = makeHelper(); h.getValue(req);` misses the vulnerability
278
+ // entirely, the identical failure shape 4a (above) closes for the
279
+ // parameter case. `_inferredFactoryReturnClass` is deliberately
280
+ // conservative, same "refuse rather than guess" discipline as every other
281
+ // pass in this file: a function qualifies ONLY when every one of its own
282
+ // `return` statements is a `new ClassName(...)` literal of the SAME
283
+ // class — a function with no return, a non-constructor return on any
284
+ // path, or disagreeing classes across returns all correctly infer
285
+ // nothing, rather than picking one arbitrarily.
286
+ function _inferredFactoryReturnClass(fn) {
287
+ if (!fn || !fn.cfg || !fn.cfg.nodes) return null;
288
+ let className = null;
289
+ for (const node of Object.values(fn.cfg.nodes)) {
290
+ if (node.kind !== 'return' || !node.value) continue;
291
+ const v = node.value;
292
+ if (v.kind !== 'call' || !v.isNew) return null;
293
+ const callee = v.callee;
294
+ const cn = callee?.kind === 'ident' ? callee.name
295
+ : typeof callee === 'string' ? (callee.includes('.') ? callee.slice(callee.lastIndexOf('.') + 1) : callee)
296
+ : null;
297
+ if (!cn || !(classes.has(cn) || /^[A-Z]/.test(cn))) return null;
298
+ if (className === null) className = cn;
299
+ else if (className !== cn) return null;
300
+ }
301
+ return className;
302
+ }
303
+ if (callGraph && typeof callGraph.resolveKnownCallee === 'function' && callGraph.functions) {
304
+ const factoryReturnCache = new Map(); // qid -> className|null, memoized
305
+ const factoryReturnClassOf = (qid, fn) => {
306
+ if (factoryReturnCache.has(qid)) return factoryReturnCache.get(qid);
307
+ const cn = _inferredFactoryReturnClass(fn);
308
+ factoryReturnCache.set(qid, cn);
309
+ return cn;
310
+ };
311
+ for (const [callerFile, ir] of Object.entries(perFileIR)) {
312
+ if (!ir || !Array.isArray(ir.functions)) continue;
313
+ for (const fn of ir.functions) {
314
+ if (!fn.cfg || !fn.cfg.nodes) continue;
315
+ for (const node of Object.values(fn.cfg.nodes)) {
316
+ if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.')) continue;
317
+ const src = node.source;
318
+ // A plain call (isNew must be FALSY — a real `new Foo()` is
319
+ // already handled by the constructor-assignment pass above, and
320
+ // conflating the two would just redundantly re-derive the same
321
+ // answer through a slower path).
322
+ if (!src || src.kind !== 'call' || src.isNew) continue;
323
+ const calleeName = typeof src.callee === 'string' ? src.callee
324
+ : src.callee && src.callee.kind === 'ident' ? src.callee.name : null;
325
+ if (!calleeName) continue;
326
+ const calleeQid = callGraph.resolveKnownCallee(calleeName, callerFile);
327
+ if (!calleeQid) continue;
328
+ const calleeFn = callGraph.functions.get(calleeQid);
329
+ if (!calleeFn) continue;
330
+ const className = factoryReturnClassOf(calleeQid, calleeFn);
331
+ if (!className) continue;
332
+ const key = `${callerFile}::${fn.qid}::${node.target}`;
333
+ if (ambiguousVarKeys.has(key)) continue;
334
+ if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
335
+ ambiguousVarKeys.add(key);
336
+ typeOfVar.delete(key);
337
+ } else {
338
+ typeOfVar.set(key, className);
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ // Next-gen taint capability #4 (a narrow, call-site-scoped slice of
346
+ // "Andersen-style points-to", not the full thing): cross-call-site
347
+ // PARAMETER type inference from the ARGUMENT a caller actually passes, for
348
+ // JS/TS — which has no declared-type mechanism at all (`fn.paramTypes`
349
+ // just above only ever fires for Java/C#). Juliet's own dominant
350
+ // "driver constructs the concrete instance, passes it as a parameter to a
351
+ // helper that invokes the virtual method" idiom (already solved for
352
+ // Java/C# via fn.paramTypes) has no JS/TS equivalent: `function
353
+ // wrapper(h, req) { return h.getValue(req); }`, called only as `const h =
354
+ // new Helper(); wrapper(h, req);`, leaves `h` permanently untyped inside
355
+ // wrapper's own body, so `h.getValue()` can never resolve via CHA —
356
+ // confirmed via a direct reproduction (cold-cache AND warm-incremental-
357
+ // cache both miss it identically, ruling out a caching-specific cause)
358
+ // showing a real cross-file taint-recall false negative for exactly this
359
+ // shape. Requires `callGraph` (optional, backward-compatible — every
360
+ // existing caller that only ever passed `perFileIR` simply skips this
361
+ // pass, matching this file's own established graceful-degradation
362
+ // convention). Deliberately runs as a LAST, separate pass over every file
363
+ // (not merged into the per-file loop above) because it needs BOTH a
364
+ // cross-file callee lookup (`callGraph.resolveKnownCallee`) AND the
365
+ // CALLER's own already-inferred `typeOfVar` entries (an identifier
366
+ // argument's type, established by the constructor-assignment pass above)
367
+ // — neither is available mid-way through that loop for an arbitrary file
368
+ // ordering.
369
+ if (callGraph && typeof callGraph.resolveKnownCallee === 'function' && callGraph.functions) {
370
+ for (const [callerFile, ir] of Object.entries(perFileIR)) {
371
+ if (!ir || !Array.isArray(ir.functions)) continue;
372
+ for (const callerFn of ir.functions) {
373
+ if (!Array.isArray(callerFn.calls)) continue;
374
+ for (const call of callerFn.calls) {
375
+ const calleeName = typeof call.callee === 'string' ? call.callee
376
+ : call.callee && call.callee.kind === 'ident' ? call.callee.name : null;
377
+ if (!calleeName) continue;
378
+ const calleeQid = callGraph.resolveKnownCallee(calleeName, callerFile);
379
+ if (!calleeQid) continue;
380
+ const calleeFn = callGraph.functions.get(calleeQid);
381
+ if (!calleeFn || !Array.isArray(calleeFn.params) || !calleeFn.params.length || !calleeFn.file) continue;
382
+ const args = Array.isArray(call.args) ? call.args : [];
383
+ for (let i = 0; i < args.length && i < calleeFn.params.length; i++) {
384
+ const arg = args[i];
385
+ let className = null;
386
+ if (arg && arg.kind === 'call' && arg.isNew) {
387
+ // A literal `new ClassName(...)` passed directly as the argument.
388
+ const argCallee = arg.callee;
389
+ className = argCallee?.kind === 'ident' ? argCallee.name
390
+ : typeof argCallee === 'string' ? (argCallee.includes('.') ? argCallee.slice(argCallee.lastIndexOf('.') + 1) : argCallee)
391
+ : null;
392
+ } else if (arg && arg.kind === 'ident' && arg.name) {
393
+ // Juliet's own dominant idiom: `const h = new Helper(); f(h,
394
+ // ...)`. `h`'s type was already established by the
395
+ // constructor-assignment pass above (it runs first, in the
396
+ // SAME outer file loop) — reuse it directly rather than
397
+ // re-deriving, so this stays in sync with that pass's own
398
+ // ambiguity handling by construction.
399
+ className = typeOfVar.get(`${callerFile}::${callerFn.qid}::${arg.name}`) || null;
400
+ }
401
+ if (!className || !(classes.has(className) || /^[A-Z]/.test(className))) continue;
402
+ const paramName = calleeFn.params[i];
403
+ if (typeof paramName !== 'string' || !paramName) continue;
404
+ // Same collision-refusal discipline as every other typeOfVar-
405
+ // populating pass in this file: a wrong "confidently resolved"
406
+ // type is worse than staying unknown, so a SECOND call site
407
+ // disagreeing with an already-recorded type for this exact
408
+ // parameter permanently refuses it, never picks a side.
409
+ const key = `${calleeFn.file}::${calleeQid}::${paramName}`;
410
+ if (ambiguousVarKeys.has(key)) continue;
411
+ if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
412
+ ambiguousVarKeys.add(key);
413
+ typeOfVar.delete(key);
414
+ } else {
415
+ typeOfVar.set(key, className);
416
+ }
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+
264
423
  return { classes, methodOwners, typeOfVar };
265
424
  }
266
425
 
@@ -228,6 +228,18 @@ export function normalizeFindings(scan){
228
228
  dataClasses: f.dataClasses || [],
229
229
  chain: Array.isArray(f.chain) ? f.chain : null,
230
230
  sourceProvenance: f.sourceProvenance || null,
231
+ // Next-gen taint capability #7 (cross-service schema-driven taint):
232
+ // dataflow/cross-service-taint.js SETS crossService/_severityBumpReason
233
+ // on findings whose source crosses a declared services.yml trust
234
+ // boundary, but this allowlist never named either field — the same
235
+ // "silently dropped field" bug class engine.js's own _funcQid/callee/
236
+ // argIndex comments already document twice for the raw finding
237
+ // object, this time at the report layer. Without this, an operator
238
+ // reading a report saw a bumped severity with no visible reason at
239
+ // all: `severity` itself IS in this allowlist (so the bump survived),
240
+ // but its only explanation was silently discarded.
241
+ crossService: f.crossService || null,
242
+ _severityBumpReason: f._severityBumpReason || null,
231
243
  confidence: typeof f.confidence === 'number' ? f.confidence : null,
232
244
  // R17: corroboration ("one issue, many signals") — count of independent
233
245
  // analyses that agreed, and which ones.