circle-ir 4.15.0 → 4.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +127 -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 +25 -0
  37. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  38. package/dist/analysis/passes/csharp-sanitizer-credit.js +157 -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 +1234 -127
  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 +326 -34
  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 +1376 -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 +24432 -19928
  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,112 @@ 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(rhsByLocal, localVar, seedVar) {
373
+ if (!seedVar || localVar === seedVar)
374
+ return false;
375
+ const rhsList = rhsByLocal.get(localVar);
376
+ if (!rhsList)
377
+ return false;
378
+ const esc = seedVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
379
+ const receiverOnRhs = new RegExp(`(?:^|[^\\w.$])${esc}\\s*\\.`, 'u');
380
+ return rhsList.some(rhs => receiverOnRhs.test(rhs));
381
+ }
382
+ /**
383
+ * cognium-dev#806 — the assignment RHS texts of each Java local, indexed once
384
+ * per file. `javaReceiverChainDerivedVar` used to re-run its declaration regex
385
+ * over every line for every (source, derived local) pair, which made large
386
+ * files take seconds. Same per-line matching, done once.
387
+ */
388
+ function indexJavaLocalAssignments(code) {
389
+ 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*$/;
390
+ const assignRe = /^\s*([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
391
+ const index = new Map();
392
+ for (const line of code.split('\n')) {
393
+ const trimmed = line.trimStart();
394
+ if (trimmed.startsWith('//') || trimmed.startsWith('*'))
395
+ continue;
396
+ // Cheap pre-check: neither regex can match a line without `=` and `;`.
397
+ if (!line.includes('=') || !line.includes(';'))
398
+ continue;
399
+ const m = declRe.exec(line) ?? assignRe.exec(line);
400
+ if (!m)
401
+ continue;
402
+ const list = index.get(m[1]);
403
+ const rhs = stripJavaTrailingComment(m[2]);
404
+ if (list)
405
+ list.push(rhs);
406
+ else
407
+ index.set(m[1], [rhs]);
408
+ }
409
+ return index;
410
+ }
411
+ function makeConstPropVeto(constProp, graph, sources, code, language, sanitizers) {
217
412
  const lines = typeof code === 'string' ? code.split('\n') : [];
218
413
  const selfReassign = new Map();
219
414
  const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -245,12 +440,40 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
245
440
  const use = (graph.usesByLine.get(line) ?? []).find(u => u.variable === v && u.def_id != null);
246
441
  return use ? graph.defById.get(use.def_id) : undefined;
247
442
  };
248
- return (step, sourceLine, sinkLine) => {
249
- const r = isFalsePositive(constProp, step.line, step.variable);
443
+ return (step, sourceLine, sinkLine, sinkType) => {
444
+ const method = methodAtLine(graph, step.line);
445
+ const scopedVar = method ? `${method}:${step.variable}` : step.variable;
446
+ // cognium-dev#751: Go locals are recorded method-scoped only (no unscoped
447
+ // alias) by handleGoAssignment, so Go looks up the scoped name. Other
448
+ // languages keep the unscoped lookup (#614).
449
+ const r = isFalsePositive(constProp, step.line, language === 'go' ? scopedVar : step.variable);
250
450
  if (!r.isFalsePositive)
251
451
  return false;
252
452
  if (r.reason !== 'variable_not_tainted')
253
453
  return true;
454
+ // #589: the step variable came from a context-specific encoder
455
+ // (`escapeHtml`, `encodeForHTML`, …), which does not clean `trust_boundary`
456
+ // (a `session.setAttribute` sink). It is "not tainted" only for the other
457
+ // sink types, so this flow is not vetoed.
458
+ // Uncovered entries are scoped (`method:var`). A bare `step.variable` looks
459
+ // up only that step's own method — never any sibling `*:name`.
460
+ if (sinkType !== undefined && constProp.sanitizedVarUncovered) {
461
+ const unc = constProp.sanitizedVarUncovered;
462
+ if (step.variable.includes(':')) {
463
+ if (unc.get(step.variable)?.has(sinkType))
464
+ return false;
465
+ }
466
+ else {
467
+ const method = methodAtLine(graph, step.line);
468
+ if (method) {
469
+ if (unc.get(`${method}:${step.variable}`)?.has(sinkType))
470
+ return false;
471
+ }
472
+ else if (unc.get(step.variable)?.has(sinkType)) {
473
+ return false;
474
+ }
475
+ }
476
+ }
254
477
  // cognium-dev#579: const-prop has no C# source model, so its tainted set is
255
478
  // empty for C# and "not tainted" there means "never looked", not "clean".
256
479
  // It still records a plain reassignment (`s = q;`) as a tracked symbol,
@@ -258,6 +481,43 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
258
481
  // code and resolved constants (the reasons above) still apply.
259
482
  if (language === 'csharp')
260
483
  return false;
484
+ // cognium-dev#582: in JS/TS a use after an `if` / `else` / `switch` has the
485
+ // outer def and the block's def both reaching it (the DFG lists one use per
486
+ // reaching def). const-prop has no model of the block's assignment, so its
487
+ // "untainted" is only the outer def's value; when any reaching def is the
488
+ // source's own assignment the verdict is "never looked", not "clean".
489
+ // Keep the veto when that source assignment itself passes through a
490
+ // sanitizer (`n = escapeHtml(req.query.n)` in a branch): skipping would
491
+ // report xss while main and the straight-line form stay clean.
492
+ if ((language === 'javascript' || language === 'typescript')) {
493
+ const reaching = (graph.usesByLine.get(step.line) ?? [])
494
+ .filter(u => u.variable === step.variable && u.def_id != null)
495
+ .map(u => graph.defById.get(u.def_id))
496
+ .filter((d) => !!d);
497
+ const sourceReaches = reaching.filter(d => d.line === sourceLine &&
498
+ sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)));
499
+ if (reaching.length > 1 && sourceReaches.length > 0) {
500
+ const sansAtSource = (sanitizers ?? []).filter(s => s.line === sourceLine);
501
+ const srcText = lines[sourceLine - 1] ?? '';
502
+ const sourceSanitized = sourceReaches.some(d => {
503
+ const relevant = sinkType
504
+ ? sansAtSource.filter(s => sanitizerCoversSink(s, sinkType))
505
+ : sansAtSource;
506
+ if (relevant.length === 0)
507
+ return false;
508
+ const v = escape(d.variable);
509
+ const dm = new RegExp(`(?:^|[^\\w$.])${v}\\s*=(?!=)(.*)$`).exec(srcText);
510
+ if (!dm)
511
+ return false;
512
+ const wrapType = sinkType ?? relevant[0]?.sanitizes?.[0];
513
+ if (!wrapType)
514
+ return false;
515
+ return expressionFullyWrappedBySanitizer(dm[1], relevant, wrapType);
516
+ });
517
+ if (!sourceSanitized)
518
+ return false;
519
+ }
520
+ }
261
521
  const lo = Math.min(sourceLine, sinkLine);
262
522
  // Walk the reaching-def chain back from this step, stepping through
263
523
  // self-derived reassignments only. Reaching defs are per method, so a
@@ -279,8 +539,20 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
279
539
  // "untainted" can only mean it has no pattern for this source. Container
280
540
  // reads are excluded: const-prop tracks collection keys, and there its
281
541
  // 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)))
542
+ if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type))) {
543
+ // cognium-dev#751: sink use after `uid := constantHelper(tainted)` must veto
544
+ // when the helper is in `constantReturningMethods`, even if the flow's
545
+ // source_line is the assignment line (rule E would otherwise keep it).
546
+ if (step.line === sinkLine && !constProp.tainted.has(scopedVar)) {
547
+ for (let ln = sourceLine; ln <= sinkLine; ln++) {
548
+ const calleeMatch = (lines[ln - 1] ?? '').match(/:=\s*(\w+)\s*\(/);
549
+ if (calleeMatch && constProp.constantReturningMethods?.has(calleeMatch[1])) {
550
+ return true;
551
+ }
552
+ }
553
+ }
283
554
  return false;
555
+ }
284
556
  // (A) the chain crossed at least one `x = f(x)` and ends inside the span.
285
557
  if (selfHops > 0 && d.line >= lo)
286
558
  return false;
@@ -296,6 +568,31 @@ export class TaintPropagationPass {
296
568
  const constProp = ctx.getResult('constant-propagation');
297
569
  const sinkFilter = ctx.getResult('sink-filter');
298
570
  const { sources, sinks, sanitizers } = sinkFilter;
571
+ // cognium-dev#624: seed receiver-chain derived locals (`label = page.getName()`)
572
+ // into const-prop's tainted set so DFG + expression-scan vetoes keep them.
573
+ // Per-source seeding + receiver filter avoids argument-style derivations
574
+ // (`shown = encodeForPage(nick)`) that must stay vetoed for sanitizer credit.
575
+ // cognium-dev#806: index the assignments once, visit each seed name once,
576
+ // and skip the file-wide derivation when no local not already tainted has
577
+ // a `seed.` receiver on its RHS (only such locals can be added).
578
+ if (ctx.language === 'java' && typeof ctx.code === 'string') {
579
+ const rhsByLocal = indexJavaLocalAssignments(ctx.code);
580
+ const seen = new Set();
581
+ for (const s of sources) {
582
+ if (!s.variable || seen.has(s.variable))
583
+ continue;
584
+ seen.add(s.variable);
585
+ const seedVar = s.variable;
586
+ const candidates = [...rhsByLocal.keys()].filter(v => !constProp.tainted.has(v) && javaReceiverChainDerivedVar(rhsByLocal, v, seedVar));
587
+ if (candidates.length === 0)
588
+ continue;
589
+ const derived = buildJavaTaintedVars(ctx.code, new Set([seedVar]));
590
+ for (const v of candidates) {
591
+ if (derived.has(v))
592
+ constProp.tainted.add(v);
593
+ }
594
+ }
595
+ }
299
596
  if (sinks.length === 0) {
300
597
  return { flows: [] };
301
598
  }
@@ -307,16 +604,16 @@ export class TaintPropagationPass {
307
604
  return { flows: [] };
308
605
  }
309
606
  // DFG-based taint propagation
310
- const propagationResult = propagateTaint(graph, sources, sinks, sanitizers);
607
+ const propagationResult = propagateTaint(graph, sources, sinks, sanitizers, undefined, ctx.code);
311
608
  // cognium-dev#328 — scoped exemption from const-prop's `variable_not_tainted`
312
609
  // veto for self-derived reassignment (`x = f(x)`, `x += y`), the #287 shape.
313
- const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language);
610
+ const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language, sanitizers);
314
611
  // Filter flows: eliminate dead-code paths and constant-propagation FPs
