@clear-capabilities/agentic-security-scanner 0.151.3 → 0.152.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 (45) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/agentic-security.mjs +80 -80
  3. package/dist/agentic-security.mjs.sha256 +1 -1
  4. package/package.json +5 -5
  5. package/src/dataflow/CLAUDE.md +1 -1
  6. package/src/dataflow/catalog.js +802 -13
  7. package/src/dataflow/engine.js +852 -89
  8. package/src/dataflow/incremental.js +13 -0
  9. package/src/dataflow/index.js +18 -5
  10. package/src/dataflow/path-feasibility.js +86 -9
  11. package/src/dataflow/string-domain.js +132 -0
  12. package/src/dataflow/summaries.js +69 -8
  13. package/src/engine.js +108 -3
  14. package/src/ir/CLAUDE.md +2 -1
  15. package/src/ir/balanced-call.js +33 -1
  16. package/src/ir/callgraph.js +26 -2
  17. package/src/ir/class-hierarchy.js +101 -6
  18. package/src/ir/csharp-ir.js +97 -8
  19. package/src/ir/index.js +21 -1
  20. package/src/ir/parser-cs.js +706 -24
  21. package/src/ir/parser-java.js +498 -17
  22. package/src/ir/parser-js.js +22 -0
  23. package/src/ir/parser-php.js +446 -43
  24. package/src/ir/parser-rust.js +1338 -0
  25. package/src/ir/php-include-merge.js +190 -0
  26. package/src/lineage/sink-registry.js +18 -0
  27. package/src/lineage/source-registry.js +23 -0
  28. package/src/posture/csharp-analysis.js +341 -43
  29. package/src/posture/finding-defaults.js +5 -1
  30. package/src/posture/fix-coverage.js +1 -0
  31. package/src/posture/proof-coverage.js +1 -0
  32. package/src/sast/CLAUDE.md +1 -1
  33. package/src/sast/authz.js +144 -1
  34. package/src/sast/crypto-protocol.js +65 -7
  35. package/src/sast/csharp-cross-file.js +125 -0
  36. package/src/sast/csharp-structural.js +78 -2
  37. package/src/sast/csharp.js +574 -42
  38. package/src/sast/java-ast-folding.js +268 -11
  39. package/src/sast/java-bench-extras.js +530 -2
  40. package/src/sast/java-deserialization.js +31 -2
  41. package/src/sast/java-structural-cross-file.js +146 -0
  42. package/src/sast/java-structural.js +499 -6
  43. package/src/sast/ldap-injection.js +133 -3
  44. package/src/sast/rust.js +57 -0
  45. package/src/sast/xss-reflected-multilang.js +252 -3
@@ -149,6 +149,18 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
149
149
  const summary = {
150
150
  returnTainted: !!s.returnTainted,
151
151
  mutatedParams: new Set(s.mutatedParams || []),
152
+ // W5.53: mutatedThisFields is a THIRD Set-valued summary field
153
+ // (object-instance field taint via method calls), added after this
154
+ // module's own serialize/reseed pair was written — omitting it here
155
+ // wouldn't crash (every consumer already null-guards with `&&`), but
156
+ // would silently drop the capability on any incremental/warm-cache
157
+ // scan, exactly the class of gap `_summaryEq`'s own header comment in
158
+ // summaries.js warns about for a different (but structurally
159
+ // identical) reason: a summary field that exists on some code paths
160
+ // and not others must be threaded through EVERY path that copies the
161
+ // summary shape, not just the ones exercised by this session's own
162
+ // tests.
163
+ mutatedThisFields: new Set(s.mutatedThisFields || []),
152
164
  taintedGlobals: new Set(s.taintedGlobals || []),
153
165
  findings: Array.isArray(s.findings) ? s.findings : [],
154
166
  };
