circle-ir 4.15.0 → 4.16.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 (135) hide show
  1. package/configs/sinks/golang.json +4 -4
  2. package/dist/analysis/config-loader.d.ts.map +1 -1
  3. package/dist/analysis/config-loader.js +11 -7
  4. package/dist/analysis/config-loader.js.map +1 -1
  5. package/dist/analysis/constant-caller-gate.d.ts +39 -0
  6. package/dist/analysis/constant-caller-gate.d.ts.map +1 -0
  7. package/dist/analysis/constant-caller-gate.js +298 -0
  8. package/dist/analysis/constant-caller-gate.js.map +1 -0
  9. package/dist/analysis/constant-propagation/evaluator.d.ts.map +1 -1
  10. package/dist/analysis/constant-propagation/evaluator.js +3 -1
  11. package/dist/analysis/constant-propagation/evaluator.js.map +1 -1
  12. package/dist/analysis/constant-propagation/patterns.d.ts +19 -0
  13. package/dist/analysis/constant-propagation/patterns.d.ts.map +1 -1
  14. package/dist/analysis/constant-propagation/patterns.js +33 -0
  15. package/dist/analysis/constant-propagation/patterns.js.map +1 -1
  16. package/dist/analysis/constant-propagation/propagator.d.ts +59 -0
  17. package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
  18. package/dist/analysis/constant-propagation/propagator.js +396 -49
  19. package/dist/analysis/constant-propagation/propagator.js.map +1 -1
  20. package/dist/analysis/constant-propagation/types.d.ts +9 -0
  21. package/dist/analysis/constant-propagation/types.d.ts.map +1 -1
  22. package/dist/analysis/findings.d.ts.map +1 -1
  23. package/dist/analysis/findings.js +134 -0
  24. package/dist/analysis/findings.js.map +1 -1
  25. package/dist/analysis/interprocedural.d.ts.map +1 -1
  26. package/dist/analysis/interprocedural.js +98 -3
  27. package/dist/analysis/interprocedural.js.map +1 -1
  28. package/dist/analysis/java-array-element-keys.d.ts +14 -0
  29. package/dist/analysis/java-array-element-keys.d.ts.map +1 -0
  30. package/dist/analysis/java-array-element-keys.js +36 -0
  31. package/dist/analysis/java-array-element-keys.js.map +1 -0
  32. package/dist/analysis/js-crlf-strip.d.ts +26 -0
  33. package/dist/analysis/js-crlf-strip.d.ts.map +1 -0
  34. package/dist/analysis/js-crlf-strip.js +244 -0
  35. package/dist/analysis/js-crlf-strip.js.map +1 -0
  36. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +12 -0
  37. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  38. package/dist/analysis/passes/csharp-sanitizer-credit.js +109 -12
  39. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
  40. package/dist/analysis/passes/language-sources-pass.d.ts +38 -3
  41. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  42. package/dist/analysis/passes/language-sources-pass.js +1176 -119
  43. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  44. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
  45. package/dist/analysis/passes/param-source-flow-gate-pass.js +53 -1
  46. package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
  47. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts +6 -0
  48. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts.map +1 -1
  49. package/dist/analysis/passes/plaintext-password-storage-pass.js +160 -0
  50. package/dist/analysis/passes/plaintext-password-storage-pass.js.map +1 -1
  51. package/dist/analysis/passes/scan-secrets-pass.d.ts +1 -1
  52. package/dist/analysis/passes/scan-secrets-pass.d.ts.map +1 -1
  53. package/dist/analysis/passes/scan-secrets-pass.js +7 -1
  54. package/dist/analysis/passes/scan-secrets-pass.js.map +1 -1
  55. package/dist/analysis/passes/sink-filter-pass.d.ts +1 -1
  56. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  57. package/dist/analysis/passes/sink-filter-pass.js +307 -31
  58. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  59. package/dist/analysis/passes/taint-matcher-pass.d.ts.map +1 -1
  60. package/dist/analysis/passes/taint-matcher-pass.js +2 -2
  61. package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
  62. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  63. package/dist/analysis/passes/taint-propagation-pass.js +1349 -58
  64. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  65. package/dist/analysis/passes/weak-hash-pass.d.ts +6 -0
  66. package/dist/analysis/passes/weak-hash-pass.d.ts.map +1 -1
  67. package/dist/analysis/passes/weak-hash-pass.js +191 -4
  68. package/dist/analysis/passes/weak-hash-pass.js.map +1 -1
  69. package/dist/analysis/passes/weak-random-pass.d.ts +5 -0
  70. package/dist/analysis/passes/weak-random-pass.d.ts.map +1 -1
  71. package/dist/analysis/passes/weak-random-pass.js +30 -0
  72. package/dist/analysis/passes/weak-random-pass.js.map +1 -1
  73. package/dist/analysis/project-callee-escape-gate.d.ts +40 -0
  74. package/dist/analysis/project-callee-escape-gate.d.ts.map +1 -0
  75. package/dist/analysis/project-callee-escape-gate.js +243 -0
  76. package/dist/analysis/project-callee-escape-gate.js.map +1 -0
  77. package/dist/analysis/rust-literal-guard-rebind.d.ts +34 -0
  78. package/dist/analysis/rust-literal-guard-rebind.d.ts.map +1 -0
  79. package/dist/analysis/rust-literal-guard-rebind.js +197 -0
  80. package/dist/analysis/rust-literal-guard-rebind.js.map +1 -0
  81. package/dist/analysis/rust-literal-guard.d.ts +33 -0
  82. package/dist/analysis/rust-literal-guard.d.ts.map +1 -0
  83. package/dist/analysis/rust-literal-guard.js +422 -0
  84. package/dist/analysis/rust-literal-guard.js.map +1 -0
  85. package/dist/analysis/scalar-derivation.d.ts +8 -0
  86. package/dist/analysis/scalar-derivation.d.ts.map +1 -1
  87. package/dist/analysis/scalar-derivation.js +172 -0
  88. package/dist/analysis/scalar-derivation.js.map +1 -1
  89. package/dist/analysis/taint-matcher.d.ts +2 -2
  90. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  91. package/dist/analysis/taint-matcher.js +531 -31
  92. package/dist/analysis/taint-matcher.js.map +1 -1
  93. package/dist/analysis/taint-propagation.d.ts +26 -1
  94. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  95. package/dist/analysis/taint-propagation.js +234 -13
  96. package/dist/analysis/taint-propagation.js.map +1 -1
  97. package/dist/analyzer.d.ts +16 -2
  98. package/dist/analyzer.d.ts.map +1 -1
  99. package/dist/analyzer.js +13 -3
  100. package/dist/analyzer.js.map +1 -1
  101. package/dist/browser/circle-ir.js +24319 -19934
  102. package/dist/core/circle-ir-core.cjs +4936 -3575
  103. package/dist/core/circle-ir-core.js +4936 -3575
  104. package/dist/core/extractors/calls.d.ts +12 -1
  105. package/dist/core/extractors/calls.d.ts.map +1 -1
  106. package/dist/core/extractors/calls.js +332 -22
  107. package/dist/core/extractors/calls.js.map +1 -1
  108. package/dist/core/extractors/dfg.js +102 -10
  109. package/dist/core/extractors/dfg.js.map +1 -1
  110. package/dist/core/extractors/types.d.ts +5 -1
  111. package/dist/core/extractors/types.d.ts.map +1 -1
  112. package/dist/core/extractors/types.js +62 -0
  113. package/dist/core/extractors/types.js.map +1 -1
  114. package/dist/graph/analysis-pass.d.ts +6 -1
  115. package/dist/graph/analysis-pass.d.ts.map +1 -1
  116. package/dist/graph/analysis-pass.js +2 -1
  117. package/dist/graph/analysis-pass.js.map +1 -1
  118. package/dist/navigation/navigation-index.d.ts +134 -14
  119. package/dist/navigation/navigation-index.d.ts.map +1 -1
  120. package/dist/navigation/navigation-index.js +1158 -104
  121. package/dist/navigation/navigation-index.js.map +1 -1
  122. package/dist/navigation/types.d.ts +59 -2
  123. package/dist/navigation/types.d.ts.map +1 -1
  124. package/dist/resolution/cross-file.d.ts +9 -0
  125. package/dist/resolution/cross-file.d.ts.map +1 -1
  126. package/dist/resolution/cross-file.js +102 -43
  127. package/dist/resolution/cross-file.js.map +1 -1
  128. package/dist/resolution/type-hierarchy.d.ts +3 -0
  129. package/dist/resolution/type-hierarchy.d.ts.map +1 -1
  130. package/dist/resolution/type-hierarchy.js +45 -1
  131. package/dist/resolution/type-hierarchy.js.map +1 -1
  132. package/dist/types/index.d.ts +33 -0
  133. package/dist/types/index.d.ts.map +1 -1
  134. package/dist/types/index.js.map +1 -1
  135. package/package.json +1 -1
@@ -11,12 +11,14 @@
11
11
  * Depends on: sink-filter, constant-propagation
12
12
  */
13
13
  import { findJavaSpringReturnXSSSinks } from './language-sources-pass.js';
14
- import { identifierOutsideSanitizerCalls, propagateTaint } from '../taint-propagation.js';
14
+ import { expressionFullyWrappedBySanitizer, identifierOutsideSanitizerCalls, identifierOutsideSanitizerCallsInText, propagateTaint } from '../taint-propagation.js';
15
15
  import { isFalsePositive, isCorrelatedPredicateFP } from '../constant-propagation.js';
16
- import { buildJavaTaintedVars, buildPythonTaintedVars, buildRustTaintedVars, findPythonTrustBoundaryViolations, findPythonReturnXSSSinks } from './language-sources-pass.js';
16
+ import { buildJavaTaintedVars, buildPythonTaintedVars, buildRustTaintedVars, findPythonTrustBoundaryViolations, findPythonReturnXSSSinks, findJavaWikiPluginReturnXSSSinks } from './language-sources-pass.js';
17
+ import { parseJavaArrayAccessExpression } from '../java-array-element-keys.js';
17
18
  import { canSourceReachSink, sourceSemanticsAllowed } from '../findings.js';
18
19
  import { walkBackwardDefs } from '../dfg-walk.js';
19
20
  import { sanitizerCoversSink } from '../sanitizer-index.js';
21
+ import { HTML_CONTEXT_ENCODER_METHODS } from '../constant-propagation/patterns.js';
20
22
  import { javaCanonicalStartsWithAllBare } from '../java-canonical-prefix.js';
21
23
  /**
22
24
  * cognium-dev#328 — the const-prop per-step veto, with two scoped exemptions.
@@ -42,6 +44,94 @@ import { javaCanonicalStartsWithAllBare } from '../java-canonical-prefix.js';
42
44
  * code, resolved constant) are unaffected.
43
45
  */
44
46
  const CONTAINER_READ_SOURCES = new Set(['plugin_param', 'config_param']);