315
612
  const verifiedFlows = propagationResult.flows.filter(flow => {
316
613
  if (constProp.unreachableLines.has(flow.sink.line))
317
614
  return false;
318
615
  for (const step of flow.path) {
319
- if (isVetoed(step, flow.source.line, flow.sink.line))
616
+ if (isVetoed(step, flow.source.line, flow.sink.line, flow.sink.type))
320
617
  return false;
321
618
  }
322
619
  if (isCorrelatedPredicateFP(constProp, flow))
@@ -498,6 +795,42 @@ export class TaintPropagationPass {
498
795
  }
499
796
  }
500
797
  }
798
+ // Supplement: Java JSPWiki plugin return-XSS flows — cognium-dev#624. The
799
+ // sink synthesised by `LanguageSourcesPass` has no method or argument, so
800
+ // no argument matcher can reach it; link it to its source here, as for the
801
+ // Python return-XSS shape above. The helper already applies its own
802
+ // statement-level sanitizer check, so these flows skip the line-keyed
803
+ // def-chain credit below (a sanitizer elsewhere on a def line of the
804
+ // returned name does not cover it).
805
+ const wikiPluginFlowKeys = new Set();
806
+ if (ctx.language === 'java' && typeof ctx.code === 'string') {
807
+ const xssSanitizers = sanitizers
808
+ .filter(sa => sa.sanitizes.includes('xss'))
809
+ .map(sa => ({ line: sa.line, method: sa.method }));
810
+ for (const rx of findJavaWikiPluginReturnXSSSinks(ctx.code, types, graph.ir.imports, sources, xssSanitizers)) {
811
+ if (constProp.unreachableLines.has(rx.sinkLine))
812
+ continue;
813
+ const sink = sinks.find(sk => sk.line === rx.sinkLine && sk.type === 'xss');
814
+ if (!sink)
815
+ continue;
816
+ const src = sources.find(sc => sc.line === rx.sourceLine);
817
+ if (!src)
818
+ continue;
819
+ wikiPluginFlowKeys.add(`${src.line}:${rx.sinkLine}`);
820
+ pushIfNew({
821
+ source_line: src.line,
822
+ sink_line: rx.sinkLine,
823
+ source_type: src.type,
824
+ sink_type: 'xss',
825
+ path: [
826
+ { variable: src.variable ?? 'response', line: src.line, type: 'source' },
827
+ { variable: src.variable ?? 'response', line: rx.sinkLine, type: 'sink' },
828
+ ],
829
+ confidence: (src.confidence ?? 1) * (sink.confidence ?? 1),
830
+ sanitized: false,
831
+ });
832
+ }
833
+ }
501
834
  // Supplement: array element flows
502
835
  // Sprint 82 (#189) — dedup keys on (source_line, sink_line, sink_type) so
503
836
  // distinct sink-types at the same call site (e.g. `res.redirect` registered