@@ -195,6 +207,7 @@ export function serializeSummaries(summaryCache) {
195
207
  out[qid] = {
196
208
  returnTainted: !!summary.returnTainted,
197
209
  mutatedParams: [...(summary.mutatedParams || [])],
210
+ mutatedThisFields: [...(summary.mutatedThisFields || [])],
198
211
  taintedGlobals: [...(summary.taintedGlobals || [])],
199
212
  findings: Array.isArray(summary.findings) ? summary.findings.slice(0, 50) : [],
200
213
  };
@@ -2,7 +2,7 @@
2
2
  import { runTaintEngine } from './engine.js';
3
3
  import { annotateProvenClean } from './proven-clean.js';
4
4
  import { CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize } from './catalog.js';
5
- import { applyPathFeasibility } from './path-feasibility.js';
5
+ import { applyPathFeasibility, buildConstantFnMap } from './path-feasibility.js';
6
6
  import { SummaryCache, entryStateFromCall } from './summaries.js';
7
7
  import { rhsReachableFunctions, shouldAnalyzeUnderRhs } from './tabulation.js';
8
8
  import { annotateBackwardSlices } from './backward.js';
@@ -12,7 +12,7 @@ import {
12
12
  serializeSummaries, commitIncrementalState,
13
13
  } from './incremental.js';
14
14
  import { buildPointsTo } from './points-to.js';
15
- import { buildClassHierarchy } from '../ir/class-hierarchy.js';
15
+ import { buildClassHierarchy, annotateRTA } from '../ir/class-hierarchy.js';
16
16
  import { annotateSoftTaint } from './soft-taint.js';
17
17
  import { runIfdsTaintEngine } from './ifds.js';
18
18
  import { proveExploits } from './exploit-prover.js';
@@ -22,9 +22,13 @@ import { runPrivacyTaintEngine } from './privacy-deep-walker.js';
22
22
 
23
23
  export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
24
24
  // Path-feasibility pass over every function before the taint walk.
25
+ // W2.1: build the trivially-constant-helper map ONCE, from every function
26
+ // in the scan, before folding any single function's conditions — a
27
+ // constant helper in file A must resolve when called from file B.
28
+ const constFns = buildConstantFnMap(callGraph.functions.values());
25
29
  let totalPruned = 0;
26
30
  for (const fn of callGraph.functions.values()) {
27
- const r = applyPathFeasibility(fn);
31
+ const r = applyPathFeasibility(fn, constFns);
28
32
  totalPruned += r.pruned;
29
33
  }
30
34
  // P2.1 — RHS-lite reachability slice. When AGENTIC_SECURITY_RHS=1 the
@@ -93,7 +97,16 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
93
97
  // (a single walk of the already-parsed IR, no fixed-point iteration) and
94
98
  // every consumer degrades to today's behavior when it finds no useful type.
95
99
  let classHierarchy = null;
96
- try { classHierarchy = buildClassHierarchy(perFileIR); } catch { classHierarchy = null; }
100
+ try {
101
+ classHierarchy = buildClassHierarchy(perFileIR);
102
+ // PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — RTA-annotate so
103
+ // `engine.js`'s `_resolveMemberCalleeViaCHA` can resolve a
104
+ // parameter-typed (or otherwise non-`new`-tracked) receiver against
105
+ // whichever subclasses are ACTUALLY instantiated somewhere in this
106
+ // scan, not just the receiver's own declared/abstract type (which
107
+ // typically has no method BODY of its own to resolve to at all).
108
+ if (classHierarchy) annotateRTA(classHierarchy, perFileIR);
109
+ } catch { classHierarchy = null; }
97
110
  // v0.70 #2 — Steensgaard points-to / alias analysis. Built once before
98
111
  // the worklist, passed via opts so the engine can resolve aliased
99
112
  // mutations (`let a = obj; a.x = tainted; sink(obj.x)`).
@@ -237,4 +250,4 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
237
250
  return findings;
238
251
  }
239
252
 
240
- export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };
253
+ export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, buildConstantFnMap, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };
@@ -14,21 +14,84 @@
14
14
  //
15
15
  // Patterns deliberately deferred (would need SMT or symbolic execution):