47
+ /** Method name enclosing `line`, from IR type ranges; null when unknown. */
48
+ function methodAtLine(graph, line) {
49
+ for (const t of graph.ir.types ?? []) {
50
+ for (const m of t.methods) {
51
+ if (line >= m.start_line && line <= m.end_line)
52
+ return m.name;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ /** #589: method-call sanitizer that is a context-specific HTML / markup encoder. */
58
+ function isHtmlContextEncoderSanitizer(san) {
59
+ if (san.type === 'derived_html_encoder')
60
+ return true; // same-file wrapper of an HTML encoder (#589)
61
+ if (san.type !== 'method_call' || !san.method)
62
+ return false;
63
+ const tail = san.method.replace(/\(\)$/, '').split('.').pop() ?? '';
64
+ return HTML_CONTEXT_ENCODER_METHODS.has(tail);
65
+ }
66
+ /**
67
+ * #589: true when some def-use chain from `startDefId` back to the flow's
68
+ * source reaches it without passing through a def that a covering sanitizer
69
+ * cleans. A def is covered when a sanitizer for `sinkType` sits on its line
70
+ * (at or after the source line) and none of its predecessor variables
71
+ * appears on that line outside the sanitizer call. Every def on the walk is
72
+ * tested, so a join one or more assignments before the sink is still seen.
73
+ * Unknown defs, chains that reach the source line raw, chains that end in a
74
+ * def with no predecessor, and an exhausted budget all count as uncovered.
75
+ */
76
+ function hasUncoveredChain(startDefId, sinkType, sourceLine, graph, sanitizersByLine, codeLines) {
77
+ const visited = new Set();
78
+ let budget = 256;
79
+ const covered = (defId, line) => {
80
+ if (line < sourceLine)
81
+ return false;
82
+ const sans = (sanitizersByLine.get(line) ?? []).filter(s => sanitizerCoversSink(s, sinkType));
83
+ if (sans.length === 0)
84
+ return false;
85
+ const preds = graph.chainsByToDef.get(defId) ?? [];
86
+ if (preds.length === 0)
87
+ return true;
88
+ const def = graph.defById.get(defId);
89
+ const text = codeLines?.[line - 1];
90
+ let rhs = text;
91
+ if (text && def) {
92
+ const v = def.variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ const m = new RegExp(`(?:^|[^\\w$.])${v}\\s*=(?!=)`).exec(text);
94
+ if (m)
95
+ rhs = text.slice(m.index + m[0].length);
96
+ }
97
+ for (const chain of preds) {
98
+ const predVar = graph.defById.get(chain.from_def)?.variable ?? chain.via;
99
+ const outside = rhs ? identifierOutsideSanitizerCallsInText(predVar, rhs, sans, sinkType) : null;
100
+ // No sanitizer call found in the text: fall back to the single-chain
101
+ // credit (one predecessor that the sanitizer line consumes).
102
+ if (outside === null) {
103
+ if (preds.length > 1)
104
+ return false;
105
+ continue;
106
+ }
107
+ if (outside)
108
+ return false;
109
+ }
110
+ return true;
111
+ };
112
+ const stack = [startDefId];
113
+ while (stack.length > 0) {
114
+ if (--budget < 0)
115
+ return true;
116
+ const id = stack.pop();
117
+ if (visited.has(id))
118
+ continue;
119
+ visited.add(id);
120
+ const def = graph.defById.get(id);
121
+ if (!def)
122
+ return true;
123
+ if (covered(id, def.line))
124
+ continue;
125
+ if (def.line <= sourceLine)
126
+ return true;
127
+ const preds = graph.chainsByToDef.get(id) ?? [];
128
+ if (preds.length === 0)
129
+ return true;
130
+ for (const chain of preds)
131
+ stack.push(chain.from_def);
132
+ }
133
+ return false;
134
+ }
45
135
  /**
46
136
  * #515 — find the real source a Python derived variable actually references.
47
137
  * Reads the derivation's right-hand side at its origin line (f-string braces
@@ -213,7 +303,93 @@ function resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines
213
303
  }
214
304
  return best;
215
305
  }
216
- function makeConstPropVeto(constProp, graph, sources, code, language) {
306
+ /** Drop a trailing `//…` comment, leaving `//` inside string literals alone. */
307
+ function stripJavaTrailingComment(text) {
308
+ let i = 0;
309
+ while (i < text.length) {
310
+ const ch = text[i];
311
+ if (ch === '"') {
312
+ i++;
313
+ while (i < text.length && text[i] !== '"') {
314
+ if (text[i] === '\\') {
315
+ i += 2;
316
+ continue;
317
+ }
318
+ i++;
319
+ }
320
+ if (i < text.length)
321
+ i++;
322
+ continue;
323
+ }
324
+ if (ch === '/' && text[i + 1] === '/')
325
+ return text.slice(0, i);
326
+ i++;
327
+ }
328
+ return text;
329
+ }
330
+ /**
331
+ * #515 (Java/C#) — like `resolvePythonDerivedAnchor`, pick the real source
332
+ * named on the derivation's right-hand side (following derived chains).
333
+ */
334
+ function resolveJavaDerivedAnchor(varName, derived, realSourceByVar, codeLines, visiting) {
335
+ if (visiting.has(varName))
336
+ return undefined;
337
+ visiting.add(varName);
338
+ const originLine = derived.get(varName);
339
+ if (originLine === undefined)
340
+ return undefined;
341
+ const text = codeLines[originLine - 1];
342
+ if (!text)
343
+ return undefined;
344
+ const eq = text.search(/(?<![=!<>+\-*/%&|^:])=(?!=)/);
345
+ if (eq < 0)
346
+ return undefined;
347
+ const rhs = maskStringLiteralsForTokenScan(stripJavaTrailingComment(text.slice(eq + 1)));
348
+ let best;
349
+ for (const m of rhs.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
350
+ const id = m[0];
351
+ if (id === varName)
352
+ continue;
353
+ const real = realSourceByVar.get(id);
354
+ const derivedLine = derived.get(id);
355
+ let cand;
356
+ if (derivedLine !== undefined && (!real || derivedLine > real.line)) {
357
+ cand = resolveJavaDerivedAnchor(id, derived, realSourceByVar, codeLines, visiting);
358
+ }
359
+ if (!cand)
360
+ cand = real;
361
+ if (cand && (!best || cand.line < best.line))
362
+ best = cand;
363
+ }
364
+ return best;
365
+ }
366
+ /**
367
+ * cognium-dev#624 — expression-scan flows through `lhs = seed.receiverCall()`
368
+ * (e.g. `label = page.getName()`) are vetoed as `variable_not_tainted` because
369
+ * const-prop never seeds those derived names. Argument-style derivations
370
+ * (`shown = encodeForPage(nick)`) must keep the veto so sanitizer credit holds.
371
+ */
372
+ function javaReceiverChainDerivedVar(code, localVar, seedVar) {
373
+ if (!seedVar || localVar === seedVar)
374
+ return false;
375
+ const esc = seedVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
376
+ const receiverOnRhs = new RegExp(`(?:^|[^\\w.$])${esc}\\s*\\.`, 'u');
377
+ const declRe = /^\s*(?:public|private|protected|static|final|volatile|transient|\s)*\s*(?:[A-Za-z_][\w.]*(?:\s*<[^>]*>)?(?:\s*\[\s*\])*)\s+([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
378
+ const assignRe = /^\s*([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
379
+ for (const line of code.split('\n')) {
380
+ const trimmed = line.trimStart();
381
+ if (trimmed.startsWith('//') || trimmed.startsWith('*'))
382
+ continue;
383
+ const m = declRe.exec(line) ?? assignRe.exec(line);
384
+ if (!m || m[1] !== localVar)
385
+ continue;
386
+ const rhs = stripJavaTrailingComment(m[2]);
387
+ if (receiverOnRhs.test(rhs))
388
+ return true;
389
+ }
390
+ return false;
391
+ }
392
+ function makeConstPropVeto(constProp, graph, sources, code, language, sanitizers) {
217
393
  const lines = typeof code === 'string' ? code.split('\n') : [];
218
394
  const selfReassign = new Map();
219
395
  const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -245,12 +421,40 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
245
421
  const use = (graph.usesByLine.get(line) ?? []).find(u => u.variable === v && u.def_id != null);
246
422
  return use ? graph.defById.get(use.def_id) : undefined;
247
423
  };
248
- return (step, sourceLine, sinkLine) => {
249
- const r = isFalsePositive(constProp, step.line, step.variable);
424
+ return (step, sourceLine, sinkLine, sinkType) => {
425
+ const method = methodAtLine(graph, step.line);
426
+ const scopedVar = method ? `${method}:${step.variable}` : step.variable;
427
+ // cognium-dev#751: Go locals are recorded method-scoped only (no unscoped
428
+ // alias) by handleGoAssignment, so Go looks up the scoped name. Other
429
+ // languages keep the unscoped lookup (#614).
430
+ const r = isFalsePositive(constProp, step.line, language === 'go' ? scopedVar : step.variable);
250
431
  if (!r.isFalsePositive)
251
432
  return false;
252
433
  if (r.reason !== 'variable_not_tainted')
253
434
  return true;
435
+ // #589: the step variable came from a context-specific encoder
436
+ // (`escapeHtml`, `encodeForHTML`, …), which does not clean `trust_boundary`
437
+ // (a `session.setAttribute` sink). It is "not tainted" only for the other
438
+ // sink types, so this flow is not vetoed.
439
+ // Uncovered entries are scoped (`method:var`). A bare `step.variable` looks
440
+ // up only that step's own method — never any sibling `*:name`.
441
+ if (sinkType !== undefined && constProp.sanitizedVarUncovered) {
442
+ const unc = constProp.sanitizedVarUncovered;
443
+ if (step.variable.includes(':')) {
444
+ if (unc.get(step.variable)?.has(sinkType))
445
+ return false;
446
+ }
447
+ else {
448
+ const method = methodAtLine(graph, step.line);
449
+ if (method) {
450
+ if (unc.get(`${method}:${step.variable}`)?.has(sinkType))
451
+ return false;
452
+ }
453
+ else if (unc.get(step.variable)?.has(sinkType)) {
454
+ return false;
455
+ }
456
+ }
457
+ }
254
458
  // cognium-dev#579: const-prop has no C# source model, so its tainted set is
255
459
  // empty for C# and "not tainted" there means "never looked", not "clean".
256
460
  // It still records a plain reassignment (`s = q;`) as a tracked symbol,
@@ -258,6 +462,43 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
258
462
  // code and resolved constants (the reasons above) still apply.
259
463
  if (language === 'csharp')
260
464
  return false;
465
+ // cognium-dev#582: in JS/TS a use after an `if` / `else` / `switch` has the
466
+ // outer def and the block's def both reaching it (the DFG lists one use per
467
+ // reaching def). const-prop has no model of the block's assignment, so its
468
+ // "untainted" is only the outer def's value; when any reaching def is the
469
+ // source's own assignment the verdict is "never looked", not "clean".
470
+ // Keep the veto when that source assignment itself passes through a
471
+ // sanitizer (`n = escapeHtml(req.query.n)` in a branch): skipping would
472
+ // report xss while main and the straight-line form stay clean.
473
+ if ((language === 'javascript' || language === 'typescript')) {
474
+ const reaching = (graph.usesByLine.get(step.line) ?? [])
475
+ .filter(u => u.variable === step.variable && u.def_id != null)
476
+ .map(u => graph.defById.get(u.def_id))
477
+ .filter((d) => !!d);
478
+ const sourceReaches = reaching.filter(d => d.line === sourceLine &&
479
+ sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)));
480
+ if (reaching.length > 1 && sourceReaches.length > 0) {
481
+ const sansAtSource = (sanitizers ?? []).filter(s => s.line === sourceLine);
482
+ const srcText = lines[sourceLine - 1] ?? '';
483
+ const sourceSanitized = sourceReaches.some(d => {
484
+ const relevant = sinkType
485
+ ? sansAtSource.filter(s => sanitizerCoversSink(s, sinkType))
486
+ : sansAtSource;
487
+ if (relevant.length === 0)
488
+ return false;
489
+ const v = escape(d.variable);
490
+ const dm = new RegExp(`(?:^|[^\\w$.])${v}\\s*=(?!=)(.*)$`).exec(srcText);
491
+ if (!dm)
492
+ return false;
493
+ const wrapType = sinkType ?? relevant[0]?.sanitizes?.[0];
494
+ if (!wrapType)
495
+ return false;
496
+ return expressionFullyWrappedBySanitizer(dm[1], relevant, wrapType);
497
+ });
498
+ if (!sourceSanitized)
499
+ return false;
500
+ }
501
+ }
261
502
  const lo = Math.min(sourceLine, sinkLine);
262
503
  // Walk the reaching-def chain back from this step, stepping through
263
504
  // self-derived reassignments only. Reaching defs are per method, so a
@@ -279,8 +520,20 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
279
520
  // "untainted" can only mean it has no pattern for this source. Container
280
521
  // reads are excluded: const-prop tracks collection keys, and there its
281
522
  // verdict is informed (`bar = map.get("safeKey")` — 10 OWASP FP files).
282
- if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)))
523
+ if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type))) {
524
+ // cognium-dev#751: sink use after `uid := constantHelper(tainted)` must veto
525
+ // when the helper is in `constantReturningMethods`, even if the flow's
526
+ // source_line is the assignment line (rule E would otherwise keep it).
527
+ if (step.line === sinkLine && !constProp.tainted.has(scopedVar)) {
528
+ for (let ln = sourceLine; ln <= sinkLine; ln++) {
529
+ const calleeMatch = (lines[ln - 1] ?? '').match(/:=\s*(\w+)\s*\(/);
530
+ if (calleeMatch && constProp.constantReturningMethods?.has(calleeMatch[1])) {
531
+ return true;
532
+ }
533
+ }
534
+ }
283
535
  return false;
536
+ }
284
537
  // (A) the chain crossed at least one `x = f(x)` and ends inside the span.
285
538
  if (selfHops > 0 && d.line >= lo)
286
539
  return false;
@@ -296,6 +549,23 @@ export class TaintPropagationPass {
296
549
  const constProp = ctx.getResult('constant-propagation');
297
550
  const sinkFilter = ctx.getResult('sink-filter');
298
551
  const { sources, sinks, sanitizers } = sinkFilter;
552
+ // cognium-dev#624: seed receiver-chain derived locals (`label = page.getName()`)
553
+ // into const-prop's tainted set so DFG + expression-scan vetoes keep them.
554
+ // Per-source seeding + receiver filter avoids argument-style derivations
555
+ // (`shown = encodeForPage(nick)`) that must stay vetoed for sanitizer credit.
556
+ if (ctx.language === 'java' && typeof ctx.code === 'string') {
557
+ for (const s of sources) {
558
+ if (!s.variable)
559
+ continue;
560
+ for (const [v] of buildJavaTaintedVars(ctx.code, new Set([s.variable]))) {
561
+ if (v === s.variable)
562
+ continue;
563
+ if (javaReceiverChainDerivedVar(ctx.code, v, s.variable)) {
564
+ constProp.tainted.add(v);
565
+ }
566
+ }
567
+ }
568
+ }
299
569
  if (sinks.length === 0) {
300
570
  return { flows: [] };
301
571
  }
@@ -307,16 +577,16 @@ export class TaintPropagationPass {
307
577
  return { flows: [] };
308
578
  }
309
579
  // DFG-based taint propagation
310
- const propagationResult = propagateTaint(graph, sources, sinks, sanitizers);
580
+ const propagationResult = propagateTaint(graph, sources, sinks, sanitizers, undefined, ctx.code);
311
581
  // cognium-dev#328 — scoped exemption from const-prop's `variable_not_tainted`
312
582
  // veto for self-derived reassignment (`x = f(x)`, `x += y`), the #287 shape.
313
- const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language);
583
+ const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language, sanitizers);
314
584
  // Filter flows: eliminate dead-code paths and constant-propagation FPs
315
585
  const verifiedFlows = propagationResult.flows.filter(flow => {
316
586
  if (constProp.unreachableLines.has(flow.sink.line))
317
587
  return false;
318
588
  for (const step of flow.path) {
319
- if (isVetoed(step, flow.source.line, flow.sink.line))
589
+ if (isVetoed(step, flow.source.line, flow.sink.line, flow.sink.type))
320
590
  return false;
321
591
  }
322
592
  if (isCorrelatedPredicateFP(constProp, flow))
@@ -498,6 +768,42 @@ export class TaintPropagationPass {
498
768
  }
499
769
  }
500
770
  }
771
+ // Supplement: Java JSPWiki plugin return-XSS flows — cognium-dev#624. The
772
+ // sink synthesised by `LanguageSourcesPass` has no method or argument, so
773
+ // no argument matcher can reach it; link it to its source here, as for the
774
+ // Python return-XSS shape above. The helper already applies its own
775
+ // statement-level sanitizer check, so these flows skip the line-keyed
776
+ // def-chain credit below (a sanitizer elsewhere on a def line of the
777
+ // returned name does not cover it).
778
+ const wikiPluginFlowKeys = new Set();
779
+ if (ctx.language === 'java' && typeof ctx.code === 'string') {
780
+ const xssSanitizers = sanitizers
781
+ .filter(sa => sa.sanitizes.includes('xss'))
782
+ .map(sa => ({ line: sa.line, method: sa.method }));
783
+ for (const rx of findJavaWikiPluginReturnXSSSinks(ctx.code, types, graph.ir.imports, sources, xssSanitizers)) {
784
+ if (constProp.unreachableLines.has(rx.sinkLine))
785
+ continue;
786
+ const sink = sinks.find(sk => sk.line === rx.sinkLine && sk.type === 'xss');
787
+ if (!sink)
788
+ continue;
789
+ const src = sources.find(sc => sc.line === rx.sourceLine);
790
+ if (!src)
791
+ continue;
792
+ wikiPluginFlowKeys.add(`${src.line}:${rx.sinkLine}`);
793
+ pushIfNew({
794
+ source_line: src.line,
795
+ sink_line: rx.sinkLine,
796
+ source_type: src.type,
797
+ sink_type: 'xss',
798
+ path: [
799
+ { variable: src.variable ?? 'response', line: src.line, type: 'source' },
800
+ { variable: src.variable ?? 'response', line: rx.sinkLine, type: 'sink' },
801
+ ],
802
+ confidence: (src.confidence ?? 1) * (sink.confidence ?? 1),
803
+ sanitized: false,
804
+ });
805
+ }
806
+ }
501
807
  // Supplement: array element flows
502
808
  // Sprint 82 (#189) — dedup keys on (source_line, sink_line, sink_type) so
503
809
  // distinct sink-types at the same call site (e.g. `res.redirect` registered
@@ -594,19 +900,47 @@ export class TaintPropagationPass {
594
900
  // explicitly marked sanitized by a guard (e.g. regex-allowlist).
595
901
  // Applied to ALL flow generators (DFG-built and the four supplements)
596
902
  // so the suppression is uniform regardless of which path emitted the flow.
903
+ //
904
+ // #589: a variable produced by a context-specific encoder (`escapeHtml`,
905
+ // `encodeForHTML`, …) is not clean for `trust_boundary`
906
+ // (`session.setAttribute`), so that flow from the variable stays.
597
907
  const sanitizedNames = constProp.sanitizedVars;
908
+ const sanitizedUncovered = constProp.sanitizedVarUncovered;
598
909
  let finalFlows = sanitizedNames.size === 0 ? flows : flows.filter(f => {
599
910
  if (f.path.length === 0)
600
911
  return true;
601
912
  const sourceVar = f.path[0].variable;
602
913
  if (!sourceVar)
603
914
  return true;
604
- if (sanitizedNames.has(sourceVar))
605
- return false;
915
+ const coversFlow = (name) => {
916
+ return !(sanitizedUncovered?.get(name)?.has(f.sink_type));
917
+ };
918
+ // #589 review: when any `method:var` sanitizer exists, only that flow's
919
+ // own method can drop it — never a sibling's scoped name (one encoder in
920
+ // doPost must not clear xss in other()), and never the bare alias alone.
921
+ const suffix = `:${sourceVar}`;
922
+ let hasScopedSanitizer = false;
606
923
  for (const s of sanitizedNames) {
607
- if (s.endsWith(`:${sourceVar}`))
924
+ if (s !== sourceVar && s.endsWith(suffix)) {
925
+ hasScopedSanitizer = true;
926
+ break;
927
+ }
928
+ }
929
+ if (hasScopedSanitizer) {
930
+ if (sourceVar.includes(':') && sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
608
931
  return false;
932
+ const method = methodAtLine(graph, f.sink_line) ?? methodAtLine(graph, f.source_line);
933
+ if (method) {
934
+ const scoped = `${method}:${sourceVar}`;
935
+ if (sanitizedNames.has(scoped) && coversFlow(scoped))
936
+ return false;
937
+ }
938
+ // Scoped sanitizers exist for this name, but none proved to be this
939
+ // flow's method — keep the flow.
940
+ return true;
609
941
  }
942
+ if (sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
943
+ return false;
610
944
  return true;
611
945
  });
612
946
  // Sprint 24 (cognium-dev #102) — uniform line-keyed sanitizer
@@ -626,6 +960,7 @@ export class TaintPropagationPass {
626
960
  arr.push(san);
627
961
  sanitizersByLine.set(san.line, arr);
628
962
  }
963
+ const codeLines = typeof ctx.code === 'string' ? ctx.code.split('\n') : undefined;
629
964
  // True unless every def at `line` among `visited` is provably off this
630
965
  // flow: not a variable on its path, and a complete backward walk from it
631
966
  // never reaches `sourceLine`.
@@ -670,6 +1005,20 @@ export class TaintPropagationPass {
670
1005
  }
671
1006
  return true;
672
1007
  }
1008
+ // #582: Python has no branch-aware def chain, so a literal in a sibling
1009
+ // arm can hide the one def whose sanitizer the walk below would find.
1010
+ // The flow's own source def is the whole answer when its right-hand side
1011
+ // is wrapped, end to end, in a covering sanitizer.
1012
+ if (ctx.language === 'python' && codeLines && f.path.length > 0) {
1013
+ const first = f.path[0];
1014
+ const defText = first.line === f.source_line ? codeLines[f.source_line - 1] : undefined;
1015
+ const sansAtSource = sanitizersByLine.get(f.source_line);
1016
+ if (defText && first.variable && sansAtSource && /^[A-Za-z_]\w*$/.test(first.variable)) {
1017
+ const dm = new RegExp(`^\\s*${first.variable}\\s*=(?!=)(.*)$`).exec(defText);
1018
+ if (dm && expressionFullyWrappedBySanitizer(dm[1], sansAtSource, f.sink_type))
1019
+ return false;
1020
+ }
1021
+ }
673
1022
  const sansAtSink = sanitizersByLine.get(f.sink_line);
674
1023
  if (sansAtSink && sansAtSink.length > 0) {
675
1024
  for (const san of sansAtSink) {
@@ -698,6 +1047,8 @@ export class TaintPropagationPass {
698
1047
  // in taint-propagation.ts checkSanitized. Doesn't over-suppress
699
1048
  // bare `shlex.quote(host)` on unrelated non-sink lines because
700
1049
  // those calls don't create a def in the sink var's chain.
1050
+ if (f.sink_type === 'xss' && wikiPluginFlowKeys.has(`${f.source_line}:${f.sink_line}`))
1051
+ return true;
701
1052
  const usesAtSink = graph.usesByLine.get(f.sink_line);
702
1053
  if (!usesAtSink || usesAtSink.length === 0)
703
1054
  return true;
@@ -706,12 +1057,29 @@ export class TaintPropagationPass {
706
1057
  : undefined;
707
1058
  if (!sinkVar)
708
1059
  return true;
1060
+ // #589: an HTML encoder is credited only when every chain from the
1061
+ // sink back to this flow's source passes through a covering
1062
+ // sanitizer. Computed once per flow, over every reaching def of the
1063
+ // sink variable, and only when an HTML encoder is the candidate.
1064
+ let everyChainCovered;
1065
+ const htmlEncoderCoversEveryChain = () => {
1066
+ if (everyChainCovered === undefined) {
1067
+ const starts = [];
1068
+ for (const u of usesAtSink) {
1069
+ if (u.variable === sinkVar && u.def_id !== null && u.def_id !== undefined)
1070
+ starts.push(u.def_id);
1071
+ }
1072
+ everyChainCovered = starts.length > 0 && starts.every(id => !hasUncoveredChain(id, f.sink_type, f.source_line, graph, sanitizersByLine, codeLines));
1073
+ }
1074
+ return everyChainCovered;
1075
+ };
709
1076
  for (const use of usesAtSink) {
710
1077
  if (use.variable !== sinkVar)
711
1078
  continue;
712
1079
  if (use.def_id === null || use.def_id === undefined)
713
1080
  continue;
714
- const walk = walkBackwardDefs(use.def_id, graph.chainsByToDef, graph.defById, { maxHops: 32 });
1081
+ const sinkDefId = use.def_id;
1082
+ const walk = walkBackwardDefs(sinkDefId, graph.chainsByToDef, graph.defById, { maxHops: 32 });
715
1083
  for (const line of walk.lines) {
716
1084
  if (line === f.sink_line)
717
1085
  continue; // already checked above
@@ -737,9 +1105,14 @@ export class TaintPropagationPass {
737
1105
  if (line > f.source_line && !sanitizedDefOnFlow(walk.visited, line, f))
738
1106
  continue;
739
1107
  for (const san of sansAtLine) {
740
- if (sanitizerCoversSink(san, f.sink_type)) {
741
- return false;
742
- }
1108
+ if (!sanitizerCoversSink(san, f.sink_type))
1109
+ continue;
1110
+ // #589: a join anywhere between the sink and the encoder
1111
+ // (`mixed = shown + b`, then `copy = mixed`) must not let an
1112
+ // encoder on one operand drop unrestricted taint from another.
1113
+ if (isHtmlContextEncoderSanitizer(san) && !htmlEncoderCoversEveryChain())
1114
+ continue;
1115
+ return false;
743
1116
  }
744
1117
  }
745
1118
  }
@@ -894,6 +1267,28 @@ export class TaintPropagationPass {
894
1267
  });
895
1268
  }
896
1269
  }
1270
+ // cognium-dev#475: DFG may keep a flow when a local (`v`) was rewritten to
1271
+ // a literal before the sink even though the sink reads a copy (`cmd`) built
1272
+ // earlier. Walk backward from the sink through assignments into the sink
1273
+ // argument; for each upstream local, literal-kill only on lines strictly
1274
+ // before the assignment that copied it into that chain (so `v = "safe"`
1275
+ // after `new SqlCommand(... + v)` does not drop the finding). Unrelated
1276
+ // locals and conditional / re-taint shapes stay sound.
1277
+ if (ctx.language === 'csharp' && typeof ctx.code === 'string') {
1278
+ const code = ctx.code;
1279
+ finalFlows = finalFlows.filter(f => {
1280
+ const deriveLine = csharpSinkDependencyKillVars(code, f);
1281
+ const carriers = csharpFlowTaintCarriers(code, f);
1282
+ for (const [v, beforeLine] of deriveLine) {
1283
+ if (!carriers.has(v))
1284
+ continue;
1285
+ if (csharpLastUnconditionalAssignIsLiteral(code, v, f.source_line, beforeLine, constProp.unreachableLines)) {
1286
+ return false;
1287
+ }
1288
+ }
1289
+ return true;
1290
+ });
1291
+ }
897
1292
  return { flows: finalFlows };
898
1293
  }
899
1294
  }
@@ -1225,8 +1620,118 @@ function detectGoPackageGlobalFlows(code, _calls, sources, sinks, unreachableLin
1225
1620
  }
1226
1621
  return flows;
1227
1622
  }
1623
+ /**
1624
+ * #614 — is `varName` tainted in the sink's own method?
1625
+ *
1626
+ * The const-prop result carries scoped names (`method:var`) and, for backward
1627
+ * compatibility, an unscoped alias of each. The unscoped alias says only that
1628
+ * SOME method tainted a local of that name. When any scoped entry for the name
1629
+ * exists, membership is decided by the sink's own scoped name, so a same-named
1630
+ * local in a safe sibling method (the Juliet `bad()` / `good*()` layout) is not
1631
+ * treated as tainted. A name with no scoped entry (a field, a parameter) keeps
1632
+ * the unscoped match. A field use (`this.name` / bare field read) is shared by
1633
+ * every method of its class (SecuriBench Inter4); a local that merely shares a
1634
+ * field's name is not.
1635
+ */
1636
+ /**
1637
+ * True when `methodName` declares a local (or param) named `varName`. With
1638
+ * overloads, the declaration that encloses `atLine` is the one checked (the
1639
+ * first declaration of that name is only a fallback when no line is known or
1640
+ * none encloses it).
1641
+ */
1642
+ function methodHasLocalDecl(code, methodName, varName, atLine) {
1643
+ if (!code || !methodName)
1644
+ return false;
1645
+ // Find `methodName(...) [throws ...] {` then scan the body for a local decl.
1646
+ const esc = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1647
+ const v = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1648
+ const methodRe = new RegExp('(?:^|[\\s;{}])' + esc + '\\s*\\([^;{]*\\)\\s*(?:throws\\s+[^{]+)?\\{', 'gm');
1649
+ const lineAt = (offset) => {
1650
+ let n = 1;
1651
+ for (let k = 0; k < offset; k++)
1652
+ if (code.charCodeAt(k) === 10)
1653
+ n++;
1654
+ return n;
1655
+ };
1656
+ let chosen = null;
1657
+ let first = null;
1658
+ for (let m = methodRe.exec(code); m; m = methodRe.exec(code)) {
1659
+ const bodyStart = m.index + m[0].length;
1660
+ let i = bodyStart;
1661
+ let depth = 1;
1662
+ while (i < code.length && depth > 0) {
1663
+ const c = code[i++];
1664
+ if (c === '{')
1665
+ depth++;
1666
+ else if (c === '}')
1667
+ depth--;
1668
+ }
1669
+ const cand = { header: m[0], bodyStart, bodyEnd: i - 1 };
1670
+ if (!first)
1671
+ first = cand;
1672
+ if (atLine === undefined)
1673
+ break;
1674
+ if (atLine >= lineAt(m.index) && atLine <= lineAt(cand.bodyEnd)) {
1675
+ chosen = cand;
1676
+ break;
1677
+ }
1678
+ }
1679
+ const hit = chosen ?? first;
1680
+ if (!hit)
1681
+ return false;
1682
+ const body = code.slice(hit.bodyStart, hit.bodyEnd);
1683
+ // Parameter named varName counts as shadowing the field.
1684
+ if (new RegExp('[(,]\\s*(?:final\\s+)?[\\w.<>,\\[\\]\\s]+\\s+' + v + '\\s*[,)]').test(hit.header))
1685
+ return true;
1686
+ const declRe = new RegExp('(?:^|[;{}])\\s*(?:(?:final|volatile)\\s+)?(?:[A-Za-z_][\\w.<>,\\[\\]\\s]*?)\\s+' + v + '\\s*(?:=|;|,)', 'm');
1687
+ return declRe.test(body);
1688
+ }
1689
+ /**
1690
+ * True when `expression` reads this class's own field `varName`: `this.name`,
1691
+ * `self.name`, or `Enclosing.name` (static access through an enclosing type).
1692
+ * Another object's member (`item.name`, `file.name`) is not this class's field.
1693
+ */
1694
+ function isFieldUseExpression(expression, varName, enclosingTypeNames) {
1695
+ if (!expression)
1696
+ return false;
1697
+ const e = expression.trim();
1698
+ if (e === `this.${varName}` || e === `self.${varName}`)
1699
+ return true;
1700
+ const m = new RegExp('^([A-Za-z_]\\w*)\\.' + varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$').exec(e);
1701
+ return !!m && enclosingTypeNames.has(m[1]);
1702
+ }
1703
+ /** Names of the types whose range encloses `line` (outer and nested). */
1704
+ function enclosingTypeNamesAt(types, line) {
1705
+ const names = new Set();
1706
+ for (const t of types ?? []) {
1707
+ if (line >= t.start_line && line <= t.end_line)
1708
+ names.add(t.name);
1709
+ }
1710
+ return names;
1711
+ }
1712
+ function isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse) {
1713
+ if (taintedVars.has(scopedName))
1714
+ return true;
1715
+ if (!taintedVars.has(varName))
1716
+ return false;
1717
+ if (scopedName === varName)
1718
+ return true;
1719
+ // Field exception only when THIS use is a field, not when any field has the name.
1720
+ if (isFieldUse && fieldNames.has(varName))
1721
+ return true;
1722
+ const suffix = `:${varName}`;
1723
+ for (const t of taintedVars) {
1724
+ if (t.endsWith(suffix))
1725
+ return false;
1726
+ }
1727
+ return true;
1728
+ }
1228
1729
  function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLines, code, types) {
1229
1730
  const flows = [];
1731
+ const fieldNames = new Set();
1732
+ for (const t of types ?? [])
1733
+ for (const f of t.fields ?? [])
1734
+ fieldNames.add(f.name);
1230
1735
  const callsByLine = new Map();
1231
1736
  for (const call of calls) {
1232
1737
  const existing = callsByLine.get(call.location.line) ?? [];
@@ -1247,7 +1752,16 @@ function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLi
1247
1752
  if (arg.variable) {
1248
1753
  const varName = arg.variable;
1249
1754
  const scopedName = call.in_method ? `${call.in_method}:${varName}` : varName;
1250
- if (taintedVars.has(varName) || taintedVars.has(scopedName)) {
1755
+ // Bare `name` that is a field read (no local of that name in this
1756
+ // method) still counts as a field use — Inter4-style. A shadowing
1757
+ // local declaration in the method does not.
1758
+ const expr = arg.expression ?? '';
1759
+ const explicitField = isFieldUseExpression(expr, varName, enclosingTypeNamesAt(types, call.location.line));
1760
+ const bareFieldRead = fieldNames.has(varName)
1761
+ && expr === varName
1762
+ && !methodHasLocalDecl(code, call.in_method, varName, call.location.line);
1763
+ const isFieldUse = explicitField || bareFieldRead;
1764
+ if (isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse)) {
1251
1765
  // Sprint 13 #70 — pick a source in the same method scope as the
1252
1766
  // sink, not blanket sources[0]. The varName-based match is
1253
1767
  // primary; method-scope is the tiebreaker; sources[0] is the
@@ -1365,23 +1879,22 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
1365
1879
  !sink.argPositions.includes(arg.position)) {
1366
1880
  continue;
1367
1881
  }
1368
- const arrayAccessMatch = arg.expression?.match(/^(\w+)\[(\d+|[^[\]]+)\]$/);
1369
- if (arrayAccessMatch) {
1370
- const arrayName = arrayAccessMatch[1];
1371
- const indexStr = arrayAccessMatch[2];
1882
+ const arrayAccess = parseJavaArrayAccessExpression(arg.expression);
1883
+ if (arrayAccess) {
1884
+ const { base, arrayName, indexKey, displayVar } = arrayAccess;
1372
1885
  const taintedIndices = taintedArrayElements.get(arrayName);
1373
1886
  if (taintedIndices) {
1374
- const isTainted = taintedIndices.has(indexStr) || taintedIndices.has('*');
1887
+ const isTainted = taintedIndices.has(indexKey) || taintedIndices.has('*');
1375
1888
  if (isTainted) {
1376
1889
  // Sprint 13 #70 — method-scoped source picker (see detectCollectionFlows).
1377
- const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types, arrayName);
1890
+ const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types, base);
1378
1891
  if (source) {
1379
1892
  flows.push({
1380
1893
  source_line: source.line, sink_line: sink.line,
1381
1894
  source_type: source.type, sink_type: sink.type,
1382
1895
  path: [
1383
- { variable: arrayName, line: source.line, type: 'source' },
1384
- { variable: `${arrayName}[${indexStr}]`, line: sink.line, type: 'sink' },
1896
+ { variable: base, line: source.line, type: 'source' },
1897
+ { variable: displayVar, line: sink.line, type: 'sink' },
1385
1898
  ],
1386
1899
  confidence: 0.85, sanitized: false,
1387
1900
  });
@@ -1394,6 +1907,20 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
1394
1907
  }
1395
1908
  return flows;
1396
1909
  }
1910
+ /** Parameter names referenced as identifiers inside a compound sink argument (#685). */
1911
+ function compoundArgParamNames(expression, paramNames) {
1912
+ const stripped = expression.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => ' '.repeat(m.length));
1913
+ const matched = [];
1914
+ for (const raw of paramNames) {
1915
+ const name = raw.startsWith('*') ? raw.slice(1) : raw;
1916
+ if (!/^[\w$]+$/.test(name))
1917
+ continue;
1918
+ const re = new RegExp(`(?<![\\w$.])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`);
1919
+ if (re.test(stripped))
1920
+ matched.push(name);
1921
+ }
1922
+ return matched;
1923
+ }
1397
1924
  function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines, tainted, code) {
1398
1925
  const flows = [];
1399
1926
  const paramSourcesByMethod = new Map();
@@ -1432,14 +1959,17 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1432
1959
  if (!methodParamSources)
1433
1960
  continue;
1434
1961
  for (const arg of call.arguments) {
1435
- if (arg.variable) {
1436
- // Skip arguments that are not in the sink's dangerous positions.
1437
- // E.g., execSync(cmd, { cwd: path }) — only arg 0 is a command injection sink.
1438
- if (sink.argPositions && sink.argPositions.length > 0 &&
1439
- !sink.argPositions.includes(arg.position)) {
1440
- continue;
1441
- }
1442
- const paramSource = methodParamSources.get(arg.variable);
1962
+ if (sink.argPositions && sink.argPositions.length > 0 &&
1963
+ !sink.argPositions.includes(arg.position)) {
1964
+ continue;
1965
+ }
1966
+ const varNames = arg.variable
1967
+ ? [arg.variable]
1968
+ : (arg.expression
1969
+ ? compoundArgParamNames(arg.expression, methodParamSources.keys())
1970
+ : []);
1971
+ for (const varName of varNames) {
1972
+ const paramSource = methodParamSources.get(varName);
1443
1973
  if (paramSource) {
1444
1974
  // Sprint 82 (#189) — sink-type-aware dedup so a sink registered
1445
1975
  // under multiple types at the same call site (e.g. `redirect` as
@@ -1447,7 +1977,7 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1447
1977
  const exists = flows.some(f => f.source_line === paramSource.line && f.sink_line === sink.line && f.sink_type === sink.type);
1448
1978
  if (!exists) {
1449
1979
  if (typeof code === 'string' &&
1450
- isReassignedToLiteralBetween(code, arg.variable, paramSource.line, sink.line, unreachableLines)) {
1980
+ isReassignedToLiteralBetween(code, varName, paramSource.line, sink.line, unreachableLines)) {
1451
1981
  continue;
1452
1982
  }
1453
1983
  // Note: DFG-flow filter handles sanitizer-guard suppression
@@ -1456,8 +1986,8 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1456
1986
  source_line: paramSource.line, sink_line: sink.line,
1457
1987
  source_type: paramSource.type, sink_type: sink.type,
1458
1988
  path: [
1459
- { variable: arg.variable, line: paramSource.line, type: 'source' },
1460
- { variable: arg.variable, line: sink.line, type: 'sink' },
1989
+ { variable: varName, line: paramSource.line, type: 'source' },
1990
+ { variable: varName, line: sink.line, type: 'sink' },
1461
1991
  ],
1462
1992
  confidence: 0.75, sanitized: false,
1463
1993
  });
@@ -1623,7 +2153,18 @@ function csharpMethodScopedAliases(code, seeds, methods, fieldNames) {
1623
2153
  }
1624
2154
  continue;
1625
2155
  }
1626
- const d = declRe.exec(text) ?? assignRe.exec(text);
2156
+ let d = declRe.exec(text) ?? assignRe.exec(text);
2157
+ if (!d && text.includes('{')) {
2158
+ const inner = text.slice(text.indexOf('{') + 1, text.lastIndexOf('}'));
2159
+ for (const stmt of inner.split(';')) {
2160
+ const trimmedStmt = stmt.trim();
2161
+ if (!trimmedStmt)
2162
+ continue;
2163
+ d = declRe.exec(`${trimmedStmt};`) ?? assignRe.exec(`${trimmedStmt};`);
2164
+ if (d)
2165
+ break;
2166
+ }
2167
+ }
1627
2168
  if (!d)
1628
2169
  continue;
1629
2170
  const [, lhs, rhs] = d;
@@ -1658,6 +2199,211 @@ function splitLinesCached(code) {
1658
2199
  _reassignSplitLines = code.split('\n');
1659
2200
  return _reassignSplitLines;
1660
2201
  }
2202
+ const CSHARP_ID_KW = new Set([
2203
+ 'if', 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'default', 'break',
2204
+ 'return', 'new', 'var', 'string', 'int', 'bool', 'void', 'static', 'public', 'class',
2205
+ 'using', 'lock', 'try', 'catch', 'finally', 'throw', 'null', 'true', 'false',
2206
+ ]);
2207
+ function csharpStripStringLiterals(text) {
2208
+ return text
2209
+ .replace(/\$@?"(?:[^"\\]|\\.)*"/g, ' ')
2210
+ .replace(/@?"(?:[^"\\]|\\.)*"/g, ' ')
2211
+ .replace(/'(?:[^'\\]|\\.)'/g, ' ')
2212
+ .replace(/`(?:[^`\\]|\\.)*`/g, ' ');
2213
+ }
2214
+ function csharpIdentifiersInExpr(text) {
2215
+ const ids = csharpStripStringLiterals(text).match(/\b[A-Za-z_][\w]*\b/g) ?? [];
2216
+ return ids.filter(id => !CSHARP_ID_KW.has(id));
2217
+ }
2218
+ const CSHARP_ASSIGN_RE = /^\s*(?:(?:var|string|String|[\w.<>\[\]?]+\s+)?([A-Za-z_]\w*)|([A-Za-z_]\w*))\s*=\s*(.+)$/;
2219
+ /**
2220
+ * Variables that carry THIS C# flow's taint (path locals plus forward/backward
2221
+ * derivation). Pure-literal helpers (`table = "users"` next to tainted `input`)
2222
+ * are excluded so their literal assignments cannot drop the flow (#475).
2223
+ */
2224
+ function csharpFlowTaintCarriers(code, flow) {
2225
+ const carriers = new Set();
2226
+ for (const step of flow.path) {
2227
+ if (step.variable)
2228
+ carriers.add(step.variable);
2229
+ }
2230
+ if (carriers.size === 0)
2231
+ return carriers;
2232
+ const lines = splitLinesCached(code);
2233
+ const lo = Math.max(0, flow.source_line - 1);
2234
+ const hi = Math.min(lines.length, flow.sink_line);
2235
+ const stripLits = (rhs) => csharpStripStringLiterals(rhs);
2236
+ const assigns = [];
2237
+ for (let i = lo; i < hi; i++) {
2238
+ const line = lines[i] ?? '';
2239
+ if (/^\s*if\b/.test(line))
2240
+ continue;
2241
+ for (const seg of line.split(';')) {
2242
+ const t = seg.trim();
2243
+ if (!t)
2244
+ continue;
2245
+ const m = CSHARP_ASSIGN_RE.exec(t);
2246
+ if (!m)
2247
+ continue;
2248
+ const lhs = m[1] ?? m[2];
2249
+ const rhs = m[3];
2250
+ if (!lhs)
2251
+ continue;
2252
+ const rhsCode = stripLits(rhs);
2253
+ const literal = rhsCode.replace(/\s+/g, '') === '';
2254
+ const rhsIdents = rhsCode.match(/\b[A-Za-z_][\w]*\b/g) ?? [];
2255
+ assigns.push({ lhs, rhsIdents, literal });
2256
+ }
2257
+ }
2258
+ const hadNonLiteral = new Set();
2259
+ for (const a of assigns) {
2260
+ if (!a.literal)
2261
+ hadNonLiteral.add(a.lhs);
2262
+ }
2263
+ let changed = true;
2264
+ for (let guard = 0; changed && guard < assigns.length + 2; guard++) {
2265
+ changed = false;
2266
+ for (const a of assigns) {
2267
+ if (!carriers.has(a.lhs) && a.rhsIdents.some(id => carriers.has(id))) {
2268
+ carriers.add(a.lhs);
2269
+ changed = true;
2270
+ }
2271
+ if (carriers.has(a.lhs)) {
2272
+ for (const id of a.rhsIdents) {
2273
+ if (!carriers.has(id) && hadNonLiteral.has(id)) {
2274
+ carriers.add(id);
2275
+ changed = true;
2276
+ }
2277
+ }
2278
+ }
2279
+ }
2280
+ }
2281
+ return carriers;
2282
+ }
2283
+ /**
2284
+ * For each upstream local, the 1-based line of the assignment that copied it
2285
+ * into the sink dependency chain (closest to the sink). A literal reset of
2286
+ * `v` after `var q = ... + v` must not kill a flow that only reads `q` (#475).
2287
+ */
2288
+ function csharpSinkDependencyKillVars(code, flow) {
2289
+ const lines = splitLinesCached(code);
2290
+ const sinkIdx = flow.sink_line - 1;
2291
+ const deriveLine = new Map();
2292
+ if (sinkIdx < 0 || sinkIdx >= lines.length)
2293
+ return deriveLine;
2294
+ const need = new Set(csharpIdentifiersInExpr(lines[sinkIdx] ?? ''));
2295
+ for (let i = sinkIdx - 1; i >= flow.source_line - 1; i--) {
2296
+ const line = lines[i] ?? '';
2297
+ for (const seg of line.split(';')) {
2298
+ const t = seg.trim();
2299
+ if (!t)
2300
+ continue;
2301
+ const m = CSHARP_ASSIGN_RE.exec(t);
2302
+ if (!m)
2303
+ continue;
2304
+ const lhs = m[1] ?? m[2];
2305
+ if (!lhs || !need.has(lhs))
2306
+ continue;
2307
+ for (const id of csharpIdentifiersInExpr(m[3])) {
2308
+ if (!deriveLine.has(id))
2309
+ deriveLine.set(id, i + 1);
2310
+ need.add(id);
2311
+ }
2312
+ }
2313
+ }
2314
+ return deriveLine;
2315
+ }
2316
+ function csharpLastUnconditionalAssignIsLiteral(code, variable, srcLine,
2317
+ /** Scan assignments on lines strictly before this 1-based line. */
2318
+ beforeLine, unreachableLines) {
2319
+ if (!variable || !/^[A-Za-z_][\w]*$/.test(variable))
2320
+ return false;
2321
+ if (beforeLine - srcLine < 1)
2322
+ return false;
2323
+ const lines = splitLinesCached(code);
2324
+ const reLit = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|`(?:[^`\\\\]|\\\\.)*`)\\s*$');
2325
+ const rePlain = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(.+)\\s*$');
2326
+ const reCompound = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*[+\\-*/%&|^]=\\s*(.+)\\s*$');
2327
+ const reRefOut = new RegExp('\\b(?:ref|out)\\s+' + variable + '\\b');
2328
+ let last = null;
2329
+ const lo = Math.max(0, srcLine);
2330
+ const hi = Math.min(lines.length, beforeLine - 1);
2331
+ for (let i = lo; i < hi; i++) {
2332
+ if (unreachableLines?.has(i + 1))
2333
+ continue;
2334
+ const line = lines[i];
2335
+ if (!line)
2336
+ continue;
2337
+ if (/^\s*if\b/.test(line))
2338
+ continue;
2339
+ for (const seg of line.split(';')) {
2340
+ const t = seg.trim();
2341
+ if (!t)
2342
+ continue;
2343
+ if (reCompound.test(t)) {
2344
+ last = 'other';
2345
+ continue;
2346
+ }
2347
+ if (reRefOut.test(csharpStripStringLiterals(t))) {
2348
+ last = 'other';
2349
+ continue;
2350
+ }
2351
+ if (!rePlain.test(t))
2352
+ continue;
2353
+ if (csharpAssignInConditionalBlock(lines, i))
2354
+ continue;
2355
+ if (reLit.test(t)) {
2356
+ last = 'literal';
2357
+ }
2358
+ else {
2359
+ last = 'other';
2360
+ }
2361
+ }
2362
+ }
2363
+ return last === 'literal';
2364
+ }
2365
+ /** True when `header` opens a non-bare control-flow block (not a method/type body). */
2366
+ function csharpBlockHeaderIsConditional(header) {
2367
+ const h = header.trim();
2368
+ if (!h || h === '{')
2369
+ return false;
2370
+ if (/^(?:case\b.*|default\s*):$/.test(h))
2371
+ return true;
2372
+ if (/\bswitch\b/.test(h))
2373
+ return true;
2374
+ if (/^(?:\}\s*)?(?:else\s+)?if\s*\(/.test(h))
2375
+ return true;
2376
+ if (/^(?:\}\s*)?else\b/.test(h))
2377
+ return true;
2378
+ if (/\b(?:for|foreach|while|do|using|lock|try|catch|finally)\b/.test(h))
2379
+ return true;
2380
+ return false;
2381
+ }
2382
+ /**
2383
+ * True when line `idx` sits in a control-flow block. Walks outward through
2384
+ * enclosing braces so an Allman `for` / `while` header above a lone `{` line
2385
+ * is recognised (#475).
2386
+ */
2387
+ function csharpAssignInConditionalBlock(lines, idx) {
2388
+ let at = idx;
2389
+ for (let guard = 0; guard < 16; guard++) {
2390
+ const block = enclosingBrace(lines, at, 0);
2391
+ if (!block)
2392
+ return false;
2393
+ let header = block.header.trim();
2394
+ if (!header || header === '{') {
2395
+ const prev = block.headerLine > 0 ? (lines[block.headerLine - 1] ?? '').trim() : '';
2396
+ if (prev)
2397
+ header = prev;
2398
+ }
2399
+ if (csharpBlockHeaderIsConditional(header))
2400
+ return true;
2401
+ if (block.headerLine <= 0)
2402
+ return false;
2403
+ at = block.headerLine - 1;
2404
+ }
2405
+ return false;
2406
+ }
1661
2407
  function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreachableLines) {
1662
2408
  if (!variable || sinkLine - srcLine < 2)
1663
2409
  return false;
@@ -1688,17 +2434,33 @@ function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreach
1688
2434
  // The fall-through `break;` is optional. Used by cognium-dev #101
1689
2435
  // to suppress the switch→constant FP corpus.
1690
2436
  const reSwitchCase = new RegExp(`^\\s*(?:case\\b.*?|default\\s*):\\s*${variable}\\s*=\\s*${strLit}\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
2437
+ const lineHasNakedLiteralReassign = (line) => {
2438
+ if (reNaked.test(line) || reSwitchCase.test(line))
2439
+ return true;
2440
+ // cognium-dev#475: `var v = input; v = "const";` on one line — only the
2441
+ // trailing statement is a literal kill, not the whole physical line.
2442
+ for (const seg of line.split(';')) {
2443
+ const t = seg.trim();
2444
+ if (!t)
2445
+ continue;
2446
+ if (reNaked.test(t) || reSwitchCase.test(t))
2447
+ return true;
2448
+ }
2449
+ return false;
2450
+ };
1691
2451
  for (let i = lo; i < hi; i++) {
1692
2452
  const line = lines[i];
1693
2453
  if (!line)
1694
2454
  continue;
1695
2455
  if (reGuarded.test(line))
1696
2456
  return true;
1697
- if (!reNaked.test(line) && !reSwitchCase.test(line))
2457
+ if (!lineHasNakedLiteralReassign(line))
1698
2458
  continue;
1699
2459
  // A literal assigned in one case of a switch only overwrites the value
1700
2460
  // when no case gives the variable anything else. `case 'A': bar = param;`
1701
2461
  // next to `default: bar = "safe";` leaves `bar` tainted on the 'A' path.
2462
+ if (javaCatchLiteralHasTaintedTryArm(lines, i, variable, strLit, unreachableLines))
2463
+ continue;
1702
2464
  if (siblingBranchAssignsNonLiteral(lines, i, variable, strLit, unreachableLines))
1703
2465
  continue;
1704
2466
  return true;
@@ -1745,6 +2507,46 @@ function matchingClose(lines, line, col) {
1745
2507
  }
1746
2508
  return null;
1747
2509
  }
2510
+ /**
2511
+ * True when line `idx` is a literal assignment in a `catch` block and the
2512
+ * paired `try` assigns `variable` a non-literal value (cognium-dev#753).
2513
+ */
2514
+ function javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines) {
2515
+ const block = enclosingBrace(lines, idx, 0);
2516
+ if (!block || !/\bcatch\b/.test(block.header))
2517
+ return false;
2518
+ let h = block.headerLine;
2519
+ while (h >= 0 && !/\btry\b/.test(lines[h]))
2520
+ h--;
2521
+ if (h < 0)
2522
+ return false;
2523
+ const tryIdx = lines[h].search(/\btry\b/);
2524
+ let openLine = h;
2525
+ let openCol = lines[h].indexOf('{', tryIdx);
2526
+ if (openCol < 0) {
2527
+ openLine = h + 1;
2528
+ while (openLine < lines.length && lines[openLine].trim() === '')
2529
+ openLine++;
2530
+ if (openLine >= lines.length)
2531
+ return false;
2532
+ openCol = lines[openLine].indexOf('{');
2533
+ if (openCol < 0)
2534
+ return false;
2535
+ }
2536
+ const tryEnd = matchingClose(lines, openLine, openCol);
2537
+ if (!tryEnd)
2538
+ return false;
2539
+ const assignTry = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
2540
+ const literalOnly = new RegExp(`^${strLit}$`);
2541
+ for (let i = openLine; i <= tryEnd.line; i++) {
2542
+ if (unreachableLines?.has(i + 1))
2543
+ continue;
2544
+ const m = assignTry.exec(lines[i]);
2545
+ if (m && m[1] && !literalOnly.test(m[1]))
2546
+ return true;
2547
+ }
2548
+ return false;
2549
+ }
1748
2550
  /**
1749
2551
  * True when line `idx` assigns `variable` in one arm of a `switch` or one
1750
2552
  * branch of an `if`/`else` chain, and a sibling arm or branch assigns it a
@@ -1767,7 +2569,7 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1767
2569
  block = enclosingBrace(lines, block.line, block.col);
1768
2570
  }
1769
2571
  if (!block)
1770
- return false;
2572
+ return pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines);
1771
2573
  let first = block.headerLine;
1772
2574
  let last;
1773
2575
  const end = matchingClose(lines, block.line, block.col);
@@ -1827,6 +2629,9 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1827
2629
  last = next.line;
1828
2630
  }
1829
2631
  }
2632
+ else if (/\bcatch\b/.test(block.header)) {
2633
+ return javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines);
2634
+ }
1830
2635
  else {
1831
2636
  return false;
1832
2637
  }
@@ -1850,6 +2655,83 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1850
2655
  }
1851
2656
  return false;
1852
2657
  }
2658
+ const PY_ARM_OPEN = /^(?:(?:if|elif)\b.*|else\s*):\s*(?:#.*)?$/;
2659
+ /**
2660
+ * The indentation-delimited counterpart of `siblingBranchAssignsNonLiteral`.
2661
+ * The arms of one `if` / `elif` / `else` chain are the header lines at one
2662
+ * indentation plus the more deeply indented lines under each. True when line
2663
+ * `idx` sits directly in one arm of such a chain and another arm assigns
2664
+ * `variable` a value that is not a string literal (cognium-dev#582). Answers
2665
+ * false, keeping the literal as an overwrite, for anything else: a
2666
+ * straight-line assignment, a `for` / `while` / `try` / `def` body, or a header
2667
+ * that is not part of an `if` chain.
2668
+ */
2669
+ function pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines) {
2670
+ const indentOf = (t) => t.length - t.trimStart().length;
2671
+ const skippable = (t) => t.trim() === '' || t.trimStart().startsWith('#');
2672
+ const own = lines[idx];
2673
+ if (own === undefined)
2674
+ return false;
2675
+ const bodyIndent = indentOf(own);
2676
+ if (bodyIndent === 0)
2677
+ return false;
2678
+ // The header of the arm that holds the literal: the nearest line above with less indentation.
2679
+ let h = idx - 1;
2680
+ while (h >= 0 && (skippable(lines[h]) || indentOf(lines[h]) >= bodyIndent))
2681
+ h--;
2682
+ if (h < 0 || !PY_ARM_OPEN.test(lines[h].trim()))
2683
+ return false;
2684
+ const armIndent = indentOf(lines[h]);
2685
+ // The last line of the arm whose header is at `header`.
2686
+ const armEnd = (header) => {
2687
+ let lastBody = header;
2688
+ for (let e = header + 1; e < lines.length; e++) {
2689
+ if (skippable(lines[e]))
2690
+ continue;
2691
+ if (indentOf(lines[e]) <= armIndent)
2692
+ break;
2693
+ lastBody = e;
2694
+ }
2695
+ return lastBody;
2696
+ };
2697
+ // Walk up through `elif` / `else` to the opening `if`.
2698
+ let first = h;
2699
+ while (/^(?:elif\b|else\s*:)/.test(lines[first].trim())) {
2700
+ let p = first - 1;
2701
+ while (p >= 0 && (skippable(lines[p]) || indentOf(lines[p]) > armIndent))
2702
+ p--;
2703
+ if (p < 0 || indentOf(lines[p]) !== armIndent || !PY_ARM_OPEN.test(lines[p].trim()))
2704
+ return false;
2705
+ first = p;
2706
+ }
2707
+ if (!/^if\b/.test(lines[first].trim()))
2708
+ return false;
2709
+ // Walk down through the following `elif` / `else` arms.
2710
+ const ownEnd = armEnd(h);
2711
+ let last = ownEnd;
2712
+ for (let guard = 0; guard < 64; guard++) {
2713
+ let n = last + 1;
2714
+ while (n < lines.length && skippable(lines[n]))
2715
+ n++;
2716
+ if (n >= lines.length || indentOf(lines[n]) !== armIndent)
2717
+ break;
2718
+ if (!/^(?:elif\b|else\s*:)/.test(lines[n].trim()) || !PY_ARM_OPEN.test(lines[n].trim()))
2719
+ break;
2720
+ last = armEnd(n);
2721
+ }
2722
+ const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
2723
+ const literalOnly = new RegExp(`^${strLit}$`);
2724
+ for (let i = first; i <= last; i++) {
2725
+ if (i === idx || unreachableLines?.has(i + 1))
2726
+ continue;
2727
+ if (i >= h && i <= ownEnd)
2728
+ continue; // the literal's own arm is not a sibling
2729
+ const m = assign.exec(lines[i]);
2730
+ if (m && m[1] && !literalOnly.test(m[1]))
2731
+ return true;
2732
+ }
2733
+ return false;
2734
+ }
1853
2735
  /**
1854
2736
  * Languages whose plain quoted strings cannot interpolate, so blanking the
1855
2737
  * literal text before identifier tokenisation is sound (cognium-dev#353).
@@ -2057,6 +2939,8 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2057
2939
  // is every Juliet C# file.
2058
2940
  const csFnRanges = methodRanges(['csharp']);
2059
2941
  const csFnStart = (line) => enclosingStart(csFnRanges, line);
2942
+ const jsFnRanges = methodRanges(['javascript', 'typescript']);
2943
+ const jsFnStart = (line) => enclosingStart(jsFnRanges, line);
2060
2944
  // cognium-dev#548 — names declared as class fields. A field is the one kind
2061
2945
  // of variable whose value legitimately crosses from one method to another, so
2062
2946
  // the per-method gate below must not apply to it.
@@ -2500,6 +3384,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2500
3384
  const aliases = csharpMethodScopedAliases(code, seeds, csMethods, csFieldNames);
2501
3385
  const fileAnchor = sourcesWithVar.reduce((a, sv) => (sv.line < a.line ? sv : a), sourcesWithVar[0]);
2502
3386
  const have = new Set(sourcesWithVar.map(sv => `${sv.variable}@${methodStartOf(sv.line)}`));
3387
+ const seedVars = new Set(sourcesWithVar.map(s => s.variable));
3388
+ const derivedForAnchor = buildJavaTaintedVars(code, seedVars);
3389
+ const codeLinesForAnchor = code.split('\n');
2503
3390
  for (const alias of aliases) {
2504
3391
  const fn = methodStartOf(alias.line);
2505
3392
  const isField = csFieldNames.has(alias.variable);
@@ -2512,14 +3399,34 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2512
3399
  // call), which also keeps it ahead of a sink in a method declared
2513
3400
  // earlier in the file than the method holding the source.
2514
3401
  const inMethod = sourcesWithVar.filter(sv => methodStartOf(sv.line) === fn && sv.line <= alias.line);
2515
- const from = !alias.carried && inMethod.length > 0
3402
+ let from = !alias.carried && inMethod.length > 0
2516
3403
  ? inMethod.reduce((a, sv) => (sv.line < a.line ? sv : a), inMethod[0])
2517
3404
  : fileAnchor;
2518
- sourcesWithVar.push({
3405
+ // cognium-dev#515: same RHS anchor as Java — do not credit an earlier decoy read.
3406
+ if (!alias.carried && derivedForAnchor.size > 0) {
3407
+ const methodRealSourceByVar = new Map();
3408
+ for (const s of sourcesWithVar) {
3409
+ if (methodStartOf(s.line) !== fn)
3410
+ continue;
3411
+ const prev = methodRealSourceByVar.get(s.variable);
3412
+ if (!prev || s.line < prev.line)
3413
+ methodRealSourceByVar.set(s.variable, s);
3414
+ }
3415
+ const referenced = resolveJavaDerivedAnchor(alias.variable, derivedForAnchor, methodRealSourceByVar, codeLinesForAnchor, new Set());
3416
+ if (referenced)
3417
+ from = referenced;
3418
+ }
3419
+ const aliasEntry = {
2519
3420
  ...from,
2520
3421
  variable: alias.variable,
2521
3422
  ...(alias.carried || inMethod.length === 0 ? { line: alias.line } : {}),
2522
- });
3423
+ };
3424
+ // cognium-dev#327: field aliases must not keep the writing method's
3425
+ // `in_method` tag — readers in other methods need to see the taint.
3426
+ if (isField) {
3427
+ delete aliasEntry.in_method;
3428
+ }
3429
+ sourcesWithVar.push(aliasEntry);
2523
3430
  }
2524
3431
  }
2525
3432
  else if ((language === 'java' || language === 'csharp') && typeof code === 'string' && sourcesWithVar.length > 0) {
@@ -2550,29 +3457,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2550
3457
  }
2551
3458
  return null;
2552
3459
  };
3460
+ const typeAt = (line) => {
3461
+ for (const t of types ?? []) {
3462
+ if (line >= t.start_line && line <= t.end_line)
3463
+ return t;
3464
+ }
3465
+ return undefined;
3466
+ };
3467
+ const isDeclaredField = (line, name) => {
3468
+ const t = typeAt(line);
3469
+ return t?.fields?.some(f => f.name === name) ?? false;
3470
+ };
2553
3471
  const existingVars = new Set(sourcesWithVar.map(s => s.variable));
3472
+ const codeLines = code.split('\n');
3473
+ const realSourceByVar = new Map();
3474
+ for (const s of sourcesWithVar) {
3475
+ const prev = realSourceByVar.get(s.variable);
3476
+ if (!prev || s.line < prev.line)
3477
+ realSourceByVar.set(s.variable, s);
3478
+ }
2554
3479
  for (const [varName, derivedLine] of derived) {
2555
3480
  if (!varName || existingVars.has(varName))
2556
3481
  continue;
2557
3482
  const owner = methodAt(derivedLine);
3483
+ const fieldAlias = isDeclaredField(derivedLine, varName);
2558
3484
  let scopedAnchor = anchor;
2559
3485
  if (owner) {
2560
3486
  let best;
3487
+ const methodRealSourceByVar = new Map();
2561
3488
  for (const s of sourcesWithVar) {
2562
3489
  const sOwner = s.in_method ?? methodAt(s.line);
2563
3490
  if (sOwner !== owner)
2564
3491
  continue;
3492
+ const prev = methodRealSourceByVar.get(s.variable);
3493
+ if (!prev || s.line < prev.line)
3494
+ methodRealSourceByVar.set(s.variable, s);
2565
3495
  if (!best || s.line < best.line)
2566
3496
  best = s;
2567
3497
  }
2568
- if (best)
3498
+ const referenced = resolveJavaDerivedAnchor(varName, derived, methodRealSourceByVar, codeLines, new Set());
3499
+ if (referenced)
3500
+ scopedAnchor = referenced;
3501
+ else if (best)
2569
3502
  scopedAnchor = best;
2570
3503
  }
2571
- sourcesWithVar.push({
2572
- ...scopedAnchor,
2573
- variable: varName,
2574
- ...(owner ? { in_method: owner } : {}),
2575
- });
3504
+ // Keep scopedAnchor.line for in_method / preceding-source matching
3505
+ // (#614). Pass derivedLine only for literal-kill (#753), so a safe
3506
+ // sibling method's earlier sink cannot pick up this alias by line.
3507
+ // cognium-dev#327: a declared field written from a method parameter
3508
+ // must not inherit the writing method as `in_method` — same exemption
3509
+ // as request sources on fields (4.12.0), so readers in other methods
3510
+ // can see the taint.
3511
+ if (fieldAlias) {
3512
+ const { in_method: _drop, ...rest } = scopedAnchor;
3513
+ sourcesWithVar.push({
3514
+ ...rest,
3515
+ variable: varName,
3516
+ literal_kill_line: derivedLine,
3517
+ });
3518
+ }
3519
+ else {
3520
+ sourcesWithVar.push({
3521
+ ...scopedAnchor,
3522
+ variable: varName,
3523
+ literal_kill_line: derivedLine,
3524
+ ...(owner ? { in_method: owner } : {}),
3525
+ });
3526
+ }
2576
3527
  existingVars.add(varName);
2577
3528
  }
2578
3529
  }
@@ -2586,7 +3537,217 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2586
3537
  // tainted var (at the CommandText line) so the arg-scan links the Execute sink.
2587
3538
  if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0) {
2588
3539
  const lines = code.split('\n');
2589
- const ctRe = /(\w+)\s*\.\s*CommandText\s*\+?=\s*(.+)/;
3540
+ // cognium-dev#594: `X.StartInfo.FileName = <tainted>` is the same shape for a
3541
+ // `System.Diagnostics.Process`: the executable path rides the process object
3542
+ // and the zero-argument `X.Start()` is the injection point. Only the
3543
+ // `FileName` write seeds it, and an `Arguments` write when the program is a
3544
+ // shell (`ArgumentList` is a separate follow-up), so a constant `FileName`
3545
+ // beside an unrelated tainted parameter stays clean.
3546
+ // A write only: `+=` or a single `=` that is not part of `==`, `!=`, `<=`, `>=`
3547
+ // (`if (p.StartInfo.FileName == tool)` is a comparison, not an assignment).
3548
+ // `StartInfo.Arguments` seeds too, but only behind a shell (or a non-constant
3549
+ // program), see `argumentsReachShell` below.
3550
+ const ctRe = /(\w+)\s*\.\s*(?:CommandText|StartInfo\s*\.\s*(?:FileName|(Arguments)))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
3551
+ // cognium-dev#594: the same two writes on a local `ProcessStartInfo psi`
3552
+ // (`psi.FileName = …`, `psi.Arguments = …`), read back by `Process.Start(psi)`.
3553
+ // Gated on the receiver being declared as a `ProcessStartInfo`, so a `FileName`
3554
+ // or `Arguments` property of any other type seeds nothing.
3555
+ const psiVars = new Set();
3556
+ for (const line of lines) {
3557
+ for (const pm of line.matchAll(/\bProcessStartInfo\s+([\p{L}_][\p{L}\p{N}_]*)/gu))
3558
+ psiVars.add(pm[1]);
3559
+ for (const pm of line.matchAll(/\b([\p{L}_][\p{L}\p{N}_]*)\s*=\s*new\s+ProcessStartInfo\b/gu))
3560
+ psiVars.add(pm[1]);
3561
+ }
3562
+ const psiRe = /(?<![\w.])(\w+)\s*\.\s*(?:FileName|(Arguments))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
3563
+ const psiOverwrittenByLiteral = (obj, prop, at) => {
3564
+ const fn = csFnStart(at + 1);
3565
+ const re = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*${prop}\\s*=(?!=)\\s*@?"[^"]*"\\s*;\\s*(?://.*)?$`);
3566
+ for (let j = at + 1; j < lines.length; j++) {
3567
+ if (csFnStart(j + 1) !== fn)
3568
+ break;
3569
+ if (re.test(lines[j]))
3570
+ return true;
3571
+ }
3572
+ return false;
3573
+ };
3574
+ const fileNameRe = (obj) => new RegExp(`\\b${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?FileName\\s*=(?!=)\\s*(.+)`);
3575
+ const shellProgramRe = /^(?:cmd|powershell|pwsh|sh|bash|zsh|dash|ksh|csh|tcsh|fish)(?:\.exe)?$/i;
3576
+ // cognium-dev#594: with a constant, non-shell program the argument string is
3577
+ // argv (the #272 / #328 safe shape), so a tainted `Arguments` does not by itself
3578
+ // reach a shell. A constant shell program, or a program that is not a constant,
3579
+ // lets the argument text choose what runs. No `FileName` write in the method
3580
+ // (for example a `ProcessStartInfo` built elsewhere) seeds nothing.
3581
+ const argumentsReachShell = (obj, line) => {
3582
+ const re = fileNameRe(obj);
3583
+ const fnStart = csFnStart(line);
3584
+ for (let j = 0; j < lines.length; j++) {
3585
+ if (csFnStart(j + 1) !== fnStart)
3586
+ continue;
3587
+ const fm = re.exec(lines[j]);
3588
+ if (!fm)
3589
+ continue;
3590
+ const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
3591
+ if (!lit)
3592
+ return true;
3593
+ const base = lit[1].split(/[\\/]/).pop() ?? '';
3594
+ return shellProgramRe.test(base);
3595
+ }
3596
+ return false;
3597
+ };
3598
+ // cognium-dev#594: `X.StartInfo.ArgumentList.Add(<tainted>)` (or `psi.ArgumentList.Add(…)`
3599
+ // on a local `ProcessStartInfo`) is argv, so it only reaches a command when the
3600
+ // program is a shell that is told to run a command string. Seed only when the
3601
+ // method writes `FileName` exactly once, to a constant shell program, and an
3602
+ // `ArgumentList.Add` of a command flag (`-c`, `/c`, …) is in the same method.
3603
+ // A non-constant or non-shell program, a second `FileName` write, an
3604
+ // `ArgumentList.Clear()`, or an `Add(` that wraps over several lines seeds nothing.
3605
+ const argListRe = /(?<![\w.])(\w+)\s*\.\s*(StartInfo\s*\.\s*)?ArgumentList\s*\.\s*Add\s*\(\s*(.+?)\s*\)\s*;?\s*(?:\/\/.*)?$/;
3606
+ const shellFlagRe = /^(?:[-/](?:c|k|command|encodedcommand|ec))$/i;
3607
+ // cognium-dev#750: the program may also be written by the `ProcessStartInfo`
3608
+ // constructor's single argument or by a `FileName = …` entry of its object
3609
+ // initializer, on the statement that creates `obj` (`var psi = new
3610
+ // ProcessStartInfo("/bin/sh");`, `new ProcessStartInfo { FileName = "/bin/sh",
3611
+ // … }`, possibly wrapped over several lines). Each counts as one `FileName`
3612
+ // write, so the exactly-one-write rule still holds, and they apply only when the
3613
+ // method has no explicit `obj.FileName = …` write. Any shape that is not
3614
+ // plainly one constant program (two constructor arguments, an `Arguments`
3615
+ // entry, a non-literal value) counts as a non-constant write.
3616
+ const splitTopLevel = (text) => {
3617
+ const parts = [];
3618
+ let depth = 0;
3619
+ let quote = '';
3620
+ let cur = '';
3621
+ for (let k = 0; k < text.length; k++) {
3622
+ const ch = text[k];
3623
+ if (quote) {
3624
+ cur += ch;
3625
+ if (ch === '\\' && quote === '"') {
3626
+ cur += text[++k] ?? '';
3627
+ continue;
3628
+ }
3629
+ if (ch === quote)
3630
+ quote = '';
3631
+ continue;
3632
+ }
3633
+ if (ch === '"') {
3634
+ quote = ch;
3635
+ cur += ch;
3636
+ continue;
3637
+ }
3638
+ if (ch === '(' || ch === '{' || ch === '[')
3639
+ depth++;
3640
+ if (ch === ')' || ch === '}' || ch === ']')
3641
+ depth--;
3642
+ if (ch === ',' && depth === 0) {
3643
+ parts.push(cur.trim());
3644
+ cur = '';
3645
+ continue;
3646
+ }
3647
+ cur += ch;
3648
+ }
3649
+ if (cur.trim())
3650
+ parts.push(cur.trim());
3651
+ return parts;
3652
+ };
3653
+ const constLiteral = (expr) => {
3654
+ const m = /^@?"([^"]*)"$/.exec(expr.trim());
3655
+ return m ? (m[1].split(/[\\/]/).pop() ?? '') : null;
3656
+ };
3657
+ // Returns null when line j does not create `obj` or the creation writes no program.
3658
+ const startInfoCreation = (obj, j) => {
3659
+ const head = new RegExp(`(?<![\\w.])${obj}(?:\\s*\\.\\s*StartInfo)?\\s*=(?!=)\\s*new\\s+ProcessStartInfo\\b`).exec(lines[j]);
3660
+ if (!head)
3661
+ return null;
3662
+ let text = '';
3663
+ for (let k = j; k < lines.length && k < j + 40; k++) {
3664
+ text += (k === j ? lines[k].slice(head.index) : ' ' + lines[k]).replace(/\/\/.*$/, '');
3665
+ if (text.includes(';'))
3666
+ break;
3667
+ }
3668
+ const m = /new\s+ProcessStartInfo\s*(?:\(([^()]*)\))?\s*(?:\{([\s\S]*)\})?\s*;/.exec(text);
3669
+ if (!m)
3670
+ return { program: null };
3671
+ let program;
3672
+ const ctorArgs = splitTopLevel(m[1] ?? '');
3673
+ if (ctorArgs.length > 1)
3674
+ return { program: null };
3675
+ if (ctorArgs.length === 1)
3676
+ program = constLiteral(ctorArgs[0]);
3677
+ for (const member of splitTopLevel(m[2] ?? '')) {
3678
+ const em = /^(\w+)\s*=(?!=)\s*([\s\S]*)$/.exec(member);
3679
+ if (!em)
3680
+ return { program: null };
3681
+ if (em[1] === 'FileName') {
3682
+ if (program !== undefined)
3683
+ return { program: null };
3684
+ program = constLiteral(em[2]);
3685
+ }
3686
+ else if (em[1] === 'Arguments' || em[1] === 'ArgumentList') {
3687
+ return { program: null };
3688
+ }
3689
+ }
3690
+ return program === undefined ? null : { program };
3691
+ };
3692
+ const constStringFieldProgram = (fieldName) => {
3693
+ const declRe = new RegExp(`\\b(?:\\w+\\s+)*const\\s+string\\s+${fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*@?"([^"]*)"\\s*;`);
3694
+ let hits = 0;
3695
+ let base = null;
3696
+ for (const srcLine of lines) {
3697
+ const dm = declRe.exec(srcLine);
3698
+ if (!dm)
3699
+ continue;
3700
+ hits++;
3701
+ base = dm[1].split(/[\\/]/).pop() ?? '';
3702
+ }
3703
+ return hits === 1 ? base : null;
3704
+ };
3705
+ const argListReachesShell = (obj, line) => {
3706
+ const fnStart = csFnStart(line);
3707
+ const nameRe = fileNameRe(obj);
3708
+ const clearRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*(?:Clear|Remove\\w*|Insert\\w*)\\s*\\(`);
3709
+ const addRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*Add\\s*\\(\\s*@?"([^"]*)"\\s*\\)`);
3710
+ let program = null;
3711
+ let writes = 0;
3712
+ let creations = 0;
3713
+ let createdProgram = null;
3714
+ let flag = false;
3715
+ for (let j = 0; j < lines.length; j++) {
3716
+ if (csFnStart(j + 1) !== fnStart)
3717
+ continue;
3718
+ if (clearRe.test(lines[j]))
3719
+ return false;
3720
+ const fm = nameRe.exec(lines[j]);
3721
+ if (fm) {
3722
+ writes++;
3723
+ const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
3724
+ if (lit) {
3725
+ program = lit[1].split(/[\\/]/).pop() ?? '';
3726
+ }
3727
+ else {
3728
+ const id = /^\s*(\w+)\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
3729
+ program = id ? constStringFieldProgram(id[1]) : null;
3730
+ }
3731
+ }
3732
+ const created = startInfoCreation(obj, j);
3733
+ if (created) {
3734
+ creations++;
3735
+ createdProgram = created.program;
3736
+ }
3737
+ const am = addRe.exec(lines[j]);
3738
+ if (am && shellFlagRe.test(am[1]))
3739
+ flag = true;
3740
+ }
3741
+ // An explicit `obj.FileName = …` decides the program, as before. Only when the
3742
+ // method has none does the creating statement's constant program count, and
3743
+ // then it must be the only creation (a second `new ProcessStartInfo(…)` for
3744
+ // the same variable could replace it).
3745
+ if (writes === 0 && creations > 0) {
3746
+ writes = creations;
3747
+ program = createdProgram;
3748
+ }
3749
+ return writes === 1 && program !== null && shellProgramRe.test(program) && flag;
3750
+ };
2590
3751
  // cognium-dev#548: seed per method, from sources in that method. The seed