@@ -594,19 +927,47 @@ export class TaintPropagationPass {
594
927
  // explicitly marked sanitized by a guard (e.g. regex-allowlist).
595
928
  // Applied to ALL flow generators (DFG-built and the four supplements)
596
929
  // so the suppression is uniform regardless of which path emitted the flow.
930
+ //
931
+ // #589: a variable produced by a context-specific encoder (`escapeHtml`,
932
+ // `encodeForHTML`, …) is not clean for `trust_boundary`
933
+ // (`session.setAttribute`), so that flow from the variable stays.
597
934
  const sanitizedNames = constProp.sanitizedVars;
935
+ const sanitizedUncovered = constProp.sanitizedVarUncovered;
598
936
  let finalFlows = sanitizedNames.size === 0 ? flows : flows.filter(f => {
599
937
  if (f.path.length === 0)
600
938
  return true;
601
939
  const sourceVar = f.path[0].variable;
602
940
  if (!sourceVar)
603
941
  return true;
604
- if (sanitizedNames.has(sourceVar))
605
- return false;
942
+ const coversFlow = (name) => {
943
+ return !(sanitizedUncovered?.get(name)?.has(f.sink_type));
944
+ };
945
+ // #589 review: when any `method:var` sanitizer exists, only that flow's
946
+ // own method can drop it — never a sibling's scoped name (one encoder in
947
+ // doPost must not clear xss in other()), and never the bare alias alone.
948
+ const suffix = `:${sourceVar}`;
949
+ let hasScopedSanitizer = false;
606
950
  for (const s of sanitizedNames) {
607
- if (s.endsWith(`:${sourceVar}`))
951
+ if (s !== sourceVar && s.endsWith(suffix)) {
952
+ hasScopedSanitizer = true;
953
+ break;
954
+ }
955
+ }
956
+ if (hasScopedSanitizer) {
957
+ if (sourceVar.includes(':') && sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
608
958
  return false;
959
+ const method = methodAtLine(graph, f.sink_line) ?? methodAtLine(graph, f.source_line);
960
+ if (method) {
961
+ const scoped = `${method}:${sourceVar}`;
962
+ if (sanitizedNames.has(scoped) && coversFlow(scoped))
963
+ return false;
964
+ }
965
+ // Scoped sanitizers exist for this name, but none proved to be this
966
+ // flow's method — keep the flow.
967
+ return true;
609
968
  }
969
+ if (sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
970
+ return false;
610
971
  return true;
611
972
  });
612
973
  // Sprint 24 (cognium-dev #102) — uniform line-keyed sanitizer
@@ -626,6 +987,7 @@ export class TaintPropagationPass {
626
987
  arr.push(san);
627
988
  sanitizersByLine.set(san.line, arr);
628
989
  }
990
+ const codeLines = typeof ctx.code === 'string' ? ctx.code.split('\n') : undefined;
629
991
  // True unless every def at `line` among `visited` is provably off this
630
992
  // flow: not a variable on its path, and a complete backward walk from it
631
993
  // never reaches `sourceLine`.
@@ -670,6 +1032,20 @@ export class TaintPropagationPass {
670
1032
  }
671
1033
  return true;
672
1034
  }
1035
+ // #582: Python has no branch-aware def chain, so a literal in a sibling
1036
+ // arm can hide the one def whose sanitizer the walk below would find.
1037
+ // The flow's own source def is the whole answer when its right-hand side
1038
+ // is wrapped, end to end, in a covering sanitizer.
1039
+ if (ctx.language === 'python' && codeLines && f.path.length > 0) {
1040
+ const first = f.path[0];
1041
+ const defText = first.line === f.source_line ? codeLines[f.source_line - 1] : undefined;
1042
+ const sansAtSource = sanitizersByLine.get(f.source_line);
1043
+ if (defText && first.variable && sansAtSource && /^[A-Za-z_]\w*$/.test(first.variable)) {
1044
+ const dm = new RegExp(`^\\s*${first.variable}\\s*=(?!=)(.*)$`).exec(defText);
1045
+ if (dm && expressionFullyWrappedBySanitizer(dm[1], sansAtSource, f.sink_type))
1046
+ return false;
1047
+ }
1048
+ }
673
1049
  const sansAtSink = sanitizersByLine.get(f.sink_line);
674
1050
  if (sansAtSink && sansAtSink.length > 0) {
675
1051
  for (const san of sansAtSink) {
@@ -698,6 +1074,8 @@ export class TaintPropagationPass {
698
1074
  // in taint-propagation.ts checkSanitized. Doesn't over-suppress
699
1075
  // bare `shlex.quote(host)` on unrelated non-sink lines because
700
1076
  // those calls don't create a def in the sink var's chain.
1077
+ if (f.sink_type === 'xss' && wikiPluginFlowKeys.has(`${f.source_line}:${f.sink_line}`))
1078
+ return true;
701
1079
  const usesAtSink = graph.usesByLine.get(f.sink_line);
702
1080
  if (!usesAtSink || usesAtSink.length === 0)
703
1081
  return true;
@@ -706,12 +1084,29 @@ export class TaintPropagationPass {
706
1084
  : undefined;
707
1085
  if (!sinkVar)
708
1086
  return true;
1087
+ // #589: an HTML encoder is credited only when every chain from the
1088
+ // sink back to this flow's source passes through a covering
1089
+ // sanitizer. Computed once per flow, over every reaching def of the
1090
+ // sink variable, and only when an HTML encoder is the candidate.
1091
+ let everyChainCovered;
1092
+ const htmlEncoderCoversEveryChain = () => {
1093
+ if (everyChainCovered === undefined) {
1094
+ const starts = [];
1095
+ for (const u of usesAtSink) {
1096
+ if (u.variable === sinkVar && u.def_id !== null && u.def_id !== undefined)
1097
+ starts.push(u.def_id);
1098
+ }
1099
+ everyChainCovered = starts.length > 0 && starts.every(id => !hasUncoveredChain(id, f.sink_type, f.source_line, graph, sanitizersByLine, codeLines));
1100
+ }
1101
+ return everyChainCovered;
1102
+ };
709
1103
  for (const use of usesAtSink) {
710
1104
  if (use.variable !== sinkVar)
711
1105
  continue;
712
1106
  if (use.def_id === null || use.def_id === undefined)
713
1107
  continue;
714
- const walk = walkBackwardDefs(use.def_id, graph.chainsByToDef, graph.defById, { maxHops: 32 });
1108
+ const sinkDefId = use.def_id;
1109
+ const walk = walkBackwardDefs(sinkDefId, graph.chainsByToDef, graph.defById, { maxHops: 32 });
715
1110
  for (const line of walk.lines) {
716
1111
  if (line === f.sink_line)
717
1112
  continue; // already checked above
@@ -737,9 +1132,14 @@ export class TaintPropagationPass {
737
1132
  if (line > f.source_line && !sanitizedDefOnFlow(walk.visited, line, f))
738
1133
  continue;
739
1134
  for (const san of sansAtLine) {
740
- if (sanitizerCoversSink(san, f.sink_type)) {
741
- return false;
742
- }
1135
+ if (!sanitizerCoversSink(san, f.sink_type))
1136
+ continue;
1137
+ // #589: a join anywhere between the sink and the encoder
1138
+ // (`mixed = shown + b`, then `copy = mixed`) must not let an
1139
+ // encoder on one operand drop unrestricted taint from another.
1140
+ if (isHtmlContextEncoderSanitizer(san) && !htmlEncoderCoversEveryChain())
1141
+ continue;
1142
+ return false;
743
1143
  }
744
1144
  }
745
1145
  }
@@ -894,6 +1294,28 @@ export class TaintPropagationPass {
894
1294
  });
895
1295
  }
896
1296
  }
1297
+ // cognium-dev#475: DFG may keep a flow when a local (`v`) was rewritten to
1298
+ // a literal before the sink even though the sink reads a copy (`cmd`) built
1299
+ // earlier. Walk backward from the sink through assignments into the sink
1300
+ // argument; for each upstream local, literal-kill only on lines strictly
1301
+ // before the assignment that copied it into that chain (so `v = "safe"`
1302
+ // after `new SqlCommand(... + v)` does not drop the finding). Unrelated
1303
+ // locals and conditional / re-taint shapes stay sound.
1304
+ if (ctx.language === 'csharp' && typeof ctx.code === 'string') {
1305
+ const code = ctx.code;
1306
+ finalFlows = finalFlows.filter(f => {
1307
+ const deriveLine = csharpSinkDependencyKillVars(code, f);
1308
+ const carriers = csharpFlowTaintCarriers(code, f);
1309
+ for (const [v, beforeLine] of deriveLine) {
1310
+ if (!carriers.has(v))
1311
+ continue;
1312
+ if (csharpLastUnconditionalAssignIsLiteral(code, v, f.source_line, beforeLine, constProp.unreachableLines)) {
1313
+ return false;
1314
+ }
1315
+ }
1316
+ return true;
1317
+ });
1318
+ }
897
1319
  return { flows: finalFlows };
898
1320
  }
899
1321
  }
@@ -1225,8 +1647,118 @@ function detectGoPackageGlobalFlows(code, _calls, sources, sinks, unreachableLin
1225
1647
  }
1226
1648
  return flows;
1227
1649
  }
1650
+ /**
1651
+ * #614 — is `varName` tainted in the sink's own method?
1652
+ *
1653
+ * The const-prop result carries scoped names (`method:var`) and, for backward
1654
+ * compatibility, an unscoped alias of each. The unscoped alias says only that
1655
+ * SOME method tainted a local of that name. When any scoped entry for the name
1656
+ * exists, membership is decided by the sink's own scoped name, so a same-named
1657
+ * local in a safe sibling method (the Juliet `bad()` / `good*()` layout) is not
1658
+ * treated as tainted. A name with no scoped entry (a field, a parameter) keeps
1659
+ * the unscoped match. A field use (`this.name` / bare field read) is shared by
1660
+ * every method of its class (SecuriBench Inter4); a local that merely shares a
1661
+ * field's name is not.
1662
+ */
1663
+ /**
1664
+ * True when `methodName` declares a local (or param) named `varName`. With
1665
+ * overloads, the declaration that encloses `atLine` is the one checked (the
1666
+ * first declaration of that name is only a fallback when no line is known or
1667
+ * none encloses it).
1668
+ */
1669
+ function methodHasLocalDecl(code, methodName, varName, atLine) {
1670
+ if (!code || !methodName)
1671
+ return false;
1672
+ // Find `methodName(...) [throws ...] {` then scan the body for a local decl.
1673
+ const esc = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1674
+ const v = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1675
+ const methodRe = new RegExp('(?:^|[\\s;{}])' + esc + '\\s*\\([^;{]*\\)\\s*(?:throws\\s+[^{]+)?\\{', 'gm');
1676
+ const lineAt = (offset) => {
1677
+ let n = 1;
1678
+ for (let k = 0; k < offset; k++)
1679
+ if (code.charCodeAt(k) === 10)
1680
+ n++;
1681
+ return n;
1682
+ };
1683
+ let chosen = null;
1684
+ let first = null;
1685
+ for (let m = methodRe.exec(code); m; m = methodRe.exec(code)) {
1686
+ const bodyStart = m.index + m[0].length;
1687
+ let i = bodyStart;
1688
+ let depth = 1;
1689
+ while (i < code.length && depth > 0) {
1690
+ const c = code[i++];
1691
+ if (c === '{')
1692
+ depth++;
1693
+ else if (c === '}')
1694
+ depth--;
1695
+ }
1696
+ const cand = { header: m[0], bodyStart, bodyEnd: i - 1 };
1697
+ if (!first)
1698
+ first = cand;
1699
+ if (atLine === undefined)
1700
+ break;
1701
+ if (atLine >= lineAt(m.index) && atLine <= lineAt(cand.bodyEnd)) {
1702
+ chosen = cand;
1703
+ break;
1704
+ }
1705
+ }
1706
+ const hit = chosen ?? first;
1707
+ if (!hit)
1708
+ return false;
1709
+ const body = code.slice(hit.bodyStart, hit.bodyEnd);
1710
+ // Parameter named varName counts as shadowing the field.
1711
+ if (new RegExp('[(,]\\s*(?:final\\s+)?[\\w.<>,\\[\\]\\s]+\\s+' + v + '\\s*[,)]').test(hit.header))
1712
+ return true;
1713
+ const declRe = new RegExp('(?:^|[;{}])\\s*(?:(?:final|volatile)\\s+)?(?:[A-Za-z_][\\w.<>,\\[\\]\\s]*?)\\s+' + v + '\\s*(?:=|;|,)', 'm');
1714
+ return declRe.test(body);
1715
+ }
1716
+ /**
1717
+ * True when `expression` reads this class's own field `varName`: `this.name`,
1718
+ * `self.name`, or `Enclosing.name` (static access through an enclosing type).
1719
+ * Another object's member (`item.name`, `file.name`) is not this class's field.
1720
+ */
1721
+ function isFieldUseExpression(expression, varName, enclosingTypeNames) {
1722
+ if (!expression)
1723
+ return false;
1724
+ const e = expression.trim();
1725
+ if (e === `this.${varName}` || e === `self.${varName}`)
1726
+ return true;
1727
+ const m = new RegExp('^([A-Za-z_]\\w*)\\.' + varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$').exec(e);
1728
+ return !!m && enclosingTypeNames.has(m[1]);
1729
+ }
1730
+ /** Names of the types whose range encloses `line` (outer and nested). */
1731
+ function enclosingTypeNamesAt(types, line) {
1732
+ const names = new Set();
1733
+ for (const t of types ?? []) {
1734
+ if (line >= t.start_line && line <= t.end_line)
1735
+ names.add(t.name);
1736
+ }
1737
+ return names;
1738
+ }
1739
+ function isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse) {
1740
+ if (taintedVars.has(scopedName))
1741
+ return true;
1742
+ if (!taintedVars.has(varName))
1743
+ return false;
1744
+ if (scopedName === varName)
1745
+ return true;
1746
+ // Field exception only when THIS use is a field, not when any field has the name.
1747
+ if (isFieldUse && fieldNames.has(varName))
1748
+ return true;
1749
+ const suffix = `:${varName}`;
1750
+ for (const t of taintedVars) {
1751
+ if (t.endsWith(suffix))
1752
+ return false;
1753
+ }
1754
+ return true;
1755
+ }
1228
1756
  function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLines, code, types) {
1229
1757
  const flows = [];
1758
+ const fieldNames = new Set();
1759
+ for (const t of types ?? [])
1760
+ for (const f of t.fields ?? [])
1761
+ fieldNames.add(f.name);
1230
1762
  const callsByLine = new Map();
1231
1763
  for (const call of calls) {
1232
1764
  const existing = callsByLine.get(call.location.line) ?? [];
@@ -1247,7 +1779,16 @@ function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLi
1247
1779
  if (arg.variable) {
1248
1780
  const varName = arg.variable;
1249
1781
  const scopedName = call.in_method ? `${call.in_method}:${varName}` : varName;
1250
- if (taintedVars.has(varName) || taintedVars.has(scopedName)) {
1782
+ // Bare `name` that is a field read (no local of that name in this
1783
+ // method) still counts as a field use — Inter4-style. A shadowing
1784
+ // local declaration in the method does not.
1785
+ const expr = arg.expression ?? '';
1786
+ const explicitField = isFieldUseExpression(expr, varName, enclosingTypeNamesAt(types, call.location.line));
1787
+ const bareFieldRead = fieldNames.has(varName)
1788
+ && expr === varName
1789
+ && !methodHasLocalDecl(code, call.in_method, varName, call.location.line);
1790
+ const isFieldUse = explicitField || bareFieldRead;
1791
+ if (isTaintedInMethod(taintedVars, varName, scopedName, fieldNames, isFieldUse)) {
1251
1792
  // Sprint 13 #70 — pick a source in the same method scope as the
1252
1793
  // sink, not blanket sources[0]. The varName-based match is
1253
1794
  // primary; method-scope is the tiebreaker; sources[0] is the
@@ -1365,23 +1906,22 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
1365
1906
  !sink.argPositions.includes(arg.position)) {
1366
1907
  continue;
1367
1908
  }
1368
- const arrayAccessMatch = arg.expression?.match(/^(\w+)\[(\d+|[^[\]]+)\]$/);
1369
- if (arrayAccessMatch) {
1370
- const arrayName = arrayAccessMatch[1];
1371
- const indexStr = arrayAccessMatch[2];
1909
+ const arrayAccess = parseJavaArrayAccessExpression(arg.expression);
1910
+ if (arrayAccess) {
1911
+ const { base, arrayName, indexKey, displayVar } = arrayAccess;
1372
1912
  const taintedIndices = taintedArrayElements.get(arrayName);
1373
1913
  if (taintedIndices) {
1374
- const isTainted = taintedIndices.has(indexStr) || taintedIndices.has('*');
1914
+ const isTainted = taintedIndices.has(indexKey) || taintedIndices.has('*');
1375
1915
  if (isTainted) {
1376
1916
  // Sprint 13 #70 — method-scoped source picker (see detectCollectionFlows).
1377
- const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types, arrayName);
1917
+ const source = pickScopedSource(sources, sink.line, call.in_method ?? null, types, base);
1378
1918
  if (source) {
1379
1919
  flows.push({
1380
1920
  source_line: source.line, sink_line: sink.line,
1381
1921
  source_type: source.type, sink_type: sink.type,
1382
1922
  path: [
1383
- { variable: arrayName, line: source.line, type: 'source' },
1384
- { variable: `${arrayName}[${indexStr}]`, line: sink.line, type: 'sink' },
1923
+ { variable: base, line: source.line, type: 'source' },
1924
+ { variable: displayVar, line: sink.line, type: 'sink' },
1385
1925
  ],
1386
1926
  confidence: 0.85, sanitized: false,
1387
1927
  });
@@ -1394,6 +1934,20 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
1394
1934
  }
1395
1935
  return flows;
1396
1936
  }
1937
+ /** Parameter names referenced as identifiers inside a compound sink argument (#685). */
1938
+ function compoundArgParamNames(expression, paramNames) {
1939
+ const stripped = expression.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => ' '.repeat(m.length));
1940
+ const matched = [];
1941
+ for (const raw of paramNames) {
1942
+ const name = raw.startsWith('*') ? raw.slice(1) : raw;
1943
+ if (!/^[\w$]+$/.test(name))
1944
+ continue;
1945
+ const re = new RegExp(`(?<![\\w$.])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`);
1946
+ if (re.test(stripped))
1947
+ matched.push(name);
1948
+ }
1949
+ return matched;
1950
+ }
1397
1951
  function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines, tainted, code) {
1398
1952
  const flows = [];
1399
1953
  const paramSourcesByMethod = new Map();
@@ -1432,14 +1986,17 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1432
1986
  if (!methodParamSources)
1433
1987
  continue;
1434
1988
  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);
1989
+ if (sink.argPositions && sink.argPositions.length > 0 &&
1990
+ !sink.argPositions.includes(arg.position)) {
1991
+ continue;
1992
+ }
1993
+ const varNames = arg.variable
1994
+ ? [arg.variable]
1995
+ : (arg.expression
1996
+ ? compoundArgParamNames(arg.expression, methodParamSources.keys())
1997
+ : []);
1998
+ for (const varName of varNames) {
1999
+ const paramSource = methodParamSources.get(varName);
1443
2000
  if (paramSource) {
1444
2001
  // Sprint 82 (#189) — sink-type-aware dedup so a sink registered
1445
2002
  // under multiple types at the same call site (e.g. `redirect` as
@@ -1447,7 +2004,7 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1447
2004
  const exists = flows.some(f => f.source_line === paramSource.line && f.sink_line === sink.line && f.sink_type === sink.type);
1448
2005
  if (!exists) {
1449
2006
  if (typeof code === 'string' &&
1450
- isReassignedToLiteralBetween(code, arg.variable, paramSource.line, sink.line, unreachableLines)) {
2007
+ isReassignedToLiteralBetween(code, varName, paramSource.line, sink.line, unreachableLines)) {
1451
2008
  continue;
1452
2009
  }
1453
2010
  // Note: DFG-flow filter handles sanitizer-guard suppression
@@ -1456,8 +2013,8 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1456
2013
  source_line: paramSource.line, sink_line: sink.line,
1457
2014
  source_type: paramSource.type, sink_type: sink.type,
1458
2015
  path: [
1459
- { variable: arg.variable, line: paramSource.line, type: 'source' },
1460
- { variable: arg.variable, line: sink.line, type: 'sink' },
2016
+ { variable: varName, line: paramSource.line, type: 'source' },
2017
+ { variable: varName, line: sink.line, type: 'sink' },
1461
2018
  ],
1462
2019
  confidence: 0.75, sanitized: false,
1463
2020
  });
@@ -1623,7 +2180,18 @@ function csharpMethodScopedAliases(code, seeds, methods, fieldNames) {
1623
2180
  }
1624
2181
  continue;
1625
2182
  }
1626
- const d = declRe.exec(text) ?? assignRe.exec(text);
2183
+ let d = declRe.exec(text) ?? assignRe.exec(text);
2184
+ if (!d && text.includes('{')) {
2185
+ const inner = text.slice(text.indexOf('{') + 1, text.lastIndexOf('}'));
2186
+ for (const stmt of inner.split(';')) {
2187
+ const trimmedStmt = stmt.trim();
2188
+ if (!trimmedStmt)
2189
+ continue;
2190
+ d = declRe.exec(`${trimmedStmt};`) ?? assignRe.exec(`${trimmedStmt};`);
2191
+ if (d)
2192
+ break;
2193
+ }
2194
+ }
1627
2195
  if (!d)
1628
2196
  continue;
1629
2197
  const [, lhs, rhs] = d;
@@ -1658,6 +2226,211 @@ function splitLinesCached(code) {
1658
2226
  _reassignSplitLines = code.split('\n');
1659
2227
  return _reassignSplitLines;
1660
2228
  }
2229
+ const CSHARP_ID_KW = new Set([
2230
+ 'if', 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'default', 'break',
2231
+ 'return', 'new', 'var', 'string', 'int', 'bool', 'void', 'static', 'public', 'class',
2232
+ 'using', 'lock', 'try', 'catch', 'finally', 'throw', 'null', 'true', 'false',
2233
+ ]);
2234
+ function csharpStripStringLiterals(text) {
2235
+ return text
2236
+ .replace(/\$@?"(?:[^"\\]|\\.)*"/g, ' ')
2237
+ .replace(/@?"(?:[^"\\]|\\.)*"/g, ' ')
2238
+ .replace(/'(?:[^'\\]|\\.)'/g, ' ')
2239
+ .replace(/`(?:[^`\\]|\\.)*`/g, ' ');
2240
+ }
2241
+ function csharpIdentifiersInExpr(text) {
2242
+ const ids = csharpStripStringLiterals(text).match(/\b[A-Za-z_][\w]*\b/g) ?? [];
2243
+ return ids.filter(id => !CSHARP_ID_KW.has(id));
2244
+ }
2245
+ const CSHARP_ASSIGN_RE = /^\s*(?:(?:var|string|String|[\w.<>\[\]?]+\s+)?([A-Za-z_]\w*)|([A-Za-z_]\w*))\s*=\s*(.+)$/;
2246
+ /**
2247
+ * Variables that carry THIS C# flow's taint (path locals plus forward/backward
2248
+ * derivation). Pure-literal helpers (`table = "users"` next to tainted `input`)
2249
+ * are excluded so their literal assignments cannot drop the flow (#475).
2250
+ */
2251
+ function csharpFlowTaintCarriers(code, flow) {
2252
+ const carriers = new Set();
2253
+ for (const step of flow.path) {
2254
+ if (step.variable)
2255
+ carriers.add(step.variable);
2256
+ }
2257
+ if (carriers.size === 0)
2258
+ return carriers;
2259
+ const lines = splitLinesCached(code);
2260
+ const lo = Math.max(0, flow.source_line - 1);
2261
+ const hi = Math.min(lines.length, flow.sink_line);
2262
+ const stripLits = (rhs) => csharpStripStringLiterals(rhs);
2263
+ const assigns = [];
2264
+ for (let i = lo; i < hi; i++) {
2265
+ const line = lines[i] ?? '';
2266
+ if (/^\s*if\b/.test(line))
2267
+ continue;
2268
+ for (const seg of line.split(';')) {
2269
+ const t = seg.trim();
2270
+ if (!t)
2271
+ continue;
2272
+ const m = CSHARP_ASSIGN_RE.exec(t);
2273
+ if (!m)
2274
+ continue;
2275
+ const lhs = m[1] ?? m[2];
2276
+ const rhs = m[3];
2277
+ if (!lhs)
2278
+ continue;
2279
+ const rhsCode = stripLits(rhs);
2280
+ const literal = rhsCode.replace(/\s+/g, '') === '';
2281
+ const rhsIdents = rhsCode.match(/\b[A-Za-z_][\w]*\b/g) ?? [];
2282
+ assigns.push({ lhs, rhsIdents, literal });
2283
+ }
2284
+ }
2285
+ const hadNonLiteral = new Set();
2286
+ for (const a of assigns) {
2287
+ if (!a.literal)
2288
+ hadNonLiteral.add(a.lhs);
2289
+ }
2290
+ let changed = true;
2291
+ for (let guard = 0; changed && guard < assigns.length + 2; guard++) {
2292
+ changed = false;
2293
+ for (const a of assigns) {
2294
+ if (!carriers.has(a.lhs) && a.rhsIdents.some(id => carriers.has(id))) {
2295
+ carriers.add(a.lhs);
2296
+ changed = true;
2297
+ }
2298
+ if (carriers.has(a.lhs)) {
2299
+ for (const id of a.rhsIdents) {
2300
+ if (!carriers.has(id) && hadNonLiteral.has(id)) {
2301
+ carriers.add(id);
2302
+ changed = true;
2303
+ }
2304
+ }
2305
+ }
2306
+ }
2307
+ }
2308
+ return carriers;
2309
+ }
2310
+ /**
2311
+ * For each upstream local, the 1-based line of the assignment that copied it
2312
+ * into the sink dependency chain (closest to the sink). A literal reset of
2313
+ * `v` after `var q = ... + v` must not kill a flow that only reads `q` (#475).
2314
+ */
2315
+ function csharpSinkDependencyKillVars(code, flow) {
2316
+ const lines = splitLinesCached(code);
2317
+ const sinkIdx = flow.sink_line - 1;
2318
+ const deriveLine = new Map();
2319
+ if (sinkIdx < 0 || sinkIdx >= lines.length)
2320
+ return deriveLine;
2321
+ const need = new Set(csharpIdentifiersInExpr(lines[sinkIdx] ?? ''));
2322
+ for (let i = sinkIdx - 1; i >= flow.source_line - 1; i--) {
2323
+ const line = lines[i] ?? '';
2324
+ for (const seg of line.split(';')) {
2325
+ const t = seg.trim();
2326
+ if (!t)
2327
+ continue;
2328
+ const m = CSHARP_ASSIGN_RE.exec(t);
2329
+ if (!m)
2330
+ continue;
2331
+ const lhs = m[1] ?? m[2];
2332
+ if (!lhs || !need.has(lhs))
2333
+ continue;
2334
+ for (const id of csharpIdentifiersInExpr(m[3])) {
2335
+ if (!deriveLine.has(id))
2336
+ deriveLine.set(id, i + 1);
2337
+ need.add(id);
2338
+ }
2339
+ }
2340
+ }
2341
+ return deriveLine;
2342
+ }
2343
+ function csharpLastUnconditionalAssignIsLiteral(code, variable, srcLine,
2344
+ /** Scan assignments on lines strictly before this 1-based line. */
2345
+ beforeLine, unreachableLines) {
2346
+ if (!variable || !/^[A-Za-z_][\w]*$/.test(variable))
2347
+ return false;
2348
+ if (beforeLine - srcLine < 1)
2349
+ return false;
2350
+ const lines = splitLinesCached(code);
2351
+ const reLit = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|`(?:[^`\\\\]|\\\\.)*`)\\s*$');
2352
+ const rePlain = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*(?<![+\\-*/%&|^])=\\s*(.+)\\s*$');
2353
+ const reCompound = new RegExp('^(?:(?:var|string|String)\\s+)?' + variable + '\\s*[+\\-*/%&|^]=\\s*(.+)\\s*$');
2354
+ const reRefOut = new RegExp('\\b(?:ref|out)\\s+' + variable + '\\b');
2355
+ let last = null;
2356
+ const lo = Math.max(0, srcLine);
2357
+ const hi = Math.min(lines.length, beforeLine - 1);
2358
+ for (let i = lo; i < hi; i++) {
2359
+ if (unreachableLines?.has(i + 1))
2360
+ continue;
2361
+ const line = lines[i];
2362
+ if (!line)
2363
+ continue;
2364
+ if (/^\s*if\b/.test(line))
2365
+ continue;
2366
+ for (const seg of line.split(';')) {
2367
+ const t = seg.trim();
2368
+ if (!t)
2369
+ continue;
2370
+ if (reCompound.test(t)) {
2371
+ last = 'other';
2372
+ continue;
2373
+ }
2374
+ if (reRefOut.test(csharpStripStringLiterals(t))) {
2375
+ last = 'other';
2376
+ continue;
2377
+ }
2378
+ if (!rePlain.test(t))
2379
+ continue;
2380
+ if (csharpAssignInConditionalBlock(lines, i))
2381
+ continue;
2382
+ if (reLit.test(t)) {
2383
+ last = 'literal';
2384
+ }
2385
+ else {
2386
+ last = 'other';
2387
+ }
2388
+ }
2389
+ }
2390
+ return last === 'literal';
2391
+ }
2392
+ /** True when `header` opens a non-bare control-flow block (not a method/type body). */
2393
+ function csharpBlockHeaderIsConditional(header) {
2394
+ const h = header.trim();
2395
+ if (!h || h === '{')
2396
+ return false;
2397
+ if (/^(?:case\b.*|default\s*):$/.test(h))
2398
+ return true;
2399
+ if (/\bswitch\b/.test(h))
2400
+ return true;
2401
+ if (/^(?:\}\s*)?(?:else\s+)?if\s*\(/.test(h))
2402
+ return true;
2403
+ if (/^(?:\}\s*)?else\b/.test(h))
2404
+ return true;
2405
+ if (/\b(?:for|foreach|while|do|using|lock|try|catch|finally)\b/.test(h))
2406
+ return true;
2407
+ return false;
2408
+ }
2409
+ /**
2410
+ * True when line `idx` sits in a control-flow block. Walks outward through
2411
+ * enclosing braces so an Allman `for` / `while` header above a lone `{` line
2412
+ * is recognised (#475).
2413
+ */
2414
+ function csharpAssignInConditionalBlock(lines, idx) {
2415
+ let at = idx;
2416
+ for (let guard = 0; guard < 16; guard++) {
2417
+ const block = enclosingBrace(lines, at, 0);
2418
+ if (!block)
2419
+ return false;
2420
+ let header = block.header.trim();
2421
+ if (!header || header === '{') {
2422
+ const prev = block.headerLine > 0 ? (lines[block.headerLine - 1] ?? '').trim() : '';
2423
+ if (prev)
2424
+ header = prev;
2425
+ }
2426
+ if (csharpBlockHeaderIsConditional(header))
2427
+ return true;
2428
+ if (block.headerLine <= 0)
2429
+ return false;
2430
+ at = block.headerLine - 1;
2431
+ }
2432
+ return false;
2433
+ }
1661
2434
  function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreachableLines) {
1662
2435
  if (!variable || sinkLine - srcLine < 2)
1663
2436
  return false;
@@ -1688,17 +2461,33 @@ function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreach
1688
2461
  // The fall-through `break;` is optional. Used by cognium-dev #101
1689
2462
  // to suppress the switch→constant FP corpus.
1690
2463
  const reSwitchCase = new RegExp(`^\\s*(?:case\\b.*?|default\\s*):\\s*${variable}\\s*=\\s*${strLit}\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
2464
+ const lineHasNakedLiteralReassign = (line) => {
2465
+ if (reNaked.test(line) || reSwitchCase.test(line))
2466
+ return true;
2467
+ // cognium-dev#475: `var v = input; v = "const";` on one line — only the
2468
+ // trailing statement is a literal kill, not the whole physical line.
2469
+ for (const seg of line.split(';')) {
2470
+ const t = seg.trim();
2471
+ if (!t)
2472
+ continue;
2473
+ if (reNaked.test(t) || reSwitchCase.test(t))
2474
+ return true;
2475
+ }
2476
+ return false;
2477
+ };
1691
2478
  for (let i = lo; i < hi; i++) {
1692
2479
  const line = lines[i];
1693
2480
  if (!line)
1694
2481
  continue;
1695
2482
  if (reGuarded.test(line))
1696
2483
  return true;
1697
- if (!reNaked.test(line) && !reSwitchCase.test(line))
2484
+ if (!lineHasNakedLiteralReassign(line))
1698
2485
  continue;
1699
2486
  // A literal assigned in one case of a switch only overwrites the value
1700
2487
  // when no case gives the variable anything else. `case 'A': bar = param;`
1701
2488
  // next to `default: bar = "safe";` leaves `bar` tainted on the 'A' path.
2489
+ if (javaCatchLiteralHasTaintedTryArm(lines, i, variable, strLit, unreachableLines))
2490
+ continue;
1702
2491
  if (siblingBranchAssignsNonLiteral(lines, i, variable, strLit, unreachableLines))
1703
2492
  continue;
1704
2493
  return true;
@@ -1745,6 +2534,46 @@ function matchingClose(lines, line, col) {
1745
2534
  }
1746
2535
  return null;
1747
2536
  }
2537
+ /**
2538
+ * True when line `idx` is a literal assignment in a `catch` block and the
2539
+ * paired `try` assigns `variable` a non-literal value (cognium-dev#753).
2540
+ */
2541
+ function javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines) {
2542
+ const block = enclosingBrace(lines, idx, 0);
2543
+ if (!block || !/\bcatch\b/.test(block.header))
2544
+ return false;
2545
+ let h = block.headerLine;
2546
+ while (h >= 0 && !/\btry\b/.test(lines[h]))
2547
+ h--;
2548
+ if (h < 0)
2549
+ return false;
2550
+ const tryIdx = lines[h].search(/\btry\b/);
2551
+ let openLine = h;
2552
+ let openCol = lines[h].indexOf('{', tryIdx);
2553
+ if (openCol < 0) {
2554
+ openLine = h + 1;
2555
+ while (openLine < lines.length && lines[openLine].trim() === '')
2556
+ openLine++;
2557
+ if (openLine >= lines.length)
2558
+ return false;
2559
+ openCol = lines[openLine].indexOf('{');
2560
+ if (openCol < 0)
2561
+ return false;
2562
+ }
2563
+ const tryEnd = matchingClose(lines, openLine, openCol);
2564
+ if (!tryEnd)
2565
+ return false;
2566
+ const assignTry = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
2567
+ const literalOnly = new RegExp(`^${strLit}$`);
2568
+ for (let i = openLine; i <= tryEnd.line; i++) {
2569
+ if (unreachableLines?.has(i + 1))
2570
+ continue;
2571
+ const m = assignTry.exec(lines[i]);
2572
+ if (m && m[1] && !literalOnly.test(m[1]))
2573
+ return true;
2574
+ }
2575
+ return false;
2576
+ }
1748
2577
  /**
1749
2578
  * True when line `idx` assigns `variable` in one arm of a `switch` or one
1750
2579
  * branch of an `if`/`else` chain, and a sibling arm or branch assigns it a
@@ -1767,7 +2596,7 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1767
2596
  block = enclosingBrace(lines, block.line, block.col);
1768
2597
  }
1769
2598
  if (!block)
1770
- return false;
2599
+ return pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines);
1771
2600
  let first = block.headerLine;
1772
2601
  let last;
1773
2602
  const end = matchingClose(lines, block.line, block.col);
@@ -1827,6 +2656,9 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1827
2656
  last = next.line;
1828
2657
  }
1829
2658
  }
2659
+ else if (/\bcatch\b/.test(block.header)) {
2660
+ return javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines);
2661
+ }
1830
2662
  else {
1831
2663
  return false;
1832
2664
  }
@@ -1850,6 +2682,83 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1850
2682
  }
1851
2683
  return false;
1852
2684
  }
2685
+ const PY_ARM_OPEN = /^(?:(?:if|elif)\b.*|else\s*):\s*(?:#.*)?$/;
2686
+ /**
2687
+ * The indentation-delimited counterpart of `siblingBranchAssignsNonLiteral`.
2688
+ * The arms of one `if` / `elif` / `else` chain are the header lines at one
2689
+ * indentation plus the more deeply indented lines under each. True when line
2690
+ * `idx` sits directly in one arm of such a chain and another arm assigns
2691
+ * `variable` a value that is not a string literal (cognium-dev#582). Answers
2692
+ * false, keeping the literal as an overwrite, for anything else: a
2693
+ * straight-line assignment, a `for` / `while` / `try` / `def` body, or a header
2694
+ * that is not part of an `if` chain.
2695
+ */
2696
+ function pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines) {
2697
+ const indentOf = (t) => t.length - t.trimStart().length;
2698
+ const skippable = (t) => t.trim() === '' || t.trimStart().startsWith('#');
2699
+ const own = lines[idx];
2700
+ if (own === undefined)
2701
+ return false;
2702
+ const bodyIndent = indentOf(own);
2703
+ if (bodyIndent === 0)
2704
+ return false;
2705
+ // The header of the arm that holds the literal: the nearest line above with less indentation.
2706
+ let h = idx - 1;
2707
+ while (h >= 0 && (skippable(lines[h]) || indentOf(lines[h]) >= bodyIndent))
2708
+ h--;
2709
+ if (h < 0 || !PY_ARM_OPEN.test(lines[h].trim()))
2710
+ return false;
2711
+ const armIndent = indentOf(lines[h]);
2712
+ // The last line of the arm whose header is at `header`.
2713
+ const armEnd = (header) => {
2714
+ let lastBody = header;
2715
+ for (let e = header + 1; e < lines.length; e++) {
2716
+ if (skippable(lines[e]))
2717
+ continue;
2718
+ if (indentOf(lines[e]) <= armIndent)
2719
+ break;
2720
+ lastBody = e;
2721
+ }
2722
+ return lastBody;
2723
+ };
2724
+ // Walk up through `elif` / `else` to the opening `if`.
2725
+ let first = h;
2726
+ while (/^(?:elif\b|else\s*:)/.test(lines[first].trim())) {
2727
+ let p = first - 1;
2728
+ while (p >= 0 && (skippable(lines[p]) || indentOf(lines[p]) > armIndent))
2729
+ p--;
2730
+ if (p < 0 || indentOf(lines[p]) !== armIndent || !PY_ARM_OPEN.test(lines[p].trim()))
2731
+ return false;
2732
+ first = p;
2733
+ }
2734
+ if (!/^if\b/.test(lines[first].trim()))
2735
+ return false;
2736
+ // Walk down through the following `elif` / `else` arms.
2737
+ const ownEnd = armEnd(h);
2738
+ let last = ownEnd;
2739
+ for (let guard = 0; guard < 64; guard++) {
2740
+ let n = last + 1;
2741
+ while (n < lines.length && skippable(lines[n]))
2742
+ n++;
2743
+ if (n >= lines.length || indentOf(lines[n]) !== armIndent)
2744
+ break;
2745
+ if (!/^(?:elif\b|else\s*:)/.test(lines[n].trim()) || !PY_ARM_OPEN.test(lines[n].trim()))
2746
+ break;
2747
+ last = armEnd(n);
2748
+ }
2749
+ const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*$`);
2750
+ const literalOnly = new RegExp(`^${strLit}$`);
2751
+ for (let i = first; i <= last; i++) {
2752
+ if (i === idx || unreachableLines?.has(i + 1))
2753
+ continue;
2754
+ if (i >= h && i <= ownEnd)
2755
+ continue; // the literal's own arm is not a sibling
2756
+ const m = assign.exec(lines[i]);
2757
+ if (m && m[1] && !literalOnly.test(m[1]))
2758
+ return true;
2759
+ }
2760
+ return false;
2761
+ }
1853
2762
  /**
1854
2763
  * Languages whose plain quoted strings cannot interpolate, so blanking the
1855
2764
  * literal text before identifier tokenisation is sound (cognium-dev#353).
@@ -2057,6 +2966,8 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2057
2966
  // is every Juliet C# file.
2058
2967
  const csFnRanges = methodRanges(['csharp']);
2059
2968
  const csFnStart = (line) => enclosingStart(csFnRanges, line);
2969
+ const jsFnRanges = methodRanges(['javascript', 'typescript']);
2970
+ const jsFnStart = (line) => enclosingStart(jsFnRanges, line);
2060
2971
  // cognium-dev#548 — names declared as class fields. A field is the one kind
2061
2972
  // of variable whose value legitimately crosses from one method to another, so
2062
2973
  // the per-method gate below must not apply to it.
@@ -2500,6 +3411,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2500
3411
  const aliases = csharpMethodScopedAliases(code, seeds, csMethods, csFieldNames);
2501
3412
  const fileAnchor = sourcesWithVar.reduce((a, sv) => (sv.line < a.line ? sv : a), sourcesWithVar[0]);
2502
3413
  const have = new Set(sourcesWithVar.map(sv => `${sv.variable}@${methodStartOf(sv.line)}`));
3414
+ const seedVars = new Set(sourcesWithVar.map(s => s.variable));
3415
+ const derivedForAnchor = buildJavaTaintedVars(code, seedVars);
3416
+ const codeLinesForAnchor = code.split('\n');
2503
3417
  for (const alias of aliases) {
2504
3418
  const fn = methodStartOf(alias.line);
2505
3419
  const isField = csFieldNames.has(alias.variable);
@@ -2512,14 +3426,34 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2512
3426
  // call), which also keeps it ahead of a sink in a method declared
2513
3427
  // earlier in the file than the method holding the source.
2514
3428
  const inMethod = sourcesWithVar.filter(sv => methodStartOf(sv.line) === fn && sv.line <= alias.line);
2515
- const from = !alias.carried && inMethod.length > 0
3429
+ let from = !alias.carried && inMethod.length > 0
2516
3430
  ? inMethod.reduce((a, sv) => (sv.line < a.line ? sv : a), inMethod[0])
2517
3431
  : fileAnchor;
2518
- sourcesWithVar.push({
3432
+ // cognium-dev#515: same RHS anchor as Java — do not credit an earlier decoy read.
3433
+ if (!alias.carried && derivedForAnchor.size > 0) {
3434
+ const methodRealSourceByVar = new Map();
3435
+ for (const s of sourcesWithVar) {
3436
+ if (methodStartOf(s.line) !== fn)
3437
+ continue;
3438
+ const prev = methodRealSourceByVar.get(s.variable);
3439
+ if (!prev || s.line < prev.line)
3440
+ methodRealSourceByVar.set(s.variable, s);
3441
+ }
3442
+ const referenced = resolveJavaDerivedAnchor(alias.variable, derivedForAnchor, methodRealSourceByVar, codeLinesForAnchor, new Set());
3443
+ if (referenced)
3444
+ from = referenced;
3445
+ }
3446
+ const aliasEntry = {
2519
3447
  ...from,
2520
3448
  variable: alias.variable,
2521
3449
  ...(alias.carried || inMethod.length === 0 ? { line: alias.line } : {}),
2522
- });
3450
+ };
3451
+ // cognium-dev#327: field aliases must not keep the writing method's
3452
+ // `in_method` tag — readers in other methods need to see the taint.
3453
+ if (isField) {
3454
+ delete aliasEntry.in_method;
3455
+ }
3456
+ sourcesWithVar.push(aliasEntry);
2523
3457
  }
2524
3458
  }
2525
3459
  else if ((language === 'java' || language === 'csharp') && typeof code === 'string' && sourcesWithVar.length > 0) {
@@ -2550,29 +3484,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2550
3484
  }
2551
3485
  return null;
2552
3486
  };
3487
+ const typeAt = (line) => {
3488
+ for (const t of types ?? []) {
3489
+ if (line >= t.start_line && line <= t.end_line)
3490
+ return t;
3491
+ }
3492
+ return undefined;
3493
+ };
3494
+ const isDeclaredField = (line, name) => {
3495
+ const t = typeAt(line);
3496
+ return t?.fields?.some(f => f.name === name) ?? false;
3497
+ };
2553
3498
  const existingVars = new Set(sourcesWithVar.map(s => s.variable));
3499
+ const codeLines = code.split('\n');
3500
+ const realSourceByVar = new Map();
3501
+ for (const s of sourcesWithVar) {
3502
+ const prev = realSourceByVar.get(s.variable);
3503
+ if (!prev || s.line < prev.line)
3504
+ realSourceByVar.set(s.variable, s);
3505
+ }
2554
3506
  for (const [varName, derivedLine] of derived) {
2555
3507
  if (!varName || existingVars.has(varName))
2556
3508
  continue;
2557
3509
  const owner = methodAt(derivedLine);
3510
+ const fieldAlias = isDeclaredField(derivedLine, varName);
2558
3511
  let scopedAnchor = anchor;
2559
3512
  if (owner) {
2560
3513
  let best;
3514
+ const methodRealSourceByVar = new Map();
2561
3515
  for (const s of sourcesWithVar) {
2562
3516
  const sOwner = s.in_method ?? methodAt(s.line);
2563
3517
  if (sOwner !== owner)
2564
3518
  continue;
3519
+ const prev = methodRealSourceByVar.get(s.variable);
3520
+ if (!prev || s.line < prev.line)
3521
+ methodRealSourceByVar.set(s.variable, s);
2565
3522
  if (!best || s.line < best.line)
2566
3523
  best = s;
2567
3524
  }
2568
- if (best)
3525
+ const referenced = resolveJavaDerivedAnchor(varName, derived, methodRealSourceByVar, codeLines, new Set());
3526
+ if (referenced)
3527
+ scopedAnchor = referenced;
3528
+ else if (best)
2569
3529
  scopedAnchor = best;
2570
3530
  }
2571
- sourcesWithVar.push({
2572
- ...scopedAnchor,
2573
- variable: varName,
2574
- ...(owner ? { in_method: owner } : {}),
2575
- });
3531
+ // Keep scopedAnchor.line for in_method / preceding-source matching
3532
+ // (#614). Pass derivedLine only for literal-kill (#753), so a safe
3533
+ // sibling method's earlier sink cannot pick up this alias by line.
3534
+ // cognium-dev#327: a declared field written from a method parameter
3535
+ // must not inherit the writing method as `in_method` — same exemption
3536
+ // as request sources on fields (4.12.0), so readers in other methods
3537
+ // can see the taint.
3538
+ if (fieldAlias) {
3539
+ const { in_method: _drop, ...rest } = scopedAnchor;
3540
+ sourcesWithVar.push({
3541
+ ...rest,
3542
+ variable: varName,
3543
+ literal_kill_line: derivedLine,
3544
+ });
3545
+ }
3546
+ else {
3547
+ sourcesWithVar.push({
3548
+ ...scopedAnchor,
3549
+ variable: varName,
3550
+ literal_kill_line: derivedLine,
3551
+ ...(owner ? { in_method: owner } : {}),
3552
+ });
3553
+ }
2576
3554
  existingVars.add(varName);
2577
3555
  }
2578
3556
  }
@@ -2586,7 +3564,217 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2586
3564
  // tainted var (at the CommandText line) so the arg-scan links the Execute sink.
2587
3565
  if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0) {
2588
3566
  const lines = code.split('\n');
2589
- const ctRe = /(\w+)\s*\.\s*CommandText\s*\+?=\s*(.+)/;
3567
+ // cognium-dev#594: `X.StartInfo.FileName = <tainted>` is the same shape for a
3568
+ // `System.Diagnostics.Process`: the executable path rides the process object
3569
+ // and the zero-argument `X.Start()` is the injection point. Only the
3570
+ // `FileName` write seeds it, and an `Arguments` write when the program is a
3571
+ // shell (`ArgumentList` is a separate follow-up), so a constant `FileName`
3572
+ // beside an unrelated tainted parameter stays clean.
3573
+ // A write only: `+=` or a single `=` that is not part of `==`, `!=`, `<=`, `>=`
3574
+ // (`if (p.StartInfo.FileName == tool)` is a comparison, not an assignment).
3575
+ // `StartInfo.Arguments` seeds too, but only behind a shell (or a non-constant
3576
+ // program), see `argumentsReachShell` below.
3577
+ const ctRe = /(\w+)\s*\.\s*(?:CommandText|StartInfo\s*\.\s*(?:FileName|(Arguments)))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
3578
+ // cognium-dev#594: the same two writes on a local `ProcessStartInfo psi`
3579
+ // (`psi.FileName = …`, `psi.Arguments = …`), read back by `Process.Start(psi)`.
3580
+ // Gated on the receiver being declared as a `ProcessStartInfo`, so a `FileName`
3581
+ // or `Arguments` property of any other type seeds nothing.
3582
+ const psiVars = new Set();
3583
+ for (const line of lines) {
3584
+ for (const pm of line.matchAll(/\bProcessStartInfo\s+([\p{L}_][\p{L}\p{N}_]*)/gu))
3585
+ psiVars.add(pm[1]);
3586
+ for (const pm of line.matchAll(/\b([\p{L}_][\p{L}\p{N}_]*)\s*=\s*new\s+ProcessStartInfo\b/gu))
3587
+ psiVars.add(pm[1]);
3588
+ }
3589
+ const psiRe = /(?<![\w.])(\w+)\s*\.\s*(?:FileName|(Arguments))\s*(?:\+=|(?<![=!<>])=(?!=))\s*(.+)/;
3590
+ const psiOverwrittenByLiteral = (obj, prop, at) => {
3591
+ const fn = csFnStart(at + 1);
3592
+ const re = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*${prop}\\s*=(?!=)\\s*@?"[^"]*"\\s*;\\s*(?://.*)?$`);
3593
+ for (let j = at + 1; j < lines.length; j++) {
3594
+ if (csFnStart(j + 1) !== fn)
3595
+ break;
3596
+ if (re.test(lines[j]))
3597
+ return true;
3598
+ }
3599
+ return false;
3600
+ };
3601
+ const fileNameRe = (obj) => new RegExp(`\\b${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?FileName\\s*=(?!=)\\s*(.+)`);
3602
+ const shellProgramRe = /^(?:cmd|powershell|pwsh|sh|bash|zsh|dash|ksh|csh|tcsh|fish)(?:\.exe)?$/i;
3603
+ // cognium-dev#594: with a constant, non-shell program the argument string is
3604
+ // argv (the #272 / #328 safe shape), so a tainted `Arguments` does not by itself
3605
+ // reach a shell. A constant shell program, or a program that is not a constant,
3606
+ // lets the argument text choose what runs. No `FileName` write in the method
3607
+ // (for example a `ProcessStartInfo` built elsewhere) seeds nothing.
3608
+ const argumentsReachShell = (obj, line) => {
3609
+ const re = fileNameRe(obj);
3610
+ const fnStart = csFnStart(line);
3611
+ for (let j = 0; j < lines.length; j++) {
3612
+ if (csFnStart(j + 1) !== fnStart)
3613
+ continue;
3614
+ const fm = re.exec(lines[j]);
3615
+ if (!fm)
3616
+ continue;
3617
+ const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
3618
+ if (!lit)
3619
+ return true;
3620
+ const base = lit[1].split(/[\\/]/).pop() ?? '';
3621
+ return shellProgramRe.test(base);
3622
+ }
3623
+ return false;
3624
+ };
3625
+ // cognium-dev#594: `X.StartInfo.ArgumentList.Add(<tainted>)` (or `psi.ArgumentList.Add(…)`
3626
+ // on a local `ProcessStartInfo`) is argv, so it only reaches a command when the
3627
+ // program is a shell that is told to run a command string. Seed only when the
3628
+ // method writes `FileName` exactly once, to a constant shell program, and an
3629
+ // `ArgumentList.Add` of a command flag (`-c`, `/c`, …) is in the same method.
3630
+ // A non-constant or non-shell program, a second `FileName` write, an
3631
+ // `ArgumentList.Clear()`, or an `Add(` that wraps over several lines seeds nothing.
3632
+ const argListRe = /(?<![\w.])(\w+)\s*\.\s*(StartInfo\s*\.\s*)?ArgumentList\s*\.\s*Add\s*\(\s*(.+?)\s*\)\s*;?\s*(?:\/\/.*)?$/;
3633
+ const shellFlagRe = /^(?:[-/](?:c|k|command|encodedcommand|ec))$/i;
3634
+ // cognium-dev#750: the program may also be written by the `ProcessStartInfo`
3635
+ // constructor's single argument or by a `FileName = …` entry of its object
3636
+ // initializer, on the statement that creates `obj` (`var psi = new
3637
+ // ProcessStartInfo("/bin/sh");`, `new ProcessStartInfo { FileName = "/bin/sh",
3638
+ // … }`, possibly wrapped over several lines). Each counts as one `FileName`
3639
+ // write, so the exactly-one-write rule still holds, and they apply only when the
3640
+ // method has no explicit `obj.FileName = …` write. Any shape that is not
3641
+ // plainly one constant program (two constructor arguments, an `Arguments`
3642
+ // entry, a non-literal value) counts as a non-constant write.
3643
+ const splitTopLevel = (text) => {
3644
+ const parts = [];
3645
+ let depth = 0;
3646
+ let quote = '';
3647
+ let cur = '';
3648
+ for (let k = 0; k < text.length; k++) {
3649
+ const ch = text[k];
3650
+ if (quote) {
3651
+ cur += ch;
3652
+ if (ch === '\\' && quote === '"') {
3653
+ cur += text[++k] ?? '';
3654
+ continue;
3655
+ }
3656
+ if (ch === quote)
3657
+ quote = '';
3658
+ continue;
3659
+ }
3660
+ if (ch === '"') {
3661
+ quote = ch;
3662
+ cur += ch;
3663
+ continue;
3664
+ }
3665
+ if (ch === '(' || ch === '{' || ch === '[')
3666
+ depth++;
3667
+ if (ch === ')' || ch === '}' || ch === ']')
3668
+ depth--;
3669
+ if (ch === ',' && depth === 0) {
3670
+ parts.push(cur.trim());
3671
+ cur = '';
3672
+ continue;
3673
+ }
3674
+ cur += ch;
3675
+ }
3676
+ if (cur.trim())
3677
+ parts.push(cur.trim());
3678
+ return parts;
3679
+ };
3680
+ const constLiteral = (expr) => {
3681
+ const m = /^@?"([^"]*)"$/.exec(expr.trim());
3682
+ return m ? (m[1].split(/[\\/]/).pop() ?? '') : null;
3683
+ };
3684
+ // Returns null when line j does not create `obj` or the creation writes no program.
3685
+ const startInfoCreation = (obj, j) => {
3686
+ const head = new RegExp(`(?<![\\w.])${obj}(?:\\s*\\.\\s*StartInfo)?\\s*=(?!=)\\s*new\\s+ProcessStartInfo\\b`).exec(lines[j]);
3687
+ if (!head)
3688
+ return null;
3689
+ let text = '';
3690
+ for (let k = j; k < lines.length && k < j + 40; k++) {
3691
+ text += (k === j ? lines[k].slice(head.index) : ' ' + lines[k]).replace(/\/\/.*$/, '');
3692
+ if (text.includes(';'))
3693
+ break;
3694
+ }
3695
+ const m = /new\s+ProcessStartInfo\s*(?:\(([^()]*)\))?\s*(?:\{([\s\S]*)\})?\s*;/.exec(text);
3696
+ if (!m)
3697
+ return { program: null };
3698
+ let program;
3699
+ const ctorArgs = splitTopLevel(m[1] ?? '');
3700
+ if (ctorArgs.length > 1)
3701
+ return { program: null };
3702
+ if (ctorArgs.length === 1)
3703
+ program = constLiteral(ctorArgs[0]);
3704
+ for (const member of splitTopLevel(m[2] ?? '')) {
3705
+ const em = /^(\w+)\s*=(?!=)\s*([\s\S]*)$/.exec(member);
3706
+ if (!em)
3707
+ return { program: null };
3708
+ if (em[1] === 'FileName') {
3709
+ if (program !== undefined)
3710
+ return { program: null };
3711
+ program = constLiteral(em[2]);
3712
+ }
3713
+ else if (em[1] === 'Arguments' || em[1] === 'ArgumentList') {
3714
+ return { program: null };
3715
+ }
3716
+ }
3717
+ return program === undefined ? null : { program };
3718
+ };
3719
+ const constStringFieldProgram = (fieldName) => {
3720
+ const declRe = new RegExp(`\\b(?:\\w+\\s+)*const\\s+string\\s+${fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*@?"([^"]*)"\\s*;`);
3721
+ let hits = 0;
3722
+ let base = null;
3723
+ for (const srcLine of lines) {
3724
+ const dm = declRe.exec(srcLine);
3725
+ if (!dm)
3726
+ continue;
3727
+ hits++;
3728
+ base = dm[1].split(/[\\/]/).pop() ?? '';
3729
+ }
3730
+ return hits === 1 ? base : null;
3731
+ };
3732
+ const argListReachesShell = (obj, line) => {
3733
+ const fnStart = csFnStart(line);
3734
+ const nameRe = fileNameRe(obj);
3735
+ const clearRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*(?:Clear|Remove\\w*|Insert\\w*)\\s*\\(`);
3736
+ const addRe = new RegExp(`(?<![\\w.])${obj}\\s*\\.\\s*(?:StartInfo\\s*\\.\\s*)?ArgumentList\\s*\\.\\s*Add\\s*\\(\\s*@?"([^"]*)"\\s*\\)`);
3737
+ let program = null;
3738
+ let writes = 0;
3739
+ let creations = 0;
3740
+ let createdProgram = null;
3741
+ let flag = false;
3742
+ for (let j = 0; j < lines.length; j++) {
3743
+ if (csFnStart(j + 1) !== fnStart)
3744
+ continue;
3745
+ if (clearRe.test(lines[j]))
3746
+ return false;
3747
+ const fm = nameRe.exec(lines[j]);
3748
+ if (fm) {
3749
+ writes++;
3750
+ const lit = /^\s*@?"([^"]*)"\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
3751
+ if (lit) {
3752
+ program = lit[1].split(/[\\/]/).pop() ?? '';
3753
+ }
3754
+ else {
3755
+ const id = /^\s*(\w+)\s*;?\s*(?:\/\/.*)?$/.exec(fm[1]);
3756
+ program = id ? constStringFieldProgram(id[1]) : null;
3757
+ }
3758
+ }
3759
+ const created = startInfoCreation(obj, j);
3760
+ if (created) {
3761
+ creations++;
3762
+ createdProgram = created.program;
3763
+ }
3764
+ const am = addRe.exec(lines[j]);
3765
+ if (am && shellFlagRe.test(am[1]))
3766
+ flag = true;
3767
+ }
3768
+ // An explicit `obj.FileName = …` decides the program, as before. Only when the
3769
+ // method has none does the creating statement's constant program count, and
3770
+ // then it must be the only creation (a second `new ProcessStartInfo(…)` for
3771
+ // the same variable could replace it).
3772
+ if (writes === 0 && creations > 0) {
3773
+ writes = creations;
3774
+ program = createdProgram;
3775
+ }
3776
+ return writes === 1 && program !== null && shellProgramRe.test(program) && flag;
3777
+ };
2590
3778
  // cognium-dev#548: seed per method, from sources in that method. The seed
2591
3779
  // used to be made once per file and matched by name, so `cmd.CommandText =
2592
3780
  // … + data` in `Bad()` tainted `cmd.ExecuteScalar()` in `GoodG2B()`, whose
@@ -2599,15 +3787,60 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2599
3787
  added = false;
2600
3788
  guard++;
2601
3789
  for (let i = 0; i < lines.length; i++) {
2602
- const m = ctRe.exec(lines[i]);
3790
+ const am = argListRe.exec(lines[i]);
3791
+ if (am) {
3792
+ const [, aobj, viaStartInfo, aargs] = am;
3793
+ const akey = `${aobj}@${csFnStart(i + 1)}`;
3794
+ if ((viaStartInfo || psiVars.has(aobj)) && !seeded.has(akey) && argListReachesShell(aobj, i + 1)) {
3795
+ // A value set to a literal after the source and before this `Add` no longer
3796
+ // carries it (`cmd = name; cmd = "uptime"; …Add(cmd)`).
3797
+ const literalSince = (v, from) => {
3798
+ const re = new RegExp(`(?<![\\w.])${v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)\\s*@?"[^"]*"\\s*;`);
3799
+ const fn = csFnStart(i + 1);
3800
+ for (let j = from; j < i; j++)
3801
+ if (csFnStart(j + 1) === fn && re.test(lines[j]))
3802
+ return true;
3803
+ return false;
3804
+ };
3805
+ const reachableA = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1) && !literalSince(sv.variable, sv.line));
3806
+ 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');
3807
+ if (reachableA.length > 0 && argsReferenceTaintedVar(maskedArg, reachableA)) {
3808
+ const fromA = reachableA.reduce((a, sv) => (sv.line < a.line ? sv : a), reachableA[0]);
3809
+ sourcesWithVar.push({ ...fromA, variable: aobj, line: i + 1 });
3810
+ seeded.add(akey);
3811
+ added = true;
3812
+ }
3813
+ }
3814
+ continue;
3815
+ }
3816
+ let m = ctRe.exec(lines[i]);
3817
+ let viaLocal = false;
3818
+ if (!m) {
3819
+ const pm = psiRe.exec(lines[i]);
3820
+ if (pm && psiVars.has(pm[1])) {
3821
+ m = pm;
3822
+ viaLocal = true;
3823
+ }
3824
+ }
2603
3825
  if (!m)
2604
3826
  continue;
2605
- const [, obj, rhs] = m;
3827
+ const [, obj, isArguments, rhs] = m;
3828
+ // A later plain-literal write of the same property in this method replaces
3829
+ // the tainted value before `Process.Start(psi)` reads it: do not credit it.
3830
+ if (viaLocal && psiOverwrittenByLiteral(obj, isArguments ? 'Arguments' : 'FileName', i))
3831
+ continue;
2606
3832
  const key = `${obj}@${csFnStart(i + 1)}`;
2607
3833
  if (seeded.has(key))
2608
3834
  continue;
3835
+ if (isArguments && !argumentsReachShell(obj, i + 1))
3836
+ continue;
2609
3837
  const reachable = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1));
2610
- if (reachable.length === 0 || !argsReferenceTaintedVar(rhs, reachable))
3838
+ // A value converted to a number / Guid / bool cannot carry a command; the
3839
+ // local-`psi` form masks such conversions so a raw operand beside one still counts.
3840
+ const rhsForTaint = viaLocal
3841
+ ? 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')
3842
+ : rhs;
3843
+ if (reachable.length === 0 || !argsReferenceTaintedVar(rhsForTaint, reachable))
2611
3844
  continue;
2612
3845
  const from = reachable.reduce((a, sv) => (sv.line < a.line ? sv : a), reachable[0]);
2613
3846
  sourcesWithVar.push({ ...from, variable: obj, line: i + 1 });
@@ -2807,6 +4040,19 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2807
4040
  if (srcFn !== -1 && sinkFn !== -1 && srcFn !== sinkFn)
2808
4041
  continue;
2809
4042
  }
4043
+ // cognium-dev#752: GraphQL resolver args are seeded by name only; scope
4044
+ // them to the resolver (or factory) they came from so a same-named
4045
+ // local in another recorded function does not inherit the seed.
4046
+ if (jsFnRanges.length > 0 &&
4047
+ typeof source.location === 'string' &&
4048
+ source.location.startsWith('GraphQL ')) {
4049
+ const sinkFn = jsFnStart(sink.line);
4050
+ if (sinkFn !== -1) {
4051
+ const range = jsFnRanges.find(([start]) => start === sinkFn);
4052
+ if (range && (source.line < range[0] || source.line > range[1]))
4053
+ continue;
4054
+ }
4055
+ }
2810
4056
  // cognium-dev#530: a builder-seeded source must not escape its method.
2811
4057
  // The seed stands for "this StringBuilder now holds tainted text",
2812
4058
  // which is true only of the one object in the one method where the
@@ -2860,14 +4106,16 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2860
4106
  if (aliasSanitizedFor.get(source.variable)?.has(sink.type)) {
2861
4107
  break;
2862
4108
  }
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"` /
4109
+ // Sprint 9 #58.3 / #56: between the alias derivation (or source)
4110
+ // line and sink.line, if the tainted variable is reassigned to a
4111
+ // pure string literal (either naked `var = "lit"` or guarded by an
4112
+ // allowlist check such as `if (!ALLOWLIST.contains(var)) var = "lit"` /
2867
4113
  // `if var not in ALLOWLIST: var = "lit"`), the original taint
2868
- // no longer reaches the sink — suppress the flow.
4114
+ // no longer reaches the sink — suppress the flow. Derived aliases
4115
+ // carry `literal_kill_line` so kill starts at the assignment that
4116
+ // created them (#753), while `source.line` stays on the anchor (#614).
2869
4117
  if (typeof code === 'string' &&
2870
- isReassignedToLiteralBetween(code, source.variable, source.line, sink.line, unreachableLines)) {
4118
+ isReassignedToLiteralBetween(code, source.variable, source.literal_kill_line ?? source.line, sink.line, unreachableLines)) {
2871
4119
  break;
2872
4120
  }
2873
4121
  // Note: DFG-flow filter handles sanitizer-guard suppression
@@ -2890,6 +4138,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2890
4138
  }
2891
4139
  }
2892
4140
  }
4141
+ // cognium-dev#490 — same helper as `splitCSharpDepth0Statements` in language-sources-pass.
4142
+ const splitCSharpDepth0Statements = (line) => {
4143
+ if (line.includes('{'))
4144
+ return [line];
4145
+ const statements = [];
4146
+ let start = 0;
4147
+ let depth = 0;
4148
+ let inStr = null;
4149
+ let esc = false;
4150
+ for (let i = 0; i < line.length; i++) {
4151
+ const c = line[i];
4152
+ if (inStr) {
4153
+ if (esc) {
4154
+ esc = false;
4155
+ continue;
4156
+ }
4157
+ if (c === '\\') {
4158
+ esc = true;
4159
+ continue;
4160
+ }
4161
+ if (c === inStr)
4162
+ inStr = null;
4163
+ continue;
4164
+ }
4165
+ if (c === '"' || c === "'") {
4166
+ inStr = c;
4167
+ continue;
4168
+ }
4169
+ if (c === '(' || c === '[')
4170
+ depth++;
4171
+ else if (c === ')' || c === ']')
4172
+ depth = Math.max(0, depth - 1);
4173
+ else if (c === ';' && depth === 0) {
4174
+ const part = line.slice(start, i + 1).trim();
4175
+ if (part)
4176
+ statements.push(part);
4177
+ start = i + 1;
4178
+ }
4179
+ }
4180
+ const tail = line.slice(start).trim();
4181
+ if (tail)
4182
+ statements.push(tail);
4183
+ return statements.length > 1 ? statements : [line];
4184
+ };
4185
+ const boundInEarlierStatement = (code, variable, sinkMethod) => {
4186
+ if (!variable || variable === '<inline>' || !sinkMethod)
4187
+ return false;
4188
+ const stmts = splitCSharpDepth0Statements(code);
4189
+ if (stmts.length < 2)
4190
+ return false;
4191
+ const methodRe = new RegExp(`\\b${sinkMethod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
4192
+ let sinkIdx = -1;
4193
+ for (let i = 0; i < stmts.length; i++) {
4194
+ if (methodRe.test(stmts[i])) {
4195
+ sinkIdx = i;
4196
+ break;
4197
+ }
4198
+ }
4199
+ if (sinkIdx <= 0)
4200
+ return false;
4201
+ const varAssign = new RegExp(`\\b${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=(?!=)`);
4202
+ for (let i = 0; i < sinkIdx; i++) {
4203
+ if (varAssign.test(stmts[i]))
4204
+ return true;
4205
+ }
4206
+ return false;
4207
+ };
2893
4208
  // cognium-dev #83: inline-source colocation.
2894
4209
  //
2895
4210
  // When a source expression is used INLINE as a sink argument — e.g.
@@ -2978,6 +4293,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2978
4293
  if (new RegExp(`\\b${sourceVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(rhs)) {
2979
4294
  continue;
2980
4295
  }
4296
+ if (boundInEarlierStatement(sinkCode, sourceVar, sink.method)) {
4297
+ continue;
4298
+ }
2981
4299
  }
2982
4300
  // Skip the degenerate `file_input` → `path_traversal` colocation
2983
4301
  // where the source and sink describe the SAME call (one being the