16
16
  // - Comparisons of unrelated variables
17
- // - Comparisons involving function call return values
18
17
  // - Aliasing-aware constraint propagation
18
+ // - Static/instance final-field constants (PRD W2.1's other half — needs
19
+ // parser changes to capture field initializer values across all IR
20
+ // languages, not attempted here)
21
+ //
22
+ // W2.1: trivially-constant helper returns. Juliet's control-flow-gating
23
+ // variants (02-22) commonly gate through a helper — `if (privateReturnsTrue())`,
24
+ // `if (IO.staticReturnsTrueOrFalse())` — rather than an inline literal. This
25
+ // is NOT a Juliet-specific special case: `buildConstantFnMap` (below, called
26
+ // once per scan in dataflow/index.js) generically finds any function whose
27
+ // ENTIRE body is `return <literal>;` and nothing else, for ANY codebase,
28
+ // then this module resolves a call to such a function the same way it
29
+ // already resolves `if (true)`. A function with real logic (even one extra
30
+ // statement) is never treated as constant — this reads the function's own
31
+ // semantics, not an answer-key label.
19
32
  //
20
33
  // Output: mutates the CFG node's `succ` array to drop unreachable edges. The
21
34
  // existing taint engine then never walks them. Logs the prune count on each
22
35
  // function so we can count how many FPs path-feasibility avoided.
23
36
 