2591
3752
  // used to be made once per file and matched by name, so `cmd.CommandText =
2592
3753
  // … + data` in `Bad()` tainted `cmd.ExecuteScalar()` in `GoodG2B()`, whose
@@ -2599,15 +3760,60 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2599
3760
  added = false;
2600
3761
  guard++;
2601
3762
  for (let i = 0; i < lines.length; i++) {
2602
- const m = ctRe.exec(lines[i]);
3763
+ const am = argListRe.exec(lines[i]);
3764
+ if (am) {
3765
+ const [, aobj, viaStartInfo, aargs] = am;
3766
+ const akey = `${aobj}@${csFnStart(i + 1)}`;
3767
+ if ((viaStartInfo || psiVars.has(aobj)) && !seeded.has(akey) && argListReachesShell(aobj, i + 1)) {
3768
+ // A value set to a literal after the source and before this `Add` no longer
3769
+ // carries it (`cmd = name; cmd = "uptime"; …Add(cmd)`).
3770
+ const literalSince = (v, from) => {
3771
+ const re = new RegExp(`(?<![\\w.])${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)\\s*@?"[^"]*"\\s*;`);
3772
+ const fn = csFnStart(i + 1);
3773
+ for (let j = from; j < i; j++)
3774
+ if (csFnStart(j + 1) === fn && re.test(lines[j]))
3775
+ return true;
3776
+ return false;
3777
+ };
3778
+ const reachableA = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1) && !literalSince(sv.variable, sv.line));
3779
+ const maskedArg = aargs.replace(/\b(?:int|uint|long|ulong|short|ushort|byte|double|float|decimal|bool|Int16|Int32|Int64|Double|Decimal|Boolean|Guid)\s*\.\s*(?:Try)?Parse\s*\((?:[^()]|\([^()]*\))*\)|\bConvert\s*\.\s*To(?:Int16|Int32|Int64|Double|Decimal|Boolean|Byte)\s*\((?:[^()]|\([^()]*\))*\)/g, '0');
3780
+ if (reachableA.length > 0 && argsReferenceTaintedVar(maskedArg, reachableA)) {
3781
+ const fromA = reachableA.reduce((a, sv) => (sv.line < a.line ? sv : a), reachableA[0]);
3782
+ sourcesWithVar.push({ ...fromA, variable: aobj, line: i + 1 });
3783
+ seeded.add(akey);
3784
+ added = true;
3785
+ }
3786
+ }
3787
+ continue;
3788
+ }
3789
+ let m = ctRe.exec(lines[i]);
3790
+ let viaLocal = false;
3791
+ if (!m) {
3792
+ const pm = psiRe.exec(lines[i]);
3793
+ if (pm && psiVars.has(pm[1])) {
3794
+ m = pm;
3795
+ viaLocal = true;
3796
+ }
3797
+ }
2603
3798
  if (!m)
