circle-ir 4.14.2 → 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 (168) 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/const-table-lookup.d.ts +29 -0
  6. package/dist/analysis/const-table-lookup.d.ts.map +1 -0
  7. package/dist/analysis/const-table-lookup.js +145 -0
  8. package/dist/analysis/const-table-lookup.js.map +1 -0
  9. package/dist/analysis/constant-caller-gate.d.ts +39 -0
  10. package/dist/analysis/constant-caller-gate.d.ts.map +1 -0
  11. package/dist/analysis/constant-caller-gate.js +298 -0
  12. package/dist/analysis/constant-caller-gate.js.map +1 -0
  13. package/dist/analysis/constant-propagation/evaluator.d.ts.map +1 -1
  14. package/dist/analysis/constant-propagation/evaluator.js +3 -1
  15. package/dist/analysis/constant-propagation/evaluator.js.map +1 -1
  16. package/dist/analysis/constant-propagation/patterns.d.ts +19 -0
  17. package/dist/analysis/constant-propagation/patterns.d.ts.map +1 -1
  18. package/dist/analysis/constant-propagation/patterns.js +33 -0
  19. package/dist/analysis/constant-propagation/patterns.js.map +1 -1
  20. package/dist/analysis/constant-propagation/propagator.d.ts +59 -0
  21. package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
  22. package/dist/analysis/constant-propagation/propagator.js +396 -49
  23. package/dist/analysis/constant-propagation/propagator.js.map +1 -1
  24. package/dist/analysis/constant-propagation/types.d.ts +9 -0
  25. package/dist/analysis/constant-propagation/types.d.ts.map +1 -1
  26. package/dist/analysis/findings.d.ts.map +1 -1
  27. package/dist/analysis/findings.js +149 -4
  28. package/dist/analysis/findings.js.map +1 -1
  29. package/dist/analysis/go-strings-replacer.d.ts +21 -0
  30. package/dist/analysis/go-strings-replacer.d.ts.map +1 -0
  31. package/dist/analysis/go-strings-replacer.js +145 -0
  32. package/dist/analysis/go-strings-replacer.js.map +1 -0
  33. package/dist/analysis/interprocedural.d.ts.map +1 -1
  34. package/dist/analysis/interprocedural.js +148 -0
  35. package/dist/analysis/interprocedural.js.map +1 -1
  36. package/dist/analysis/java-array-element-keys.d.ts +14 -0
  37. package/dist/analysis/java-array-element-keys.d.ts.map +1 -0
  38. package/dist/analysis/java-array-element-keys.js +36 -0
  39. package/dist/analysis/java-array-element-keys.js.map +1 -0
  40. package/dist/analysis/java-canonical-prefix.d.ts +42 -0
  41. package/dist/analysis/java-canonical-prefix.d.ts.map +1 -0
  42. package/dist/analysis/java-canonical-prefix.js +145 -0
  43. package/dist/analysis/java-canonical-prefix.js.map +1 -0
  44. package/dist/analysis/java-crlf-strip.d.ts +30 -0
  45. package/dist/analysis/java-crlf-strip.d.ts.map +1 -0
  46. package/dist/analysis/java-crlf-strip.js +195 -0
  47. package/dist/analysis/java-crlf-strip.js.map +1 -0
  48. package/dist/analysis/js-crlf-strip.d.ts +26 -0
  49. package/dist/analysis/js-crlf-strip.d.ts.map +1 -0
  50. package/dist/analysis/js-crlf-strip.js +244 -0
  51. package/dist/analysis/js-crlf-strip.js.map +1 -0
  52. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts +2 -0
  53. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts.map +1 -1
  54. package/dist/analysis/passes/cleartext-credential-transport-pass.js +64 -12
  55. package/dist/analysis/passes/cleartext-credential-transport-pass.js.map +1 -1
  56. package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
  57. package/dist/analysis/passes/cross-file-pass.js +43 -0
  58. package/dist/analysis/passes/cross-file-pass.js.map +1 -1
  59. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +12 -0
  60. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  61. package/dist/analysis/passes/csharp-sanitizer-credit.js +127 -12
  62. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
  63. package/dist/analysis/passes/interprocedural-pass.d.ts +28 -0
  64. package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
  65. package/dist/analysis/passes/interprocedural-pass.js +20 -1
  66. package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
  67. package/dist/analysis/passes/language-sources-pass.d.ts +76 -2
  68. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  69. package/dist/analysis/passes/language-sources-pass.js +2045 -156
  70. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  71. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts +8 -0
  72. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
  73. package/dist/analysis/passes/param-source-flow-gate-pass.js +214 -6
  74. package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
  75. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts +6 -0
  76. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts.map +1 -1
  77. package/dist/analysis/passes/plaintext-password-storage-pass.js +160 -0
  78. package/dist/analysis/passes/plaintext-password-storage-pass.js.map +1 -1
  79. package/dist/analysis/passes/scan-secrets-pass.d.ts +1 -1
  80. package/dist/analysis/passes/scan-secrets-pass.d.ts.map +1 -1
  81. package/dist/analysis/passes/scan-secrets-pass.js +7 -1
  82. package/dist/analysis/passes/scan-secrets-pass.js.map +1 -1
  83. package/dist/analysis/passes/sink-filter-pass.d.ts +1 -1
  84. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  85. package/dist/analysis/passes/sink-filter-pass.js +509 -27
  86. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  87. package/dist/analysis/passes/taint-matcher-pass.d.ts.map +1 -1
  88. package/dist/analysis/passes/taint-matcher-pass.js +2 -2
  89. package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
  90. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  91. package/dist/analysis/passes/taint-propagation-pass.js +1606 -60
  92. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  93. package/dist/analysis/passes/weak-hash-pass.d.ts +6 -0
  94. package/dist/analysis/passes/weak-hash-pass.d.ts.map +1 -1
  95. package/dist/analysis/passes/weak-hash-pass.js +191 -4
  96. package/dist/analysis/passes/weak-hash-pass.js.map +1 -1
  97. package/dist/analysis/passes/weak-random-pass.d.ts +5 -0
  98. package/dist/analysis/passes/weak-random-pass.d.ts.map +1 -1
  99. package/dist/analysis/passes/weak-random-pass.js +30 -0
  100. package/dist/analysis/passes/weak-random-pass.js.map +1 -1
  101. package/dist/analysis/project-callee-escape-gate.d.ts +40 -0
  102. package/dist/analysis/project-callee-escape-gate.d.ts.map +1 -0
  103. package/dist/analysis/project-callee-escape-gate.js +243 -0
  104. package/dist/analysis/project-callee-escape-gate.js.map +1 -0
  105. package/dist/analysis/rust-literal-guard-rebind.d.ts +34 -0
  106. package/dist/analysis/rust-literal-guard-rebind.d.ts.map +1 -0
  107. package/dist/analysis/rust-literal-guard-rebind.js +197 -0
  108. package/dist/analysis/rust-literal-guard-rebind.js.map +1 -0
  109. package/dist/analysis/rust-literal-guard.d.ts +33 -0
  110. package/dist/analysis/rust-literal-guard.d.ts.map +1 -0
  111. package/dist/analysis/rust-literal-guard.js +422 -0
  112. package/dist/analysis/rust-literal-guard.js.map +1 -0
  113. package/dist/analysis/scalar-derivation.d.ts +33 -0
  114. package/dist/analysis/scalar-derivation.d.ts.map +1 -0
  115. package/dist/analysis/scalar-derivation.js +368 -0
  116. package/dist/analysis/scalar-derivation.js.map +1 -0
  117. package/dist/analysis/taint-matcher.d.ts +2 -2
  118. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  119. package/dist/analysis/taint-matcher.js +695 -34
  120. package/dist/analysis/taint-matcher.js.map +1 -1
  121. package/dist/analysis/taint-propagation.d.ts +26 -1
  122. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  123. package/dist/analysis/taint-propagation.js +375 -24
  124. package/dist/analysis/taint-propagation.js.map +1 -1
  125. package/dist/analyzer.d.ts +16 -2
  126. package/dist/analyzer.d.ts.map +1 -1
  127. package/dist/analyzer.js +19 -2
  128. package/dist/analyzer.js.map +1 -1
  129. package/dist/browser/circle-ir.js +25539 -19200
  130. package/dist/core/circle-ir-core.cjs +3945 -2126
  131. package/dist/core/circle-ir-core.js +3945 -2126
  132. package/dist/core/extractors/calls.d.ts +12 -1
  133. package/dist/core/extractors/calls.d.ts.map +1 -1
  134. package/dist/core/extractors/calls.js +332 -22
  135. package/dist/core/extractors/calls.js.map +1 -1
  136. package/dist/core/extractors/dfg.js +336 -10
  137. package/dist/core/extractors/dfg.js.map +1 -1
  138. package/dist/core/extractors/types.d.ts +5 -1
  139. package/dist/core/extractors/types.d.ts.map +1 -1
  140. package/dist/core/extractors/types.js +62 -0
  141. package/dist/core/extractors/types.js.map +1 -1
  142. package/dist/graph/analysis-pass.d.ts +6 -1
  143. package/dist/graph/analysis-pass.d.ts.map +1 -1
  144. package/dist/graph/analysis-pass.js +2 -1
  145. package/dist/graph/analysis-pass.js.map +1 -1
  146. package/dist/graph/code-graph.d.ts +9 -0
  147. package/dist/graph/code-graph.d.ts.map +1 -1
  148. package/dist/graph/code-graph.js +11 -0
  149. package/dist/graph/code-graph.js.map +1 -1
  150. package/dist/navigation/navigation-index.d.ts +134 -14
  151. package/dist/navigation/navigation-index.d.ts.map +1 -1
  152. package/dist/navigation/navigation-index.js +1158 -104
  153. package/dist/navigation/navigation-index.js.map +1 -1
  154. package/dist/navigation/types.d.ts +59 -2
  155. package/dist/navigation/types.d.ts.map +1 -1
  156. package/dist/resolution/cross-file.d.ts +16 -0
  157. package/dist/resolution/cross-file.d.ts.map +1 -1
  158. package/dist/resolution/cross-file.js +110 -42
  159. package/dist/resolution/cross-file.js.map +1 -1
  160. package/dist/resolution/type-hierarchy.d.ts +3 -0
  161. package/dist/resolution/type-hierarchy.d.ts.map +1 -1
  162. package/dist/resolution/type-hierarchy.js +45 -1
  163. package/dist/resolution/type-hierarchy.js.map +1 -1
  164. package/dist/types/index.d.ts +33 -0
  165. package/dist/types/index.d.ts.map +1 -1
  166. package/dist/types/index.js.map +1 -1
  167. package/dist/wasm/web-tree-sitter.wasm +0 -0
  168. package/package.json +3 -3
