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
@@ -7,6 +7,7 @@
7
7
  import { CodeGraph } from '../graph/index.js';
8
8
  import { walkBackwardDefs } from './dfg-walk.js';
9
9
  import { sanitizerCoversSink } from './sanitizer-index.js';
10
+ import { HTML_CONTEXT_ENCODER_METHODS, HTML_CONTEXT_ENCODER_UNCOVERED_SINKS } from './constant-propagation/patterns.js';
10
11
  /**
11
12
  * Build a line → sanitizers index from a list of sanitizers.
12
13
  *
@@ -30,11 +31,19 @@ function buildSanitizersByLine(sanitizers) {
30
31
  * Accepts either a CodeGraph (preferred) or the legacy (dfg, calls, ...) signature
31
32
  * for backward compatibility with existing call sites and tests.
32
33
  */
34
+ /** Number of `${…}` interpolations in a JS/TS template-literal argument. */
35
+ function templateInterpolationCount(expression) {
36
+ if (!expression.trimStart().startsWith('`'))
37
+ return 0;
38
+ return (expression.match(/\$\{/g) ?? []).length;
39
+ }
33
40
  /** True when `+` appears outside string literals. */
34
41
  function concatOutsideStrings(expression) {
35
42
  return expression.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, ' ').includes('+');
36
43
  }