2604
3799
  continue;
2605
- const [, obj, rhs] = m;
3800
+ const [, obj, isArguments, rhs] = m;
3801
+ // A later plain-literal write of the same property in this method replaces
3802
+ // the tainted value before `Process.Start(psi)` reads it: do not credit it.
3803
+ if (viaLocal && psiOverwrittenByLiteral(obj, isArguments ? 'Arguments' : 'FileName', i))
3804
+ continue;
2606
3805
  const key = `${obj}@${csFnStart(i + 1)}`;
2607
3806
  if (seeded.has(key))
2608
3807
  continue;
3808
+ if (isArguments && !argumentsReachShell(obj, i + 1))
3809
+ continue;
2609
3810
  const reachable = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1));
2610
- if (reachable.length === 0 || !argsReferenceTaintedVar(rhs, reachable))
3811
+ // A value converted to a number / Guid / bool cannot carry a command; the
3812
+ // local-`psi` form masks such conversions so a raw operand beside one still counts.
3813
+ const rhsForTaint = viaLocal
3814
+ ? rhs.replace(/\b(?:int|uint|long|ulong|short|ushort|byte|double|float|decimal|bool|Int16|Int32|Int64|Double|Decimal|Boolean|Guid)\s*\.\s*(?:Try)?Parse\s*\((?:[^()]|\([^()]*\))*\)|\bConvert\s*\.\s*To(?:Int16|Int32|Int64|Double|Decimal|Boolean|Byte)\s*\((?:[^()]|\([^()]*\))*\)/g, '0')
3815
+ : rhs;
3816
+ if (reachable.length === 0 || !argsReferenceTaintedVar(rhsForTaint, reachable))
2611
3817
  continue;