@@ -10,12 +10,16 @@
10
10
  *
11
11
  * Depends on: sink-filter, constant-propagation
12
12
  */
13
- import { identifierOutsideSanitizerCalls, propagateTaint } from '../taint-propagation.js';
13
+ import { findJavaSpringReturnXSSSinks } from './language-sources-pass.js';
14
+ import { expressionFullyWrappedBySanitizer, identifierOutsideSanitizerCalls, identifierOutsideSanitizerCallsInText, propagateTaint } from '../taint-propagation.js';
14
15
  import { isFalsePositive, isCorrelatedPredicateFP } from '../constant-propagation.js';
15
- 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';
16
18
  import { canSourceReachSink, sourceSemanticsAllowed } from '../findings.js';
17
19
  import { walkBackwardDefs } from '../dfg-walk.js';
18
20
  import { sanitizerCoversSink } from '../sanitizer-index.js';
21
+ import { HTML_CONTEXT_ENCODER_METHODS } from '../constant-propagation/patterns.js';
22
+ import { javaCanonicalStartsWithAllBare } from '../java-canonical-prefix.js';
19
23
  /**
20
24
  * cognium-dev#328 — the const-prop per-step veto, with two scoped exemptions.
21
25
  *
@@ -40,7 +44,352 @@ import { sanitizerCoversSink } from '../sanitizer-index.js';
40
44
  * code, resolved constant) are unaffected.
41
45
  */