37
- export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanitizers, sanitizersArg) {
44
+ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanitizers, sanitizersArg,
45
+ /** Optional source text — used when DFG defs lack `expression` (Java locals). */
46
+ sourceCode) {
38
47
  let graph;
39
48
  let sources;
40
49
  let sinks;
@@ -63,6 +72,7 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
63
72
  const taintedVars = [];
64
73
  const flows = [];
65
74
  const javaConcat = graph.ir.meta.language === 'java';
75
+ const jsLike = graph.ir.meta.language === 'javascript' || graph.ir.meta.language === 'typescript';
66
76
  const reachableSinks = new Map();
67
77
  // Use pre-computed indexes from CodeGraph — no local map building needed
68
78
  const defsByLine = graph.defsByLine;
@@ -77,27 +87,51 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
77
87
  ? buildSanitizersByLine(sanitizers)
78
88
  : graph.sanitizersByLine;
79
89
  const defById = graph.defById;
90
+ const sourceLines = typeof sourceCode === 'string' ? sourceCode.split('\n') : [];
91
+ const lineText = (line) => {
92
+ const raw = sourceLines[line - 1];
93
+ return raw !== undefined ? raw : undefined;
94
+ };
80
95
  // Step 1: Identify initial tainted definitions (from sources)
81
- const rawInitialTaint = findInitialTaint(sources, callsByLine, defsByLine);
96
+ const rawInitialTaint = findInitialTaint(sources, callsByLine, defsByLine, graph.chainsByFromDef, jsLike, lineText);
82
97
  // Filter variables added via the "next-line" heuristic that are actually the
83
98
  // result of a sanitizer call. The source variable itself (tv.line ===
84
99
  // tv.sourceLine) is always tainted; next-line additions need an extra check.
85
- const initialTaint = rawInitialTaint.filter(tv => {
100
+ const filteredInitialTaint = rawInitialTaint.filter(tv => {
86
101
  if (tv.line === tv.sourceLine)
87
102
  return true;
103
+ if (graph.taintBarrierDefIds.has(tv.defId))
104
+ return false;
88
105
  const sanCheck = checkSanitized(tv.sourceLine, tv.line, tv.sourceType, sanitizersByLine);
89
106
  return !sanCheck.sanitized;
90
107
  });
108
+ // Propagation is a BFS that credits each definition to the first seed that
109
+ // reaches it. A bare function parameter (`interprocedural_param`) is only
110
+ // speculative evidence, and its line usually precedes the handler that
111
+ // reads real input, so seeding it first let it claim definitions that a
112
+ // real source also reaches: `const ident = async (x) => x; … v = await
113
+ // ident(req.query.u)` credited `v` to `x`, and the flow was then dropped
114
+ // as parameter-only. Seed real sources first so they win any shared
115
+ // definition; parameter-only reach is unchanged.
116
+ const initialTaint = [
117
+ ...filteredInitialTaint.filter(tv => tv.sourceType !== 'interprocedural_param'),
118
+ ...filteredInitialTaint.filter(tv => tv.sourceType === 'interprocedural_param'),
119
+ ];
91
120
  taintedVars.push(...initialTaint);
92
121
  // Step 2: Propagate taint through def-use chains
93
- const propagatedTaint = propagateThroughChains(initialTaint, graph.chainsByFromDef, defById, sanitizersByLine);
94
- taintedVars.push(...propagatedTaint);
122
+ const propagatedTaint = propagateThroughChains(initialTaint, graph.chainsByFromDef, defById, sanitizersByLine, graph.taintBarrierDefIds, lineText);
123
+ // #589: variables that survived only an HTML-encoder hop (`survivesOnlyFor`)
124
+ // are not general taint, so they stay out of the reported `taintedVars`; they
125
+ // still seed the sink check below, which lets them reach only the sink types
126
+ // the encoder does not clean (`trust_boundary`).
127
+ taintedVars.push(...propagatedTaint.filter(tv => !tv.survivesOnlyFor));
95
128
  // Combine all tainted definitions
96
129
  const allTaintedDefIds = new Set();
97
130
  const taintByDefId = new Map();
98
- for (const tv of taintedVars) {
131
+ for (const tv of [...taintedVars, ...propagatedTaint.filter(tv => tv.survivesOnlyFor)]) {
99
132
  allTaintedDefIds.add(tv.defId);
100
- taintByDefId.set(tv.defId, tv);
133
+ if (!yieldsToRealSource(taintByDefId.get(tv.defId), tv))
134
+ taintByDefId.set(tv.defId, tv);
101
135
  }
102
136
  // Step 3: Check which sinks are reachable from tainted variables
103
137
  for (const sink of sinks) {
@@ -125,7 +159,11 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
125
159
  // A constant prefix must not hide a later tainted operand
126
160
  // (cognium-dev#627).
127
161
  const expr = typeof arg.expression === 'string' ? arg.expression : '';
128
- const compoundConcat = javaConcat && !!arg.variable && expr !== arg.variable && concatOutsideStrings(expr);
162
+ // cognium-dev#563: a JS/TS template literal names its first
163
+ // interpolated identifier as `arg.variable`; a sanitized first value
164
+ // (`${n}` from `parseInt`) must not hide a raw `${to}` after it.
165
+ const compoundConcat = !!arg.variable && expr !== arg.variable && ((javaConcat && concatOutsideStrings(expr)) ||
166
+ (jsLike && templateInterpolationCount(expr) > 1));
129
167
  const candidateUses = arg.variable && !compoundConcat
130
168
  ? usesAtSink.filter(u => u.variable === arg.variable)
131
169
  : usesAtSink;
@@ -146,6 +184,10 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
146
184
  continue;
147
185
  }
148
186
  const taintInfo = taintByDefId.get(use.def_id);
187
+ // #589: taint that survived only an HTML-encoder hop is clean for
188
+ // every sink type except the ones it still reaches.
189
+ if (taintInfo?.survivesOnlyFor && !taintInfo.survivesOnlyFor.has(sink.type))
190
+ continue;
149
191
  if (taintInfo) {
150
192
  // Check if sanitized.
151
193
  //
@@ -215,12 +257,142 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
215
257
  /**
216
258
  * Find initial tainted definitions from sources.
217
259
  */
218
- function findInitialTaint(sources, callsByLine, defsByLine) {
260
+ /** Locations of sources that name exactly one parameter binding (#586, #587). */
261
+ const PARAM_SCOPED_SOURCE_RE = /^(?:GraphQL \w+\.\w+ resolver args|Express handler destructured request) /;
262
+ /**
263
+ * True when `code` (one trimmed source line) is an `if` / `else if` / `while`
264
+ * header whose parenthesised condition closes on that same line.
265
+ */
266
+ function isCompleteConditionHeader(code) {
267
+ if (!code)
268
+ return false;
269
+ const m = /^(?:\}\s*)?(?:else\s+)?(?:if|while)\s*\(/.exec(code);
270
+ if (!m)
271
+ return false;
272
+ // Blank out string / char literals so their parens do not count.
273
+ const text = code.slice(m[0].length - 1).replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, '""');
274
+ let depth = 0;
275
+ for (const ch of text) {
276
+ if (ch === '(')
277
+ depth++;
278
+ else if (ch === ')' && --depth === 0)
279
+ return true;
280
+ }
281
+ return false;
282
+ }
283
+ /**
284
+ * True when `code` (one trimmed source line) holds a second statement after a
285
+ * `;` (`a := f(); b := "x"`). A `for (…;…;…)` header and a trailing comment
286
+ * or closing brace do not count.
287
+ */
288
+ function hasSecondStatement(code) {
289
+ if (!code || /^for\b/.test(code))
290
+ return false;
291
+ const text = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, '""');
292
+ return /;\s*(?!\/\/|\/\*|#|\}|\)|$)\S/.test(text);
293
+ }
294
+ /**
295
+ * #490: on a line that holds several statements, true when the one statement
296
+ * that defines `variable` is positively known not to read `via`. The data-flow
297
+ * graph links a use to every definition on its line, so in
298
+ * `const q = req.query.q; const tool = "ls";` the handler parameter `req` would
299
+ * otherwise reach `tool`. Returns false whenever the defining statement cannot
300
+ * be isolated (no match, more than one match, a destructuring or other form the
301
+ * scan does not read) so the line-granular behaviour is kept.
302
+ */
303
+ function lineDefinitionLacksUse(lineCode, variable, via) {
304
+ if (!/^[A-Za-z_$][\w$]*$/.test(variable) || !/^[A-Za-z_$][\w$]*$/.test(via))
305
+ return false;
306
+ // Blank string / char / template literals so `;` and identifiers inside them do not count.
307
+ let text = lineCode.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, m => '"' + ' '.repeat(Math.max(0, m.length - 2)) + '"');
308
+ // Drop a trailing `//` comment. Any `/` left over may open a regex literal
309
+ // (`/;/.test(q)`) whose `;` or `=` would mislead the scan, so keep the
310
+ // line-granular behaviour.
311
+ text = text.replace(/(^|[\s;{}])\/\/.*$/, '$1');
312
+ if (text.includes('/'))
313
+ return false;
314
+ const name = variable.replace(/\$/g, '\\$');
315
+ // A second write to the variable on the line (compound assignment, `++`/`--`,
316
+ // or a destructuring assignment target such as `[a, b] = [b, a]`) means the
317
+ // first initializer is not its only value.
318
+ if (new RegExp(String.raw `(?<![.\w$])${name}\s*(?:\*\*|<<|>>>?|&&|\|\||\?\?|[-+*%&|^])=`).test(text))
319
+ return false;
320
+ if (new RegExp(String.raw `(?<![.\w$])${name}\s*(?:\+\+|--)|(?:\+\+|--)\s*${name}(?![\w$])`).test(text))
321
+ return false;
322
+ for (const d of text.matchAll(/[\[{]([^[\]{}]*)[\]}]\s*=(?![=>])/g)) {
323
+ if (new RegExp(String.raw `(?<![.\w$])${name}(?![\w$])`).test(d[1]))
324
+ return false;
325
+ }
326
+ const declRe = new RegExp(String.raw `(?<![.\w$])${name}\s*(?::=|=|:[^=;]*=)(?![=>])`, 'g');
327
+ const matches = [...text.matchAll(declRe)];
328
+ if (matches.length !== 1)
329
+ return false;
330
+ const m = matches[0];
331
+ // The initializer runs to the next `;` at its own nesting depth, or to the
332
+ // closer of the block / call it sits in. When neither is on this line the
333
+ // initializer wraps (`'ls ' +` / `(` then `q);` below), so it cannot be judged.
334
+ let depth = 0;
335
+ let end = -1;
336
+ for (let i = m.index + m[0].length; i < text.length; i++) {
337
+ const c = text[i];
338
+ if (c === '(' || c === '[' || c === '{')
339
+ depth++;
340
+ else if (c === ')' || c === ']' || c === '}') {
341
+ if (--depth < 0) {
342
+ end = i;
343
+ break;
344
+ }
345
+ }
346
+ else if (c === ';' && depth === 0) {
347
+ end = i;
348
+ break;
349
+ }
350
+ }
351
+ if (end < 0)
352
+ return false;
353
+ const bounded = text.slice(m.index + m[0].length, end).trim();
354
+ if (bounded === '' || /[-+*%&|^!~<>=?:,.([{]$/.test(bounded))
355
+ return false;
356
+ // Judge the original text of the initializer: an identifier inside a literal
357
+ // (`$"make {q}"`, `f"{q}"`, `${q}`) counts as a read. Blanking keeps offsets.
358
+ const rhs = lineCode.slice(m.index + m[0].length, end);
359
+ return !new RegExp(String.raw `(?<![.\w$])${via.replace(/\$/g, '\\$')}(?![\w$])`).test(rhs);
360
+ }
361
+ function findInitialTaint(sources, callsByLine, defsByLine, chainsByFromDef, jsLike = false, lineText) {
219
362
  const tainted = [];
220
363
  for (const source of sources) {
221
364
  // Find definitions on the same line as the source
222
365
  const defsOnLine = defsByLine.get(source.line) ?? [];
366
+ // cognium-dev#490: `q := r.FormValue("q"); x := "fixed"` puts two
367
+ // statements on the source line. Only the source's own binding, and a
368
+ // same-line definition the data-flow graph derives from it
369
+ // (`w := r.FormValue("w"); cmd := "x " + w`), is tainted; an unrelated
370
+ // neighbour statement's definition is not.
371
+ let seedable = null;
372
+ // The whole line, not just the text from the source: a statement before the
373
+ // read (`const tool = "ls"; const q = req.query.q;`) is a second statement too.
374
+ const lineCode = lineText?.(source.line)?.trim() || source.code;
375
+ if (source.variable && hasSecondStatement(lineCode)) {
376
+ const own = defsOnLine.filter(d => d.variable === source.variable);
377
+ if (own.length > 0) {
378
+ seedable = new Set(own.map(d => d.id));
379
+ for (const d of own) {
380
+ for (const c of chainsByFromDef.get(d.id) ?? []) {
381
+ const to = defsOnLine.find(o => o.id === c.to_def);
382
+ if (!to)
383
+ continue;
384
+ // The graph links a use to every definition on its line; keep the
385
+ // neighbour only when its own statement reads the linked variable.
386
+ if (c.via && lineCode && lineDefinitionLacksUse(lineCode, to.variable, c.via))
387
+ continue;
388
+ seedable.add(c.to_def);
389
+ }
390
+ }
391
+ }
392
+ }
223
393
  for (const def of defsOnLine) {
394
+ if (seedable && !seedable.has(def.id))
395
+ continue;
224
396
  // cognium-dev #308 (from cognium-ai#326 / #289): an
225
397
  // `interprocedural_param` source names exactly one parameter
226
398
  // (`source.variable`, set for Java/C#). Every parameter shares the
@@ -230,12 +402,22 @@ function findInitialTaint(sources, callsByLine, defsByLine) {
230
402
  // the bogus taint into `cmd.ExecuteReader()`. Taintable neighbours
231
403
  // (`string tableName`) get their own source, so restricting the seed
232
404
  // to the named parameter loses nothing.
233
- if (source.type === 'interprocedural_param' &&
405
+ // cognium-dev#586 / #587: framework parameter sources (GraphQL resolver
406
+ // `args`, Express destructured request keys) also name exactly one
407
+ // binding; `parent` / `context` / `res` share the signature line.
408
+ if ((source.type === 'interprocedural_param' || PARAM_SCOPED_SOURCE_RE.test(source.location ?? '')) &&
234
409
  source.variable &&
235
410
  def.kind === 'param' &&
236
411
  def.variable !== source.variable) {
237
412
  continue;
238
413
  }
414
+ // A parameter source stands for the parameter's binding only. Other
415
+ // definitions that share the signature line are not the parameter:
416
+ // `const ident = async (x) => x;` defines `ident` there, and seeding it
417
+ // tainted every `ident(…)` result with the parameter's taint (#490).
418
+ if (source.type === 'interprocedural_param' && def.kind !== 'param') {
419
+ continue;
420
+ }
239
421
  tainted.push({
240
422
  variable: def.variable,
241
423
  defId: def.id,
@@ -256,13 +438,40 @@ function findInitialTaint(sources, callsByLine, defsByLine) {
256
438
  // purely by line adjacency, then `propagateThroughChains` carries the
257
439
  // bogus taint forward to any sink that consumes `cmd` later in the
258
440
  // method — that was the FP-03 root cause from issue #101.
259
- const defsNextLine = defsByLine.get(source.line + 1) ?? [];
441
+ //
442
+ // cognium-dev#596: a source inside a complete `if (…)` / `while (…)` header
443
+ // (`if (request.getParameter("a") != null)`) is a condition, not the start
444
+ // of an expression that continues on the next line. The next line is the
445
+ // body, so `target = "safe";` there must not be seeded by adjacency.
446
+ //
447
+ // cognium-dev#563: a source line that is a complete assignment
448
+ // (`const qty = parseInt(req.query.qty, 10);`) does not continue on the
449
+ // next line either; a local declared there (`const label = 'units';`)
450
+ // is a new statement and gets no taint by adjacency. JavaScript /
451
+ // TypeScript only, the languages this was measured on: in Java the next
452
+ // line can overwrite a servlet field (`name = "abc";`) that other
453
+ // requests share, and SecuriBench StrongUpdates4 expects that flow kept.
454
+ const closesOwnStatement = jsLike && defsOnLine.length > 0 && /;\s*(?:\/\/.*)?$/.test(source.code ?? '');
455
+ const defsNextLine = isCompleteConditionHeader(source.code)
456
+ ? []
457
+ : (defsByLine.get(source.line + 1) ?? []).filter(d => !(closesOwnStatement && d.kind === 'local'));
458
+ // cognium-dev#490: a source with no binding of its own (a handler signature)
459
+ // seeds the first body line by adjacency. When that line holds several
460
+ // statements, a definition whose own statement reads nothing defined on the
461
+ // source line (`const tool = "ls";` beside `const q = req.query.q;`) is not
462
+ // a continuation of it.
463
+ const nextCode = lineText && !source.variable ? lineText(source.line + 1)?.trim() : undefined;
464
+ const nextMulti = !!nextCode && hasSecondStatement(nextCode);
465
+ const sourceLineNames = [...new Set(defsOnLine.map(d => d.variable))];
260
466
  for (const def of defsNextLine) {
261
467
  // Only include if there's a call on the source line
262
468
  const callsOnSourceLine = callsByLine.get(source.line) ?? [];
263
469
  if (callsOnSourceLine.length > 0) {
264
470
  if (source.variable && def.variable !== source.variable)
265
471
  continue;
472
+ if (nextMulti && sourceLineNames.length > 0 &&
473
+ sourceLineNames.every(n => lineDefinitionLacksUse(nextCode, def.variable, n)))
474
+ continue;
266
475
  tainted.push({
267
476
  variable: def.variable,
268
477
  defId: def.id,
@@ -296,17 +505,53 @@ function findInitialTaint(sources, callsByLine, defsByLine) {
296
505
  }
297
506
  return tainted;
298
507
  }
508
+ /**
509
+ * #589: true when `variable` still appears in a def expression after HTML-encoder
510
+ * calls are masked — e.g. `escapeHtml(a) + b` keeps `b` as a raw operand.
511
+ */
512
+ function identifierOutsideHtmlEncoderCalls(variable, expression, sans) {
513
+ if (!expression || !sans || sans.length === 0)
514
+ return false;
515
+ if (!/^[A-Za-z_$][\w$]*$/.test(variable))
516
+ return false;
517
+ const refs = [];
518
+ for (const san of sans) {
519
+ if (!isHtmlContextEncoder(san))
520
+ continue;
521
+ const m = /(?:(\w+)\.)?(\w+)\(\)/.exec(san.method);
522
+ if (!m?.[2])
523
+ continue;
524
+ refs.push({ receiver: m[1], methodName: m[2] });
525
+ }
526
+ if (refs.length === 0)
527
+ return false;
528
+ const masked = maskSanitizerCalls(expression, refs);
529
+ if (masked === expression)
530
+ return false;
531
+ const re = new RegExp(`(?:^|[^A-Za-z0-9_$])${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[^A-Za-z0-9_$]|$)`);
532
+ return re.test(blankStringLiterals(masked));
533
+ }
534
+ /**
535
+ * True when `next` is a bare-parameter seed and `existing` already credits the
536
+ * same definition to a real source: the real source keeps the definition.
537
+ */
538
+ function yieldsToRealSource(existing, next) {
539
+ return existing !== undefined &&
540
+ next.sourceType === 'interprocedural_param' &&
541
+ existing.sourceType !== 'interprocedural_param';
542
+ }
299
543
  /**
300
544
  * Propagate taint through def-use chains.
301
545
  * Accepts a pre-computed chainsByFromDef index (from CodeGraph) — no internal
302
546
  * adjacency list building needed.
303
547
  */
304
- function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitizersByLine) {
548
+ function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitizersByLine, taintBarrierDefIds, lineText) {
305
549
  const propagated = [];
306
550
  const taintedDefIds = new Set(initialTaint.map(t => t.defId));
307
551
  const taintInfoByDefId = new Map();
308
552
  for (const t of initialTaint) {
309
- taintInfoByDefId.set(t.defId, t);
553
+ if (!yieldsToRealSource(taintInfoByDefId.get(t.defId), t))
554
+ taintInfoByDefId.set(t.defId, t);
310
555
  }
311
556
  // BFS to propagate taint
312
557
  const queue = [...initialTaint.map(t => t.defId)];
@@ -318,14 +563,32 @@ function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitize
318
563
  continue;
319
564
  const outgoingChains = chainsByFromDef.get(currentDefId) ?? [];
320
565
  for (const chain of outgoingChains) {
321
- if (visited.has(chain.to_def))
566
+ // A measure/test of the value or a constant-table lookup (#568, #667).
567
+ if (taintBarrierDefIds.has(chain.to_def))
322
568
  continue;
323
569
  const targetDef = defById.get(chain.to_def);
324
570
  if (!targetDef)
325
571
  continue;
572
+ // #490: the graph links a use to every definition on its line; on a line
573
+ // with a second statement the link holds only when the use is in the
574
+ // definition's own statement.
575
+ if (chain.via && lineText) {
576
+ const code = lineText(targetDef.line)?.trim();
577
+ if (code && hasSecondStatement(code) && lineDefinitionLacksUse(code, targetDef.variable, chain.via))
578
+ continue;
579
+ }
326
580
  // Check if there's a sanitizer between source and this def
327
581
  const sanitizeCheck = checkSanitized(currentTaint.sourceLine, targetDef.line, currentTaint.sourceType, sanitizersByLine);
328
- if (!sanitizeCheck.sanitized) {
582
+ const sansAtTarget = sanitizersByLine.get(targetDef.line);
583
+ // #589: decide encoder-only per operand, not per line. A raw sibling on
584
+ // `escapeHtml(a) + b` is unrestricted even though the line has an encoder.
585
+ const viaName = chain.via || currentTaint.variable;
586
+ const rawOperand = identifierOutsideHtmlEncoderCalls(viaName, targetDef.expression ?? lineText?.(targetDef.line), sansAtTarget);
587
+ const effectivelySanitized = sanitizeCheck.sanitized && !rawOperand;
588
+ // #589: a hop through a context-specific HTML encoder keeps the taint
589
+ // alive for the sink types the encoder does not clean (`trust_boundary`).
590
+ const encoderOnly = effectivelySanitized && sanitizersAreHtmlContextEncoders(sansAtTarget);
591
+ if (!effectivelySanitized || encoderOnly) {
329
592
  const newTaint = {
330
593
  variable: targetDef.variable,
331
594
  defId: targetDef.id,
@@ -333,7 +596,28 @@ function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitize
333
596
  sourceType: currentTaint.sourceType,
334
597
  sourceLine: currentTaint.sourceLine,
335
598
  confidence: currentTaint.confidence * 0.95, // Decay confidence slightly
599
+ ...(encoderOnly
600
+ ? { survivesOnlyFor: HTML_CONTEXT_ENCODER_UNCOVERED_SINKS }
601
+ : currentTaint.survivesOnlyFor ? { survivesOnlyFor: currentTaint.survivesOnlyFor } : {}),
336
602
  };
603
+ const existing = taintInfoByDefId.get(targetDef.id);
604
+ if (visited.has(chain.to_def)) {
605
+ // #589: first-wins `visited` kept an encoder-only predecessor; a later
606
+ // unrestricted chain must replace it and keep propagating.
607
+ if (!existing?.survivesOnlyFor)
608
+ continue;
609
+ if (newTaint.survivesOnlyFor)
610
+ continue;
611
+ const idx = propagated.findIndex(t => t.defId === targetDef.id);
612
+ if (idx >= 0)
613
+ propagated[idx] = newTaint;
614
+ else
615
+ propagated.push(newTaint);
616
+ taintedDefIds.add(targetDef.id);
617
+ taintInfoByDefId.set(targetDef.id, newTaint);
618
+ queue.push(targetDef.id);
619
+ continue;
620
+ }
337
621
  propagated.push(newTaint);
338
622
  taintedDefIds.add(targetDef.id);
339
623
  taintInfoByDefId.set(targetDef.id, newTaint);
@@ -443,6 +727,11 @@ function maskSanitizerCalls(expr, refs) {
443
727
  }
444
728
  return out;
445
729
  }
730
+ function maskLengthOf(expr) {
731
+ return expr
732
+ .replace(/\b(?:len|strlen|count)\s*\(\s*[\w$.]+\s*\)/g, ' 0 ')
733
+ .replace(/[\w$.]+\s*\.\s*(?:length|size|count)\s*(?:\(\s*\))?(?![\w$(])/g, ' 0 ');
734
+ }
446
735
  function blankStringLiterals(expr) {
447
736
  return expr
448
737
  .replace(/"(?:[^"\\]|\\.)*"/g, '""')
@@ -466,22 +755,79 @@ export function identifierOutsideSanitizerCalls(variable, calls, sanitizers, sin
466
755
  for (const call of calls) {
467
756
  if (refs.some(r => r.methodName === call.method_name))
468
757
  continue;
469
- for (const arg of call.arguments) {
758
+ // A same-line guard (`IsLocalUrl(next); Redirect(next)`) is not a
759
+ // partial wrap. Only a call with an argument that itself contains the
760
+ // sanitizer call can leave a raw operand: in that argument, or in a
761
+ // sibling argument of the same call (`Printf("%s %s", clean(a), b)`).
762
+ const masked = call.arguments.map(arg => {
470
763
  const expr = arg.expression ?? '';
471
- if (!expr)
472
- continue;
473
- // A same-line guard (`IsLocalUrl(next); Redirect(next)`) is not a
474
- // partial wrap. Only an argument that itself contains the sanitizer
475
- // call can leave a raw sibling operand.
476
- const masked = maskSanitizerCalls(expr, refs);
477
- if (masked === expr)
764
+ return { expr, masked: expr ? maskSanitizerCalls(expr, refs) : expr };
765
+ });
766
+ if (!masked.some(m => m.masked !== m.expr))
767
+ continue;
768
+ for (const m of masked) {
769
+ if (!m.masked)
478
770
  continue;
479
- if (re.test(blankStringLiterals(masked)))
771
+ // A sibling that passes only the value's length (`len(s)`,
772
+ // `s.length()`, `s.size()`) carries no attacker-controlled text.
773
+ const text = m.masked === m.expr ? maskLengthOf(m.masked) : m.masked;
774
+ if (re.test(blankStringLiterals(text)))
480
775
  return true;
481
776
  }
482
777
  }
483
778
  return false;
484
779
  }
780
+ /**
781
+ * True when `variable` appears in `text` once covering sanitizer calls are
782
+ * removed. Returns `null` when no covering sanitizer call is found in `text`
783
+ * (the caller cannot tell a wrapped operand from a raw one).
784
+ *
785
+ * #589: `mixed = escapeHtml(a) + b` keeps `b`; `both = escapeHtml(a) +
786
+ * escapeHtml(b)` keeps nothing.
787
+ */
788
+ export function identifierOutsideSanitizerCallsInText(variable, text, sanitizers, sinkType) {
789
+ const refs = sanitizerCallRefs(sanitizers, sinkType);
790
+ if (refs.length === 0)
791
+ return null;
792
+ const masked = maskSanitizerCalls(text, refs);
793
+ if (masked === text)
794
+ return null;
795
+ if (!/^[A-Za-z_$][\w$]*$/.test(variable))
796
+ return true;
797
+ const re = new RegExp(`(?:^|[^A-Za-z0-9_$])${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[^A-Za-z0-9_$]|$)`);
798
+ return re.test(blankStringLiterals(masked));
799
+ }
800
+ /**
801
+ * True when every operand of `text` sits inside a covering sanitizer call:
802
+ * after those calls are removed and string literals blanked, no identifier is
803
+ * left. `shlex.quote(request.args.get("t"))` qualifies; `quote(a) + b` does
804
+ * not (#582).
805
+ */
806
+ export function expressionFullyWrappedBySanitizer(text, sanitizers, sinkType) {
807
+ const refs = sanitizerCallRefs(sanitizers, sinkType);
808
+ if (refs.length === 0)
809
+ return false;
810
+ const masked = maskSanitizerCalls(text, refs);
811
+ if (masked === text)
812
+ return false;
813
+ // A qualified call (`os.path.basename(x)`) leaves its module prefix (`os. `) behind.
814
+ const rest = blankStringLiterals(masked).replace(/\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\.(?=\s)/g, ' ');
815
+ return !/[A-Za-z_]/.test(rest);
816
+ }
817
+ /** Last segment of a sanitizer's method name, without the call parens (`ESAPI.encoder().encodeForHTML()` -> `encodeForHTML`). */
818
+ function sanitizerMethodTail(san) {
819
+ const m = san.method ?? '';
820
+ return m.replace(/\(\)$/, '').split('.').pop() ?? '';
821
+ }
822
+ /** #589: a method-call sanitizer that is a context-specific HTML / markup encoder. */
823
+ function isHtmlContextEncoder(san) {
824
+ if (san.type === 'derived_html_encoder')
825
+ return true; // same-file wrapper of an HTML encoder (#589)
826
+ return san.type === 'method_call' && HTML_CONTEXT_ENCODER_METHODS.has(sanitizerMethodTail(san));
827
+ }
828
+ function sanitizersAreHtmlContextEncoders(sans) {
829
+ return !!sans && sans.length > 0 && sans.every(isHtmlContextEncoder);
830
+ }
485
831
  function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
486
832
  const isKnownSinkType = KNOWN_SINK_TYPES.has(sinkType);
487
833
  // Per-line fast path — preserves 3.154.0 semantics for site #1 (propagation
@@ -490,6 +836,9 @@ function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
490
836
  const sanitizersAtTarget = sanitizersByLine.get(toLine);
491
837
  if (sanitizersAtTarget && sanitizersAtTarget.length > 0) {
492
838
  for (const san of sanitizersAtTarget) {
839
+ // #589: an HTML-context encoder does not clean a `trust_boundary` sink.
840
+ if (HTML_CONTEXT_ENCODER_UNCOVERED_SINKS.has(sinkType) && isHtmlContextEncoder(san))
841
+ continue;
493
842
  if (isKnownSinkType) {
494
843
  // H8 (#254 3.171.0): Set-backed lookup — O(1) after first call.
495
844
  if (sanitizerCoversSink(san, sinkType)) {
@@ -526,6 +875,8 @@ function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
526
875
  if (!sansAtLine || sansAtLine.length === 0)
527
876
  continue;
528
877
  for (const san of sansAtLine) {
878
+ if (HTML_CONTEXT_ENCODER_UNCOVERED_SINKS.has(sinkType) && isHtmlContextEncoder(san))
879
+ continue;
529
880
  if (isKnownSinkType) {
530
881
  // H8 (#254 3.171.0): Set-backed lookup — O(1) after first call.
531
882
  if (sanitizerCoversSink(san, sinkType)) {