2612
3818
  const from = reachable.reduce((a, sv) => (sv.line < a.line ? sv : a), reachable[0]);
2613
3819
  sourcesWithVar.push({ ...from, variable: obj, line: i + 1 });
@@ -2807,6 +4013,19 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2807
4013
  if (srcFn !== -1 && sinkFn !== -1 && srcFn !== sinkFn)
2808
4014
  continue;
2809
4015
  }
4016
+ // cognium-dev#752: GraphQL resolver args are seeded by name only; scope
4017
+ // them to the resolver (or factory) they came from so a same-named
4018
+ // local in another recorded function does not inherit the seed.
4019
+ if (jsFnRanges.length > 0 &&
4020
+ typeof source.location === 'string' &&
4021
+ source.location.startsWith('GraphQL ')) {
4022
+ const sinkFn = jsFnStart(sink.line);
4023
+ if (sinkFn !== -1) {
4024
+ const range = jsFnRanges.find(([start]) => start === sinkFn);
4025
+ if (range && (source.line < range[0] || source.line > range[1]))
4026
+ continue;
4027
+ }
4028
+ }
2810
4029
  // cognium-dev#530: a builder-seeded source must not escape its method.
2811
4030
  // The seed stands for "this StringBuilder now holds tainted text",
2812
4031
  // which is true only of the one object in the one method where the