42
46
  const CONTAINER_READ_SOURCES = new Set(['plugin_param', 'config_param']);
43
- function makeConstPropVeto(constProp, graph, sources, code, language) {
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
+ }
135
+ /**
136
+ * #515 — find the real source a Python derived variable actually references.
137
+ * Reads the derivation's right-hand side at its origin line (f-string braces
138
+ * kept, plain quotes and trailing `#…` comments dropped), picks the real
139
+ * source variables named there (earliest line wins when several), and follows
140
+ * names that are themselves derived — including a later assignment of a name
141
+ * that was also a real source. Returns undefined when nothing can be
142
+ * resolved, so the caller keeps its earliest-source fallback.
143
+ */
144
+ /** Drop a trailing `#…` comment, leaving `#` inside string literals alone. */
145
+ function stripPythonTrailingComment(text) {
146
+ let i = 0;
147
+ while (i < text.length) {
148
+ const ch = text[i];
149
+ if (ch === '"' || ch === "'") {
150
+ const triple = text.startsWith(ch.repeat(3), i);
151
+ const delim = triple ? ch.repeat(3) : ch;
152
+ i += delim.length;
153
+ while (i < text.length && !text.startsWith(delim, i)) {
154
+ if (text[i] === '\\') {
155
+ i += 2;
156
+ continue;
157
+ }
158
+ i++;
159
+ }
160
+ i += delim.length;
161
+ continue;
162
+ }
163
+ if (ch === '#')
164
+ return text.slice(0, i);
165
+ i++;
166
+ }
167
+ return text;
168
+ }
169
+ /**
170
+ * Keep only the f-string interpolation expression: cut before a top-level
171
+ * `!conversion` or `:format_spec` (Python replacement_field grammar). A `!`
172
+ * followed by `=` is the `!=` operator and is not a cut point. Depth
173
+ * tracks `()[]{}`; string literals are skipped so a `:` inside a subscript
174
+ * key does not truncate the expression.
175
+ */
176
+ function pythonFStringNestedFields(body) {
177
+ // Format-spec / conversion tails only: a `{` here always opens a nested
178
+ // replacement field (Python 3.12+). Do NOT treat `{{` / `}}` as literal-brace
179
+ // escapes — `f"{label:{{fmt}}}"` parses as a nested FormattedValue whose
180
+ // expression is the set `{fmt}`. The top-level f-string skip stays in
181
+ // braceExprs so a literal `f"{{fmt}}"` still does not credit `fmt`. Each
182
+ // nested `{…}` is itself an expression (and may carry its own conversion /
183
+ // format-spec), so recurse through pythonFStringExprOnly.
184
+ let acc = '';
185
+ for (let j = 0; j < body.length;) {
186
+ if (body[j] === '{') {
187
+ j++;
188
+ let depth = 1;
189
+ let inner = '';
190
+ while (j < body.length && depth > 0) {
191
+ if (body[j] === '{')
192
+ depth++;
193
+ else if (body[j] === '}') {
194
+ depth--;
195
+ if (depth === 0)
196
+ break;
197
+ }
198
+ inner += body[j];
199
+ j++;
200
+ }
201
+ j++;
202
+ acc += ' ' + pythonFStringExprOnly(inner) + ' ';
203
+ continue;
204
+ }
205
+ j++;
206
+ }
207
+ return acc;
208
+ }
209
+ /**
210
+ * Keep the expression part of an f-string replacement field; drop literal
211
+ * conversion / format-spec text, but retain nested `{…}` fields from the
212
+ * discarded tail (format specs may contain nested replacement fields).
213
+ */
214
+ function pythonFStringExprOnly(inner) {
215
+ let depth = 0;
216
+ let i = 0;
217
+ while (i < inner.length) {
218
+ const ch = inner[i];
219
+ if (ch === '"' || ch === "'") {
220
+ const triple = inner.startsWith(ch.repeat(3), i);
221
+ const delim = triple ? ch.repeat(3) : ch;
222
+ i += delim.length;
223
+ while (i < inner.length && !inner.startsWith(delim, i)) {
224
+ if (inner[i] === '\\') {
225
+ i += 2;
226
+ continue;
227
+ }
228
+ i++;
229
+ }
230
+ i += delim.length;
231
+ continue;
232
+ }
233
+ if (ch === '(' || ch === '[' || ch === '{') {
234
+ depth++;
235
+ i++;
236
+ continue;
237
+ }
238
+ if (ch === ')' || ch === ']' || ch === '}') {
239
+ depth--;
240
+ i++;
241
+ continue;
242
+ }
243
+ if (depth === 0 && ch === '!') {
244
+ // `!=` is the comparison operator, not a `!conversion`; keep scanning.
245
+ let j = i + 1;
246
+ while (j < inner.length && (inner[j] === ' ' || inner[j] === '\t'))
247
+ j++;
248
+ if (inner[j] === '=') {
249
+ i = j + 1;
250
+ continue;
251
+ }
252
+ const expr = inner.slice(0, i);
253
+ const nested = pythonFStringNestedFields(inner.slice(i + 1));
254
+ return nested ? expr + nested : expr;
255
+ }
256
+ if (depth === 0 && ch === ':') {
257
+ const expr = inner.slice(0, i);
258
+ const nested = pythonFStringNestedFields(inner.slice(i + 1));
259
+ return nested ? expr + nested : expr;
260
+ }
261
+ i++;
262
+ }
263
+ return inner;
264
+ }
265
+ function resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines, visiting) {
266
+ if (visiting.has(varName))
267
+ return undefined;
268
+ visiting.add(varName);
269
+ const originLine = derived.get(varName);
270
+ if (originLine === undefined)
271
+ return undefined;
272
+ const text = codeLines[originLine - 1];
273
+ if (!text)
274
+ return undefined;
275
+ const eq = text.search(/(?<![=!<>+\-*/%&|^:])=(?!=)/);
276
+ if (eq < 0)
277
+ return undefined;
278
+ // Drop a real trailing `#…` comment first (outside strings only), then lift
279
+ // f-string `{…}` names. Format specs / conversion flags are cut inside
280
+ // braceExprs so a `#` in `{width:#04x}` cannot eat later names, and `!s`
281
+ // cannot become a scanned identifier; nested `{…}` fields in the format
282
+ // spec (e.g. `{label:{fmt}}`) are kept.
283
+ const rhs = maskStringLiteralsForTokenScan(stripPythonTrailingComment(text.slice(eq + 1)));
284
+ let best;
285
+ for (const m of rhs.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
286
+ const id = m[0];
287
+ if (id === varName)
288
+ continue;
289
+ const real = realSourceByVar.get(id);
290
+ const derivedLine = derived.get(id);
291
+ // A later assignment of the same name (recorded in `derived` after the
292
+ // real source line) is the value that flows — resolve it first. Fall
293
+ // back to the real source when that derivation does not resolve (e.g.
294
+ // `fmt = request.form.get("fmt")`, whose RHS names no source variable).
295
+ let cand;
296
+ if (derivedLine !== undefined && (!real || derivedLine > real.line)) {
297
+ cand = resolvePythonDerivedAnchor(id, derived, realSourceByVar, codeLines, visiting);
298
+ }
299
+ if (!cand)
300
+ cand = real;
301
+ if (cand && (!best || cand.line < best.line))
302
+ best = cand;
303
+ }
304
+ return best;
305
+ }
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) {
44
393
  const lines = typeof code === 'string' ? code.split('\n') : [];
45
394
  const selfReassign = new Map();
46
395
  const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -72,12 +421,40 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
72
421
  const use = (graph.usesByLine.get(line) ?? []).find(u => u.variable === v && u.def_id != null);
73
422
  return use ? graph.defById.get(use.def_id) : undefined;
74
423
  };