24
- function evalConst(expr) {
37
+ // A function is "trivially constant" iff its CFG, after stripping entry/exit/
38
+ // noop bookkeeping nodes, is exactly one `return <literal>;` node. Anything
39
+ // else (a branch, an assignment, a call, a non-literal return) disqualifies
40
+ // it — conservative by design, since a false resolution here silently prunes
41
+ // a REAL branch.
42
+ function triviallyConstantValue(fn) {
43
+ if (!fn || !fn.cfg || !fn.cfg.nodes) return undefined;
44
+ const real = Object.values(fn.cfg.nodes).filter(n => n && n.kind !== 'entry' && n.kind !== 'exit' && n.kind !== 'noop');
45
+ if (real.length !== 1) return undefined;
46
+ const only = real[0];
47
+ if (only.kind !== 'return') return undefined;
48
+ if (!only.value || only.value.kind !== 'literal') return undefined;
49
+ return only.value.value;
50
+ }
51
+
52
+ // Builds { byQid: Map<qid, value>, byBareName: Map<lastSegment, value|AMBIGUOUS> }
53
+ // from every function in the scan. Bare-name resolution is how the hand-rolled
54
+ // parsers' flat dotted-string callees (`"IO.staticReturnsTrue"`,
55
+ // `"privateReturnsTrue"`) get matched — same collision-refusal precedent as
56
+ // `callgraph.js`'s `~bare~` key: two differently-valued same-named functions
57
+ // refuse to resolve via bare name rather than guessing.
58
+ const AMBIGUOUS = Symbol('ambiguous-constant-fn');
59
+
60
+ export function buildConstantFnMap(functions) {
61
+ const byQid = new Map();
62
+ const byBareName = new Map();
63
+ for (const fn of functions) {
64
+ const val = triviallyConstantValue(fn);
65
+ if (val === undefined) continue;
66
+ if (fn.qid) byQid.set(fn.qid, val);
67
+ const bare = (fn.name || '').split('.').pop();
68
+ if (!bare) continue;
69
+ if (byBareName.has(bare)) {
70
+ const existing = byBareName.get(bare);
71
+ if (existing !== AMBIGUOUS && existing !== val) byBareName.set(bare, AMBIGUOUS);
72
+ } else {
73
+ byBareName.set(bare, val);
74
+ }
75
+ }
76
+ return { byQid, byBareName };
77
+ }
78
+
79
+ function _bareCalleeName(callee) {
80
+ if (!callee) return null;
81
+ if (typeof callee === 'string') return callee.split('.').pop() || null;
82
+ if (callee.kind === 'ident') return callee.name || null;
83
+ if (callee.kind === 'member') return callee.prop || null;
84
+ return null;
85
+ }
86
+
87
+ function evalConst(expr, constFns) {
25
88
  if (!expr) return undefined;
26
89
  switch (expr.kind) {
27
90
  case 'literal': return expr.value;
28
91
  case 'unknown': return undefined;
29
92
  case 'binary': {
30
- const l = evalConst(expr.left);
31
- const r = evalConst(expr.right);
93
+ const l = evalConst(expr.left, constFns);
94
+ const r = evalConst(expr.right, constFns);
32
95
  if (l === undefined || r === undefined) return undefined;
33
96
  switch (expr.op) {
34
97
  case '===': return l === r;
@@ -47,10 +110,10 @@ function evalConst(expr) {
47
110
  return undefined;
48
111
  }
49
112
  case 'logical': {
50
- const l = evalConst(expr.left);
113
+ const l = evalConst(expr.left, constFns);
51
114
  if (l === undefined) return undefined;
52
- if (expr.op === '&&') return l ? evalConst(expr.right) : l;
53
- if (expr.op === '||') return l ? l : evalConst(expr.right);
115
+ if (expr.op === '&&') return l ? evalConst(expr.right, constFns) : l;
116
+ if (expr.op === '||') return l ? l : evalConst(expr.right, constFns);
54
117
  return undefined;
55
118
  }
56
119
  case 'ident': {
@@ -62,6 +125,20 @@ function evalConst(expr) {
62
125
  // x === x style: the engine can't fold this without symbolic equality.
63
126
  return undefined;
64
127
  }
128
+ case 'call': {
129
+ // W2.1: a call to a function whose entire body is `return <literal>;`
130
+ // resolves to that literal — see `buildConstantFnMap` above. Prefer an
131
+ // exact qid match (rare for hand-rolled parsers' flat callee strings,
132
+ // but free to check), fall back to the collision-refused bare-name map.
133
+ if (!constFns) return undefined;
134
+ if (typeof expr.callee === 'string' && constFns.byQid.has(expr.callee)) {
135
+ return constFns.byQid.get(expr.callee);
136
+ }
137
+ const bare = _bareCalleeName(expr.callee);
138
+ if (!bare) return undefined;
139
+ const v = constFns.byBareName.get(bare);
140
+ return v === AMBIGUOUS ? undefined : v;
141
+ }
65
142
  }
66
143
  return undefined;
67
144
  }
@@ -77,7 +154,7 @@ function syntacticallyEqual(a, b) {
77
154
  return false;
78
155
  }
79
156
 
80
- export function applyPathFeasibility(fn) {
157
+ export function applyPathFeasibility(fn, constFns) {
81
158
  if (!fn || !fn.cfg || !fn.cfg.nodes) return { pruned: 0 };
82
159
  let pruned = 0;
83
160
  for (const id of Object.keys(fn.cfg.nodes)) {
@@ -86,7 +163,7 @@ export function applyPathFeasibility(fn) {
86
163
  const cond = node.cond;
87
164
  if (!cond) continue;
88
165
  // Constant cond?
89
- const val = evalConst(cond);
166
+ const val = evalConst(cond, constFns);
90
167
  if (val === true) {
91
168
  // Drop the second successor (the false branch).
92
169
  if (node.succ.length > 1) {
@@ -232,3 +232,135 @@ export function isSafeRedirectTarget(absVal, allowedHosts) {
232
232
  export function hashAbstract(absVal) {
233
233
  return render(absVal);
234
234
  }
235
+
236
+ /**
237
+ * Next-gen taint capability #2 — regex-VALIDATION guard safety.
238
+ *
239
+ * A DIFFERENT question from `provablyMatches` above (which asks "does this
240
+ * already-known abstract value fit a safety regex"): here we're asked "is
241
+ * this REGEX PATTERN, used as a `pattern.test(x)`-style validation guard,
242
+ * restrictive enough that observing it MATCH on the true branch proves `x`
243
+ * cannot carry injection metacharacters for ANY family?" This is the JS/TS
244
+ * analog of PHP's `if (preg_match($re, $x) == 1) { ... }` idiom — found to
245
+ * be a genuine, real-world-common pattern with NO recognition anywhere in
246
+ * this engine (neither the name-based `GUARD_PREDICATES` in engine.js, nor
247
+ * this file's own `abstract()`/`SANITIZER_OUTPUT_REGEX`, which characterizes
248
+ * a SANITIZER CALL's output, not a validation-guard's CONDITION).
249
+ *
250
+ * Deliberately a conservative ALLOWLIST parser, not a full regex-engine
251
+ * subset check (undecidable in general): walks the pattern character by
252
+ * character, permitting only literal safe characters (letters, digits, and
253
+ * a short vetted punctuation set), `\d`/`\w` shorthand classes, and
254
+ * bracket character classes built from the same safe set — with
255
+ * quantifiers on any of the above. Anything it cannot fully account for
256
+ * (alternation, groups, wildcards, negated classes `[^...]`, negated
257
+ * shorthand `\D`/`\W`/`\s`/`\S`, backreferences, lookaround, unicode
258
+ * property escapes, an unescaped stray metacharacter) REJECTS rather than
259
+ * guesses — a false "safe" here would silently suppress a real,
260
+ * exploitable finding, which is a much worse failure mode than missing a
261
+ * genuinely-safe-but-unrecognized pattern (the existing, unchanged taint
262
+ * flow still fires in that case, exactly as it did before this capability
263
+ * existed).
264
+ *
265
+ * Also requires full anchoring (`^...$`) and rejects the `m` (multiline)
266
+ * flag: with `m` set, `^`/`$` match at LINE boundaries rather than string
267
+ * boundaries, so a multi-line input could have a safe first line and an
268
+ * unsafe second line while still matching `^safe$` against line one —
269
+ * silently defeating the "matched, therefore the WHOLE value is safe"
270
+ * guarantee this function exists to provide.
271
+ */
272
+ export function isSafeValidationPattern(pattern) {
273
+ if (!(pattern instanceof RegExp)) return false;
274
+ if (pattern.flags && pattern.flags.includes('m')) return false;
275
+ const source = pattern.source;
276
+ if (!source.startsWith('^') || !source.endsWith('$')) return false;
277
+ const body = source.slice(1, -1);
278
+ if (!body) return false; // `^$` (empty-string-only) is degenerate, not a useful guard
279
+ // Two DISTINCT allowlists, deliberately not merged into one — conflating
280
+ // them is exactly the bug this comment now documents (caught by this
281
+ // function's own adversarial unit tests before ever reaching production):
282
+ //
283
+ // `isSafeBareChar` — characters with NO special regex meaning when they
284
+ // appear UNESCAPED. Critically, `.` (the wildcard metacharacter — matches
285
+ // ANY character) is NOT in this set: an earlier version of this function
286
+ // included `.` here on the (wrong) assumption that it's "just punctuation,"
287
+ // which caused `/^.*$/` — the real PHP generator's OWN "no_filtering"
288
+ // unsafe sample, matching literally everything — to be accepted as a safe
289
+ // guard. A bare `.` in a pattern is ALWAYS the wildcard; a literal dot
290
+ // must be escaped (`\.`) or appear inside a character class (`[.]`),
291
+ // both handled by the escaped/class-content paths below.
292
+ //
293
+ // `isSafeEscapedPunct` — punctuation that, when ESCAPED with a backslash,
294
+ // unambiguously means "match this literal character." Deliberately
295
+ // EXCLUDES every letter and digit: an escaped letter in regex syntax is
296
+ // Two DELIBERATELY SEPARATE allowlists, keyed by context, not merged into
297
+ // one — conflating them is the exact bug class this function's own
298
+ // adversarial unit tests caught twice while writing it, both fixed here:
299
+ //
300
+ // `isSafeBareChar` (governs an UNESCAPED character, bare or inside a
301
+ // class) — letters, digits, underscore, hyphen, at-sign: characters with
302
+ // NO special regex meaning at all when unescaped. Crucially this does
303
+ // NOT include `.` — a bare `.` outside a class is the wildcard
304
+ // metacharacter (matches ANY character), not punctuation. An earlier
305
+ // version of this allowlist included `.`, which let `/^.*$/` — the real
306
+ // PHP generator's own "no_filtering" UNSAFE sample, matching literally
307
+ // everything — pass as a safe guard.
308
+ //
309
+ // `isSafeEscapedChar` (governs a character immediately AFTER a
310
+ // backslash) — punctuation ONLY, deliberately excluding every letter and
311
+ // digit. An escaped letter in regex syntax is NEVER a plain literal:
312
+ // `\d`/`\D`/`\w`/`\W`/`\s`/`\S`/`\b`/`\B` are shorthand/boundary
313
+ // constructs, `\1`/`\2`/… are backreferences, and any other escaped
314
+ // letter could be a unicode property escape (`\p{...}`) or named
315
+ // backreference this parser has never heard of. An earlier version of
316
+ // this function reused `isSafeBareChar` (which includes letters) for the
317
+ // escaped-character check too, so `\D` (negated digit — matches almost
318
+ // anything) was accepted as though it meant a literal capital D.
319
+ const isSafeBareChar = (ch) => /^[a-zA-Z0-9_\-@]$/.test(ch);
320
+ const isSafeEscapedChar = (ch) => /^[_\-@.]$/.test(ch);
321
+ let i = 0;
322
+ const consumeQuantifier = () => {
323
+ if (i >= body.length) return;
324
+ const ch = body[i];
325
+ if (ch === '*' || ch === '+' || ch === '?') { i++; return; }
326
+ if (ch === '{') {
327
+ const close = body.indexOf('}', i);
328
+ if (close === -1) throw new Error('unterminated');
329
+ if (!/^\d+(,\d*)?$/.test(body.slice(i + 1, close))) throw new Error('malformed');
330
+ i = close + 1;
331
+ }
332
+ };
333
+ try {
334
+ while (i < body.length) {
335
+ const ch = body[i];
336
+ if (ch === '\\') {
337
+ const next = body[i + 1];
338
+ if (next === 'd' || next === 'w') { i += 2; consumeQuantifier(); continue; }
339
+ if (next && isSafeEscapedChar(next)) { i += 2; consumeQuantifier(); continue; }
340
+ return false; // \D, \W, \s, \S, \b, an escaped letter/digit, unicode escapes, or anything unrecognized
341
+ }
342
+ if (ch === '[') {
343
+ const close = body.indexOf(']', i);
344
+ if (close === -1) return false;
345
+ const cls = body.slice(i + 1, close);
346
+ if (cls.startsWith('^')) return false; // negated class — "anything except", unsafe by construction
347
+ let j = 0;
348
+ while (j < cls.length) {
349
+ if (cls[j] === '\\' && (cls[j + 1] === 'd' || cls[j + 1] === 'w')) { j += 2; continue; }
350
+ if (cls[j] === '\\' && isSafeEscapedChar(cls[j + 1])) { j += 2; continue; }
351
+ // Inside a character class, `.` loses its wildcard meaning and IS
352
+ // a literal dot even when bare — no escaping needed here, unlike
353
+ // outside a class.
354
+ if (isSafeBareChar(cls[j]) || cls[j] === '-' || cls[j] === '.') { j++; continue; }
355
+ return false;
356
+ }
357
+ i = close + 1;
358
+ consumeQuantifier();
359
+ continue;
360
+ }
361
+ if (isSafeBareChar(ch)) { i++; consumeQuantifier(); continue; }
362
+ return false; // `.`, `|`, `(`, `)`, a bare mid-pattern `^`/`$`, or any other metacharacter
363
+ }
364
+ } catch { return false; }
365
+ return true;
366
+ }
@@ -91,9 +91,26 @@ export class SummaryCache {
91
91
  _key(qid, taintedParams, receiverType) {
92
92
  // P1.2: when a receiver type is provided, extend the cache key with
93
93
  // its hash. Backward-compatible: no receiverType → same key as before.
94
- let base = `${qid}::${_hashState(taintedParams)}`;
95
- // R2: append the caller qid when call-string sensitivity is enabled.
96
- if (this._callString && this._callerCtx) base = `${base}@${this._callerCtx}`;
94
+ const stateHash = _hashState(taintedParams);
95
+ let base = `${qid}::${stateHash}`;
96
+ // R2: append the caller qid when call-string sensitivity is enabled —
97
+ // but ONLY for a NON-EMPTY entry state. The EMPTY-entry baseline is the
98
+ // one summary `engine.js`'s outer fixed-point pre-pass loop shares and
99
+ // progressively refines across BOTH multiple passes AND every call path
100
+ // that reaches a function with no tainted args — splitting it by caller
101
+ // breaks that sharing outright: a 3-hop empty-entry mutation chain
102
+ // (leaf mutates its own param unconditionally, mid calls leaf, top calls
103
+ // mid) stopped propagating entirely once enabled, confirmed via direct
104
+ // instrumentation, not a hypothetical concern — the pre-pass loop's own
105
+ // `bMid`/`cLeaf` iterations write to a caller-LESS key nothing else ever
106
+ // reads once a NESTED call from a different caller starts writing to a
107
+ // caller-SUFFIXED key instead. A non-empty entry state has no such
108
+ // shared-convergence dependency to begin with — the value-context cache
109
+ // (`AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`) already computes each distinct
110
+ // tainted-arg shape independently, lazily, per call site, so adding a
111
+ // caller dimension there only ever narrows an already-isolated lookup,
112
+ // never disconnects a shared one.
113
+ if (this._callString && this._callerCtx && stateHash !== 'empty') base = `${base}@${this._callerCtx}`;
97
114
  if (!receiverType) return base;
98
115
  return `${base}::${hashReceiverType(receiverType)}`;
99
116
  }
@@ -103,7 +120,8 @@ export class SummaryCache {
103
120
  }
104
121
 
105
122
  set(qid, taintedParams, summary, receiverType) {
106
- this._cache.set(this._key(qid, taintedParams, receiverType), summary);
123
+ const k = this._key(qid, taintedParams, receiverType);
124
+ this._cache.set(k, summary);
107
125
  }
108
126
 
109
127
  has(qid, taintedParams, receiverType) {
@@ -172,8 +190,16 @@ export class SummaryCache {
172
190
  // Helper: apply a summary to a caller's taint state given the call site's
173
191
  // argument bindings. Returns { calleeReturnTainted, mutated: Set of caller-side
174
192
  // var names that should become tainted because the callee mutated them }.
175
- applyAtCallSite(summary, paramNames, callArgs, callerTaintedVars) {
176
- if (!summary) return { returnTainted: false, mutated: new Set() };
193
+ // `receiverPath` (optional, next-gen taint capability #3): the caller-side
194
+ // access path of a receiver-qualified call's OWN receiver (`bad` for
195
+ // `bad.setData(x)`). When present and `summary.mutatedThisFields` is
196
+ // non-empty, also returns `mutatedThisPaths` — the receiver's own field
197
+ // paths (`bad.data`) to taint in the caller, mirroring `mutated` above but
198
+ // for the implicit `this` parameter instead of a declared one. Omitting
199
+ // `receiverPath` (every pre-existing call site) leaves `mutatedThisPaths`
200
+ // empty, so this is purely additive for callers that don't pass it.
201
+ applyAtCallSite(summary, paramNames, callArgs, callerTaintedVars, receiverPath) {
202
+ if (!summary) return { returnTainted: false, mutated: new Set(), mutatedThisPaths: new Set() };
177
203
  const mutated = new Set();
178
204
  if (summary.mutatedParams && summary.mutatedParams.size) {
179
205
  // Map each mutated parameter position back to the caller-side argument name.
@@ -184,7 +210,11 @@ export class SummaryCache {
184
210
  if (arg && arg.kind === 'ident') mutated.add(arg.name);
185
211
  }
186
212
  }
187
- return { returnTainted: !!summary.returnTainted, mutated };
213
+ const mutatedThisPaths = new Set();
214
+ if (receiverPath && summary.mutatedThisFields && summary.mutatedThisFields.size) {
215
+ for (const field of summary.mutatedThisFields) mutatedThisPaths.add(`${receiverPath}.${field}`);
216
+ }
217
+ return { returnTainted: !!summary.returnTainted, mutated, mutatedThisPaths };
188
218
  }
189
219
 
190
220
  size() { return this._cache.size; }
@@ -208,6 +238,16 @@ function _summaryEq(a, b) {
208
238
  const bm = b.mutatedParams || new Set();
209
239
  if (am.size !== bm.size) return false;
210
240
  for (const p of am) if (!bm.has(p)) return false;
241
+ // Next-gen taint capability #3's `mutatedThisFields` is a THIRD summary
242
+ // field alongside `returnTainted`/`mutatedParams` — deliberately compared
243
+ // here too, not left out, per this function's own header comment above:
244
+ // omitting a field from this equality check is exactly how a fresher,
245
+ // more-refined summary can be silently discarded by the fixed-point loop
246
+ // while still being returned to its immediate caller.
247
+ const atf = a.mutatedThisFields || new Set();
248
+ const btf = b.mutatedThisFields || new Set();
249
+ if (atf.size !== btf.size) return false;
250
+ for (const f of atf) if (!btf.has(f)) return false;
211
251
  return true;
212
252
  }
213
253
 
@@ -244,10 +284,31 @@ export function entryStateFromCall(paramNames, callArgs, callerTaintedVars, isAr
244
284
  // ASSIGNED locals, not raw globals) took the member branch, found
245
285
  // nothing, and — being an else-if — never reached isArgTaintedExpr at
246
286
  // all. Confirmed via a real corpus fixture.
287
+ // PRD W2 (SARD_80_F1_EXECUTION_PRD.md) — an object argument whose FIELD
288
+ // was tainted before the call (`c.field = tainted; step(c);`) is a
289
+ // documented scope gap: entry-state granularity here is param-level,
290
+ // not per-access-path, so `c` itself was never marked tainted even
291
+ // though `c.field` genuinely is in the caller's field-sensitive lattice
292
+ // — the callee then reads `c.field` back out with no provenance at all.
293
+ // Found sampling real Juliet variant 67 false negatives (a tainted
294
+ // value stored into a `Container` object's field, the object passed
295
+ // whole to another function, the field read back out there). Widens
296
+ // the SAME direction this codebase already takes for "unknown-key
297
+ // container writes" and collection taint (dataflow/CLAUDE.md's own
298
+ // documented precedent): if ANY access path prefixed by this
299
+ // identifier is tainted, treat the whole object as tainted when it
300
+ // crosses a call boundary — over-approximating (a clean sibling field
301
+ // read downstream may falsely inherit taint) is the accepted direction
302
+ // for a security scanner, the same tradeoff every other "widen to the
303
+ // container" rule in this codebase already makes.
247
304
  let tainted = false;
248
305
  if (arg.kind === 'ident' && callerTaintedVars.has(arg.name)) {
249
306
  tainted = true;
250
- } else if (arg.kind === 'member' && arg.object?.kind === 'ident') {
307
+ } else if (arg.kind === 'ident') {
308
+ const prefix = `${arg.name}.`;
309
+ for (const v of callerTaintedVars) { if (v.startsWith(prefix)) { tainted = true; break; } }
310
+ }
311
+ if (!tainted && arg.kind === 'member' && arg.object?.kind === 'ident') {
251
312
  const base = arg.object.name;
252
313
  if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`)) tainted = true;
253
314
  }