@@ -2860,14 +4079,16 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2860
4079
  if (aliasSanitizedFor.get(source.variable)?.has(sink.type)) {
2861
4080
  break;
2862
4081
  }
2863
- // Sprint 9 #58.3 / #56: between source.line and sink.line, if
2864
- // the tainted variable is reassigned to a pure string literal
2865
- // (either naked `var = "lit"` or guarded by an allowlist check
2866
- // such as `if (!ALLOWLIST.contains(var)) var = "lit"` /
4082
+ // Sprint 9 #58.3 / #56: between the alias derivation (or source)
4083
+ // line and sink.line, if the tainted variable is reassigned to a
4084
+ // pure string literal (either naked `var = "lit"` or guarded by an
4085
+ // allowlist check such as `if (!ALLOWLIST.contains(var)) var = "lit"` /
2867
4086
  // `if var not in ALLOWLIST: var = "lit"`), the original taint
2868
- // no longer reaches the sink — suppress the flow.
4087
+ // no longer reaches the sink — suppress the flow. Derived aliases
4088
+ // carry `literal_kill_line` so kill starts at the assignment that
4089
+ // created them (#753), while `source.line` stays on the anchor (#614).
2869
4090
  if (typeof code === 'string' &&
2870
- isReassignedToLiteralBetween(code, source.variable, source.line, sink.line, unreachableLines)) {
4091
+ isReassignedToLiteralBetween(code, source.variable, source.literal_kill_line ?? source.line, sink.line, unreachableLines)) {
2871
4092
  break;
2872
4093
  }