75
- return (step, sourceLine, sinkLine) => {
76
- 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);
77
431
  if (!r.isFalsePositive)
78
432
  return false;
79
433
  if (r.reason !== 'variable_not_tainted')
80
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
+ }
81
458
  // cognium-dev#579: const-prop has no C# source model, so its tainted set is
82
459
  // empty for C# and "not tainted" there means "never looked", not "clean".
83
460
  // It still records a plain reassignment (`s = q;`) as a tracked symbol,
@@ -85,6 +462,43 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
85
462
  // code and resolved constants (the reasons above) still apply.
86
463
  if (language === 'csharp')
87
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
+ }
88
502
  const lo = Math.min(sourceLine, sinkLine);
89
503
  // Walk the reaching-def chain back from this step, stepping through
90
504
  // self-derived reassignments only. Reaching defs are per method, so a
@@ -106,8 +520,20 @@ function makeConstPropVeto(constProp, graph, sources, code, language) {
106
520
  // "untainted" can only mean it has no pattern for this source. Container
107
521
  // reads are excluded: const-prop tracks collection keys, and there its
108
522
  // verdict is informed (`bar = map.get("safeKey")` — 10 OWASP FP files).
109
- 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
+ }
110
535
  return false;
536
+ }
111
537
  // (A) the chain crossed at least one `x = f(x)` and ends inside the span.
112
538
  if (selfHops > 0 && d.line >= lo)
113
539
  return false;
@@ -123,6 +549,23 @@ export class TaintPropagationPass {
123
549
  const constProp = ctx.getResult('constant-propagation');
124
550
  const sinkFilter = ctx.getResult('sink-filter');
125
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
+ }
126
569
  if (sinks.length === 0) {
127
570
  return { flows: [] };
128
571
  }
@@ -134,16 +577,16 @@ export class TaintPropagationPass {
134
577
  return { flows: [] };
135
578
  }
136
579
  // DFG-based taint propagation
137
- const propagationResult = propagateTaint(graph, sources, sinks, sanitizers);
580
+ const propagationResult = propagateTaint(graph, sources, sinks, sanitizers, undefined, ctx.code);
138
581
  // cognium-dev#328 — scoped exemption from const-prop's `variable_not_tainted`
139
582
  // veto for self-derived reassignment (`x = f(x)`, `x += y`), the #287 shape.
140
- const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language);
583
+ const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language, sanitizers);
141
584
  // Filter flows: eliminate dead-code paths and constant-propagation FPs
142
585
  const verifiedFlows = propagationResult.flows.filter(flow => {
143
586
  if (constProp.unreachableLines.has(flow.sink.line))
144
587
  return false;
145
588
  for (const step of flow.path) {
146
- if (isVetoed(step, flow.source.line, flow.sink.line))
589
+ if (isVetoed(step, flow.source.line, flow.sink.line, flow.sink.type))
147
590
  return false;
148
591
  }
149
592
  if (isCorrelatedPredicateFP(constProp, flow))
@@ -186,6 +629,38 @@ export class TaintPropagationPass {
186
629
  flows.push(f);
187
630
  return true;
188
631
  };
632
+ // Supplement: Spring MVC return-as-body XSS flows — cognium-dev#566. The
633
+ // sink `LanguageSourcesPass` synthesises at the `return` has no method or
634
+ // argument, so no argument matcher reaches it; the detector already knows
635
+ // both ends, so the flow is emitted here (as for Python #368 below).
636
+ if (ctx.language === 'java' && typeof ctx.code === 'string') {
637
+ const xssSanitizerLines = sanitizers
638
+ .filter(sa => sa.sanitizes.includes('xss'))
639
+ .map(sa => sa.line);
640
+ for (const rx of findJavaSpringReturnXSSSinks(ctx.code, types, sources, xssSanitizerLines)) {
641
+ if (constProp.unreachableLines.has(rx.sinkLine))
642
+ continue;
643
+ const sink = sinks.find(sk => sk.line === rx.sinkLine && sk.type === 'xss');
644
+ if (!sink)
645
+ continue;
646
+ const atLine = sources.filter(sc => sc.line === rx.sourceLine);
647
+ const src = atLine.find(sc => sc.type !== 'interprocedural_param') ?? atLine[0];
648
+ if (!src)
649
+ continue;
650
+ pushIfNew({
651
+ source_line: src.line,
652
+ sink_line: rx.sinkLine,
653
+ source_type: src.type,
654
+ sink_type: 'xss',
655
+ path: [
656
+ { variable: src.variable ?? 'response', line: src.line, type: 'source' },
657
+ { variable: src.variable ?? 'response', line: rx.sinkLine, type: 'sink' },
658
+ ],
659
+ confidence: (src.confidence ?? 1) * (sink.confidence ?? 1),
660
+ sanitized: false,
661
+ });
662
+ }
663
+ }
189
664
  // Supplement: Python trust-boundary (CWE-501) flows — cognium-dev#363.
190
665
  //
191
666
  // `LanguageSourcesPass` detects `session[<tainted>] = …` and synthesises a
@@ -293,6 +768,42 @@ export class TaintPropagationPass {
293
768
  }
294
769
  }
295
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
+ }
296
807
  // Supplement: array element flows
297
808
  // Sprint 82 (#189) — dedup keys on (source_line, sink_line, sink_type) so
298
809
  // distinct sink-types at the same call site (e.g. `res.redirect` registered
@@ -389,19 +900,47 @@ export class TaintPropagationPass {
389
900
  // explicitly marked sanitized by a guard (e.g. regex-allowlist).
390
901
  // Applied to ALL flow generators (DFG-built and the four supplements)
391
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.
392
907
  const sanitizedNames = constProp.sanitizedVars;
908
+ const sanitizedUncovered = constProp.sanitizedVarUncovered;
393
909
  let finalFlows = sanitizedNames.size === 0 ? flows : flows.filter(f => {
394
910
  if (f.path.length === 0)
395
911
  return true;
396
912
  const sourceVar = f.path[0].variable;
397
913
  if (!sourceVar)
398
914
  return true;
399
- if (sanitizedNames.has(sourceVar))
400
- 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;
401
923
  for (const s of sanitizedNames) {
402
- 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))
403
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;
404
941
  }
942
+ if (sanitizedNames.has(sourceVar) && coversFlow(sourceVar))
943
+ return false;
405
944
  return true;
406
945
  });
407
946
  // Sprint 24 (cognium-dev #102) — uniform line-keyed sanitizer
@@ -421,6 +960,25 @@ export class TaintPropagationPass {
421
960
  arr.push(san);
422
961
  sanitizersByLine.set(san.line, arr);
423
962
  }
963
+ const codeLines = typeof ctx.code === 'string' ? ctx.code.split('\n') : undefined;
964
+ // True unless every def at `line` among `visited` is provably off this
965
+ // flow: not a variable on its path, and a complete backward walk from it
966
+ // never reaches `sourceLine`.
967
+ const sanitizedDefOnFlow = (visited, line, f) => {
968
+ let sawDef = false;
969
+ for (const id of visited) {
970
+ const def = graph.defById.get(id);
971
+ if (!def || def.line !== line)
972
+ continue;
973
+ sawDef = true;
974
+ if (f.path.some(step => step.variable === def.variable))
975
+ return true;
976
+ const back = walkBackwardDefs(id, graph.chainsByToDef, graph.defById, { maxHops: 32 });
977
+ if (back.hopCapReached || back.lines.has(f.source_line))
978
+ return true;
979
+ }
980
+ return !sawDef;
981
+ };
424
982
  finalFlows = finalFlows.filter(f => {
425
983
  // Two-tier filter:
426
984
  // - `external_taint_escape` (synthetic CWE-668 fallback): no
@@ -447,6 +1005,20 @@ export class TaintPropagationPass {
447
1005
  }
448
1006
  return true;
449
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
+ }
450
1022
  const sansAtSink = sanitizersByLine.get(f.sink_line);
451
1023
  if (sansAtSink && sansAtSink.length > 0) {
452
1024
  for (const san of sansAtSink) {
@@ -475,6 +1047,8 @@ export class TaintPropagationPass {
475
1047
  // in taint-propagation.ts checkSanitized. Doesn't over-suppress
476
1048
  // bare `shlex.quote(host)` on unrelated non-sink lines because
477
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;
478
1052
  const usesAtSink = graph.usesByLine.get(f.sink_line);
479
1053
  if (!usesAtSink || usesAtSink.length === 0)
480
1054
  return true;
@@ -483,12 +1057,29 @@ export class TaintPropagationPass {
483
1057
  : undefined;
484
1058
  if (!sinkVar)
485
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
+ };
486
1076
  for (const use of usesAtSink) {
487
1077
  if (use.variable !== sinkVar)
488
1078
  continue;
489
1079
  if (use.def_id === null || use.def_id === undefined)
490
1080
  continue;
491
- 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 });
492
1083
  for (const line of walk.lines) {
493
1084
  if (line === f.sink_line)
494
1085
  continue; // already checked above
@@ -507,10 +1098,21 @@ export class TaintPropagationPass {
507
1098
  const sansAtLine = sanitizersByLine.get(line);
508
1099
  if (!sansAtLine || sansAtLine.length === 0)
509
1100
  continue;
1101
+ // cognium-dev#348: the walk visits every contributor of the sink
1102
+ // value. In `clean = a.replaceAll(..); msg = clean + b; log(msg)`
1103
+ // the strip on `a` must not credit the flow from `b`. Skip a
1104
+ // sanitized def that does not derive from this flow's source.
1105
+ if (line > f.source_line && !sanitizedDefOnFlow(walk.visited, line, f))
1106
+ continue;
510
1107
  for (const san of sansAtLine) {
511
- if (sanitizerCoversSink(san, f.sink_type)) {
512
- return false;
513
- }
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;
514
1116
  }
515
1117
  }
516
1118
  }
@@ -665,6 +1267,28 @@ export class TaintPropagationPass {
665
1267
  });
666
1268
  }
667
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
+ }
668
1292
  return { flows: finalFlows };
669
1293
  }
670
1294
  }
@@ -829,6 +1453,10 @@ function isInJavaSanitizedMethod(code, types, sinkLine, sinkType) {
829
1453
  return false;
830
1454
  if (!/\.startsWith\s*\([^)]*getCanonicalPath/.test(body))
831
1455
  return false;
1456
+ // cognium-dev#618: `startsWith(parent.getCanonicalPath())` with no
1457
+ // trailing separator is the prefix-confusion bypass, not a guard.
1458
+ if (javaCanonicalStartsWithAllBare(body))
1459
+ return false;
832
1460
  if (!/\bthrow\s+new\b/.test(body))
833
1461
  return false;
834
1462
  return true;
@@ -992,8 +1620,118 @@ function detectGoPackageGlobalFlows(code, _calls, sources, sinks, unreachableLin
992
1620
  }
993
1621
  return flows;
994
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
+ }
995
1729
  function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLines, code, types) {
996
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);
997
1735
  const callsByLine = new Map();