2873
4094
  // Note: DFG-flow filter handles sanitizer-guard suppression
@@ -2890,6 +4111,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2890
4111
  }
2891
4112
  }
2892
4113
  }
4114
+ // cognium-dev#490 — same helper as `splitCSharpDepth0Statements` in language-sources-pass.
4115
+ const splitCSharpDepth0Statements = (line) => {
4116
+ if (line.includes('{'))
4117
+ return [line];
4118
+ const statements = [];
4119
+ let start = 0;
4120
+ let depth = 0;
4121
+ let inStr = null;
4122
+ let esc = false;
4123
+ for (let i = 0; i < line.length; i++) {
4124
+ const c = line[i];
4125
+ if (inStr) {
4126
+ if (esc) {
4127
+ esc = false;
4128
+ continue;
4129
+ }
4130
+ if (c === '\\') {
4131
+ esc = true;
4132
+ continue;
4133
+ }
4134
+ if (c === inStr)
4135
+ inStr = null;
4136
+ continue;
4137
+ }
4138
+ if (c === '"' || c === "'") {
4139
+ inStr = c;
4140
+ continue;
4141
+ }
4142
+ if (c === '(' || c === '[')
4143
+ depth++;
4144
+ else if (c === ')' || c === ']')
4145
+ depth = Math.max(0, depth - 1);
4146
+ else if (c === ';' && depth === 0) {
4147
+ const part = line.slice(start, i + 1).trim();
4148
+ if (part)
4149
+ statements.push(part);
4150
+ start = i + 1;
4151
+ }
4152
+ }
4153
+ const tail = line.slice(start).trim();
4154
+ if (tail)
4155
+ statements.push(tail);
4156
+ return statements.length > 1 ? statements : [line];
4157
+ };
4158
+ const boundInEarlierStatement = (code, variable, sinkMethod) => {
4159
+ if (!variable || variable === '<inline>' || !sinkMethod)
4160
+ return false;
4161
+ const stmts = splitCSharpDepth0Statements(code);
4162
+ if (stmts.length < 2)
4163
+ return false;
4164
+ const methodRe = new RegExp(`\\b${sinkMethod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
4165
+ let sinkIdx = -1;
4166
+ for (let i = 0; i < stmts.length; i++) {
4167
+ if (methodRe.test(stmts[i])) {
4168
+ sinkIdx = i;
4169
+ break;
4170
+ }
4171
+ }
4172
+ if (sinkIdx <= 0)
4173
+ return false;
4174
+ const varAssign = new RegExp(`\\b${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)`);
4175
+ for (let i = 0; i < sinkIdx; i++) {
4176
+ if (varAssign.test(stmts[i]))
4177
+ return true;
4178
+ }
4179
+ return false;
4180
+ };
2893
4181
  // cognium-dev #83: inline-source colocation.
2894
4182
  //
2895
4183
  // When a source expression is used INLINE as a sink argument — e.g.
@@ -2978,6 +4266,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2978
4266
  if (new RegExp(`\\b${sourceVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(rhs)) {
2979
4267
  continue;
2980
4268
  }
4269
+ if (boundInEarlierStatement(sinkCode, sourceVar, sink.method)) {
4270
+ continue;
4271
+ }
2981
4272
  }
2982
4273
  // Skip the degenerate `file_input` → `path_traversal` colocation
2983
4274
  // where the source and sink describe the SAME call (one being the