998
1736
  for (const call of calls) {
999
1737
  const existing = callsByLine.get(call.location.line) ?? [];
@@ -1014,7 +1752,16 @@ function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLi
1014
1752
  if (arg.variable) {
1015
1753
  const varName = arg.variable;
1016
1754
  const scopedName = call.in_method ? `${call.in_method}:${varName}` : varName;
1017
- 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)) {
1018
1765
  // Sprint 13 #70 — pick a source in the same method scope as the
1019
1766
  // sink, not blanket sources[0]. The varName-based match is
1020
1767
  // primary; method-scope is the tiebreaker; sources[0] is the
@@ -1132,23 +1879,22 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
1132
1879
  !sink.argPositions.includes(arg.position)) {
1133
1880
  continue;
1134
1881
  }
1135
- const arrayAccessMatch = arg.expression?.match(/^(\w+)\[(\d+|[^[\]]+)\]$/);
1136
- if (arrayAccessMatch) {
1137
- const arrayName = arrayAccessMatch[1];
1138
- const indexStr = arrayAccessMatch[2];
1882
+ const arrayAccess = parseJavaArrayAccessExpression(arg.expression);
1883
+ if (arrayAccess) {
1884
+ const { base, arrayName, indexKey, displayVar } = arrayAccess;
1139
1885
  const taintedIndices = taintedArrayElements.get(arrayName);
1140
1886
  if (taintedIndices) {
1141
- const isTainted = taintedIndices.has(indexStr) || taintedIndices.has('*');
1887
+ const isTainted = taintedIndices.has(indexKey) || taintedIndices.has('*');
1142
1888
  if (isTainted) {
1143
1889
  // Sprint 13 #70 — method-scoped source picker (see detectCollectionFlows).
1144
- 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);
1145
1891
  if (source) {
1146
1892
  flows.push({
1147
1893
  source_line: source.line, sink_line: sink.line,
1148
1894
  source_type: source.type, sink_type: sink.type,
1149
1895
  path: [
1150
- { variable: arrayName, line: source.line, type: 'source' },
1151
- { variable: `${arrayName}[${indexStr}]`, line: sink.line, type: 'sink' },
1896
+ { variable: base, line: source.line, type: 'source' },
1897
+ { variable: displayVar, line: sink.line, type: 'sink' },
1152
1898
  ],
1153
1899
  confidence: 0.85, sanitized: false,
1154
1900
  });
@@ -1161,6 +1907,20 @@ function detectArrayElementFlows(calls, sources, sinks, taintedArrayElements, un
1161
1907
  }
1162
1908
  return flows;
1163
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
+ }
1164
1924
  function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines, tainted, code) {
1165
1925
  const flows = [];
1166
1926
  const paramSourcesByMethod = new Map();
@@ -1199,14 +1959,17 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1199
1959
  if (!methodParamSources)
1200
1960
  continue;
1201
1961
  for (const arg of call.arguments) {
1202
- if (arg.variable) {
1203
- // Skip arguments that are not in the sink's dangerous positions.
1204
- // E.g., execSync(cmd, { cwd: path }) — only arg 0 is a command injection sink.
1205
- if (sink.argPositions && sink.argPositions.length > 0 &&
1206
- !sink.argPositions.includes(arg.position)) {
1207
- continue;
1208
- }
1209
- 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);
1210
1973
  if (paramSource) {
1211
1974
  // Sprint 82 (#189) — sink-type-aware dedup so a sink registered
1212
1975
  // under multiple types at the same call site (e.g. `redirect` as
@@ -1214,7 +1977,7 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1214
1977
  const exists = flows.some(f => f.source_line === paramSource.line && f.sink_line === sink.line && f.sink_type === sink.type);
1215
1978
  if (!exists) {
1216
1979
  if (typeof code === 'string' &&
1217
- isReassignedToLiteralBetween(code, arg.variable, paramSource.line, sink.line, unreachableLines)) {
1980
+ isReassignedToLiteralBetween(code, varName, paramSource.line, sink.line, unreachableLines)) {
1218
1981
  continue;
1219
1982
  }
1220
1983
  // Note: DFG-flow filter handles sanitizer-guard suppression
@@ -1223,8 +1986,8 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1223
1986
  source_line: paramSource.line, sink_line: sink.line,
1224
1987
  source_type: paramSource.type, sink_type: sink.type,
1225
1988
  path: [
1226
- { variable: arg.variable, line: paramSource.line, type: 'source' },
1227
- { variable: arg.variable, line: sink.line, type: 'sink' },
1989
+ { variable: varName, line: paramSource.line, type: 'source' },
1990
+ { variable: varName, line: sink.line, type: 'sink' },
1228
1991
  ],
1229
1992
  confidence: 0.75, sanitized: false,
1230
1993
  });
@@ -1390,7 +2153,18 @@ function csharpMethodScopedAliases(code, seeds, methods, fieldNames) {
1390
2153
  }
1391
2154
  continue;
1392
2155
  }
1393
- 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
+ }
1394
2168
  if (!d)
1395
2169
  continue;
1396
2170
  const [, lhs, rhs] = d;
@@ -1425,6 +2199,211 @@ function splitLinesCached(code) {
1425
2199
  _reassignSplitLines = code.split('\n');
1426
2200
  return _reassignSplitLines;
1427
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
+ }
1428
2407
  function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreachableLines) {
1429
2408
  if (!variable || sinkLine - srcLine < 2)
1430
2409
  return false;
@@ -1455,17 +2434,33 @@ function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreach
1455
2434
  // The fall-through `break;` is optional. Used by cognium-dev #101
1456
2435
  // to suppress the switch→constant FP corpus.
1457
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
+ };
1458
2451
  for (let i = lo; i < hi; i++) {
1459
2452
  const line = lines[i];
1460
2453
  if (!line)
1461
2454
  continue;
1462
2455
  if (reGuarded.test(line))
1463
2456
  return true;
1464
- if (!reNaked.test(line) && !reSwitchCase.test(line))
2457
+ if (!lineHasNakedLiteralReassign(line))
1465
2458
  continue;
1466
2459
  // A literal assigned in one case of a switch only overwrites the value
1467
2460
  // when no case gives the variable anything else. `case 'A': bar = param;`
1468
2461
  // next to `default: bar = "safe";` leaves `bar` tainted on the 'A' path.
2462
+ if (javaCatchLiteralHasTaintedTryArm(lines, i, variable, strLit, unreachableLines))
2463
+ continue;
1469
2464
  if (siblingBranchAssignsNonLiteral(lines, i, variable, strLit, unreachableLines))
1470
2465
  continue;
1471
2466
  return true;
@@ -1512,6 +2507,46 @@ function matchingClose(lines, line, col) {
1512
2507
  }
1513
2508
  return null;
1514
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
+ }
1515
2550
  /**
1516
2551
  * True when line `idx` assigns `variable` in one arm of a `switch` or one
1517
2552
  * branch of an `if`/`else` chain, and a sibling arm or branch assigns it a
@@ -1534,7 +2569,7 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1534
2569
  block = enclosingBrace(lines, block.line, block.col);
1535
2570
  }
1536
2571
  if (!block)
1537
- return false;
2572
+ return pythonSiblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines);
1538
2573
  let first = block.headerLine;
1539
2574
  let last;
1540
2575
  const end = matchingClose(lines, block.line, block.col);
@@ -1594,14 +2629,103 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1594
2629
  last = next.line;
1595
2630
  }
1596
2631
  }
2632
+ else if (/\bcatch\b/.test(block.header)) {
2633
+ return javaCatchLiteralHasTaintedTryArm(lines, idx, variable, strLit, unreachableLines);
2634
+ }
1597
2635
  else {
1598
2636
  return false;
1599
2637
  }
1600
2638
  const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
1601
2639
  const literalOnly = new RegExp(`^${strLit}$`);
2640
+ // For an if/else chain the literal's own arm is skipped: an earlier
2641
+ // assignment in that same arm (`if (a) { if (b) x = input; x = "safe"; }`) is
2642
+ // overwritten by the literal, it is not a sibling branch. A switch body shares
2643
+ // one brace, so every line of it stays a sibling.
2644
+ const isSwitch = /\bswitch\b/.test(block.header);
2645
+ const ownStart = block.line;
2646
+ const ownEnd = end.line;
1602
2647
  for (let i = first; i <= last; i++) {
1603
2648
  if (i === idx || unreachableLines?.has(i + 1))
1604
2649
  continue;
2650
+ if (!isSwitch && i > ownStart && i < ownEnd)
2651
+ continue;
2652
+ const m = assign.exec(lines[i]);
2653
+ if (m && m[1] && !literalOnly.test(m[1]))
2654
+ return true;
2655
+ }
2656
+ return false;
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
1605
2729
  const m = assign.exec(lines[i]);
1606
2730
  if (m && m[1] && !literalOnly.test(m[1]))
1607
2731
  return true;
@@ -1692,7 +2816,7 @@ export function maskStringLiteralsForTokenScan(expr) {
1692
2816
  j++;
1693
2817
  }
1694
2818
  j++;
1695
- acc += ' ' + maskStringLiteralsForTokenScan(inner) + ' ';
2819
+ acc += ' ' + maskStringLiteralsForTokenScan(pythonFStringExprOnly(inner)) + ' ';
1696
2820
  continue;
1697
2821
  }
1698
2822
  j++;
@@ -1815,6 +2939,8 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1815
2939
  // is every Juliet C# file.
1816
2940
  const csFnRanges = methodRanges(['csharp']);
1817
2941
  const csFnStart = (line) => enclosingStart(csFnRanges, line);
2942
+ const jsFnRanges = methodRanges(['javascript', 'typescript']);
2943
+ const jsFnStart = (line) => enclosingStart(jsFnRanges, line);
1818
2944
  // cognium-dev#548 — names declared as class fields. A field is the one kind
1819
2945
  // of variable whose value legitimately crosses from one method to another, so
1820
2946
  // the per-method gate below must not apply to it.
@@ -1885,6 +3011,15 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1885
3011
  anchor = s;
1886
3012
  }
1887
3013
  }
3014
+ // #515: real (non-derived) sources by variable name, earliest first, and
3015
+ // the file's lines for reading each derivation's right-hand side.
3016
+ const realSourceByVar = new Map();
3017
+ for (const s of sourcesWithVar) {
3018
+ const prev = realSourceByVar.get(s.variable);
3019
+ if (!prev || s.line < prev.line)
3020
+ realSourceByVar.set(s.variable, s);
3021
+ }
3022
+ const codeLines = code.split('\n');
1888
3023
  for (const [varName, originLine] of derived) {
1889
3024
  if (!varName || existingVars.has(varName))
1890
3025
  continue;
@@ -1894,8 +3029,12 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1894
3029
  // `http_param` type — the for-loop / bare-inline patterns we want
1895
3030
  // to recover here are all web-request-derived in practice.
1896
3031
  if (hasRealSource && anchor) {
3032
+ // #515: anchor to the real source this derivation actually
3033
+ // references (through earlier derived vars); the earliest-source
3034
+ // anchor stays the fallback when none can be found.
3035
+ const referenced = resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines, new Set());
1897
3036
  sourcesWithVar.push({
1898
- ...anchor,
3037
+ ...(referenced ?? anchor),
1899
3038
  variable: varName,
1900
3039
  });
1901
3040
  }
@@ -2245,6 +3384,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2245
3384
  const aliases = csharpMethodScopedAliases(code, seeds, csMethods, csFieldNames);
2246
3385
  const fileAnchor = sourcesWithVar.reduce((a, sv) => (sv.line < a.line ? sv : a), sourcesWithVar[0]);
2247
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');
2248
3390
  for (const alias of aliases) {
2249
3391
  const fn = methodStartOf(alias.line);
2250
3392
  const isField = csFieldNames.has(alias.variable);
@@ -2257,14 +3399,34 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2257
3399
  // call), which also keeps it ahead of a sink in a method declared
2258
3400
  // earlier in the file than the method holding the source.
2259
3401
  const inMethod = sourcesWithVar.filter(sv => methodStartOf(sv.line) === fn && sv.line <= alias.line);
2260
- const from = !alias.carried && inMethod.length > 0
3402
+ let from = !alias.carried && inMethod.length > 0
2261
3403
  ? inMethod.reduce((a, sv) => (sv.line < a.line ? sv : a), inMethod[0])
2262
3404
  : fileAnchor;
2263
- 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 = {
2264
3420
  ...from,
2265
3421
  variable: alias.variable,
2266
3422
  ...(alias.carried || inMethod.length === 0 ? { line: alias.line } : {}),
2267
- });
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);
2268
3430
  }
2269
3431
  }
2270
3432
  else if ((language === 'java' || language === 'csharp') && typeof code === 'string' && sourcesWithVar.length > 0) {
@@ -2295,29 +3457,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2295
3457
  }
2296
3458
  return null;
2297
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
+ };
2298
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
+ }
2299
3479
  for (const [varName, derivedLine] of derived) {
2300
3480
  if (!varName || existingVars.has(varName))
2301
3481
  continue;
2302
3482
  const owner = methodAt(derivedLine);
3483
+ const fieldAlias = isDeclaredField(derivedLine, varName);
2303
3484
  let scopedAnchor = anchor;
2304
3485
  if (owner) {
2305
3486
  let best;
3487
+ const methodRealSourceByVar = new Map();
2306
3488
  for (const s of sourcesWithVar) {
2307
3489
  const sOwner = s.in_method ?? methodAt(s.line);
2308
3490
  if (sOwner !== owner)
2309
3491
  continue;
3492
+ const prev = methodRealSourceByVar.get(s.variable);
3493
+ if (!prev || s.line < prev.line)
3494
+ methodRealSourceByVar.set(s.variable, s);
2310
3495
  if (!best || s.line < best.line)
2311
3496
  best = s;
2312
3497
  }
2313
- if (best)
3498
+ const referenced = resolveJavaDerivedAnchor(varName, derived, methodRealSourceByVar, codeLines, new Set());
3499
+ if (referenced)
3500
+ scopedAnchor = referenced;
3501
+ else if (best)
2314
3502
  scopedAnchor = best;
2315
3503
  }
2316
- sourcesWithVar.push({
2317
- ...scopedAnchor,
2318
- variable: varName,
2319
- ...(owner ? { in_method: owner } : {}),
2320
- });
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
+ }
2321
3527
  existingVars.add(varName);
2322
3528
  }
2323
3529
  }
@@ -2331,7 +3537,217 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2331
3537
  // tainted var (at the CommandText line) so the arg-scan links the Execute sink.
2332
3538
  if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0) {
2333
3539
  const lines = code.split('\n');
2334
- 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
+ };
2335
3751
  // cognium-dev#548: seed per method, from sources in that method. The seed
2336
3752
  // used to be made once per file and matched by name, so `cmd.CommandText =
2337
3753
  // … + data` in `Bad()` tainted `cmd.ExecuteScalar()` in `GoodG2B()`, whose
@@ -2344,15 +3760,60 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2344
3760
  added = false;
2345
3761
  guard++;
2346
3762
  for (let i = 0; i < lines.length; i++) {
2347
- 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
+ }
2348
3798
  if (!m)
2349
3799
  continue;
2350
- 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;
2351
3805
  const key = `${obj}@${csFnStart(i + 1)}`;
2352
3806
  if (seeded.has(key))
2353
3807
  continue;
3808
+ if (isArguments && !argumentsReachShell(obj, i + 1))
3809
+ continue;
2354
3810
  const reachable = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1));
2355
- 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))
2356
3817
  continue;
2357
3818
  const from = reachable.reduce((a, sv) => (sv.line < a.line ? sv : a), reachable[0]);
2358
3819
  sourcesWithVar.push({ ...from, variable: obj, line: i + 1 });
@@ -2552,6 +4013,19 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2552
4013
  if (srcFn !== -1 && sinkFn !== -1 && srcFn !== sinkFn)
2553
4014
  continue;
2554
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
+ }
2555
4029
  // cognium-dev#530: a builder-seeded source must not escape its method.
2556
4030
  // The seed stands for "this StringBuilder now holds tainted text",
2557
4031
  // which is true only of the one object in the one method where the
@@ -2605,14 +4079,16 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2605
4079
  if (aliasSanitizedFor.get(source.variable)?.has(sink.type)) {
2606
4080
  break;
2607
4081
  }
2608
- // Sprint 9 #58.3 / #56: between source.line and sink.line, if
2609
- // the tainted variable is reassigned to a pure string literal
2610
- // (either naked `var = "lit"` or guarded by an allowlist check
2611
- // 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"` /
2612
4086
  // `if var not in ALLOWLIST: var = "lit"`), the original taint
2613
- // 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).
2614
4090
  if (typeof code === 'string' &&
2615
- isReassignedToLiteralBetween(code, source.variable, source.line, sink.line, unreachableLines)) {
4091
+ isReassignedToLiteralBetween(code, source.variable, source.literal_kill_line ?? source.line, sink.line, unreachableLines)) {
2616
4092
  break;
2617
4093
  }
2618
4094
  // Note: DFG-flow filter handles sanitizer-guard suppression
@@ -2635,6 +4111,73 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2635
4111
  }
2636
4112
  }
2637
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
+ };
2638
4181
  // cognium-dev #83: inline-source colocation.
2639
4182
  //
2640
4183
  // When a source expression is used INLINE as a sink argument — e.g.
@@ -2723,6 +4266,9 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2723
4266
  if (new RegExp(`\\b${sourceVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(rhs)) {
2724
4267
  continue;
2725
4268
  }
4269
+ if (boundInEarlierStatement(sinkCode, sourceVar, sink.method)) {
4270
+ continue;
4271
+ }
2726
4272
  }
2727
4273
  // Skip the degenerate `file_input` → `path_traversal` colocation
2728
4274
  // where the source and sink describe the SAME call (one being the