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
@@ -15,12 +15,13 @@
15
15
  *
16
16
  * Depends on: taint-matcher, constant-propagation, language-sources
17
17
  */
18
- import { CSHARP_SANITIZER_RES, CSharpCredit } from './csharp-sanitizer-credit.js';
18
+ import { CSHARP_SANITIZER_RES, CSharpCredit, csharpSanitizersFor } from './csharp-sanitizer-credit.js';
19
19
  import { JS_TAINTED_PATTERNS } from './language-sources-pass.js';
20
20
  import { LIBRARY_API_SURFACE_TAG } from '../library-api-surface-downgrade.js';
21
21
  import { isNonExecutableSourceLine } from '../non-executable-lines.js';
22
22
  import { sanitizerCoversSink } from '../sanitizer-index.js';
23
23
  import { identifierOutsideSanitizerCalls } from '../taint-propagation.js';
24
+ import { parseJavaArrayAccessExpression } from '../java-array-element-keys.js';
24
25
  import { resolveIdentifierType, resolveCallReturnType, isArgvContainerType, } from '../arg-type-resolver.js';
25
26
  /** Escape regex metacharacters for safe pattern building. */
26
27
  function escapeReSf(s) {
@@ -60,10 +61,8 @@ const JS_LOG_INJECTION_SANITIZERS = [
60
61
  /\bstripCRLF\s*\(/,
61
62
  /\bremoveNewlines\s*\(/,
62
63
  /\bsanitizeLogValue\s*\(/,
63
- // Inline CRLF-stripping regex literal: .replace(/[\r\n]/g, '')
64
- /\.replace\(\s*\/\[\s*\\r\s*\\n\s*\]\/[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
65
- // Inline CRLF sequence: .replace(/\r\n/g, '')
66
- /\.replace\(\s*\/\\r\\n\/[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
64
+ // Inline CRLF-stripping regex literal: .replace(/[\r\n]/g, '') — global required
65
+ /\.replace\(\s*\/\[\s*\\r\s*\\n\s*\]\/[gimsu]*g[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
67
66
  ];
68
67
  // ---------------------------------------------------------------------------
69
68
  // Stage 17 — Python ldap_injection (CWE-90) regex-strip wrapper recognition.
@@ -291,6 +290,60 @@ function argvFirstLiteralIsShell(lineText) {
291
290
  .toLowerCase();
292
291
  return SHELL_PROGRAMS.has(base);
293
292
  }
293
+ /**
294
+ * cognium-dev#626 — does a same-file call of the wrapper method that contains
295
+ * the sink pass a shell argv (`Arrays.asList("/bin/sh", "-c", ...)`,
296
+ * `List.of("bash", ...)`, `new String[]{"sh", ...}`) at the position of its
297
+ * parameter `paramName`?
298
+ *
299
+ * Reuses the #351 literal test (`argvFirstLiteralIsShell`) on the caller's
300
+ * argument text, so the criterion is the same one the inline form already
301
+ * obeys. The wrapper is the method (name and parameter list) of the declaring
302
+ * type that contains the sink line. A call counts only when it has no receiver
303
+ * or a `this` receiver, sits in that same type, and passes the same number of
304
+ * arguments, so a call on another object, another class's method of the same
305
+ * name or another overload is ignored, and so is a call inside a nested type
306
+ * that declares its own method of that name and arity (it calls that one, not
307
+ * the wrapper; `enclosing_type` is unset on the default path, so the nested
308
+ * type is found by its line range). A parameter that is not found answers
309
+ * `false` and today's suppression stands.
310
+ */
311
+ function sameFileCallerPassesShellArgv(paramName, methodName, sinkLine, types, calls) {
312
+ if (!methodName)
313
+ return false;
314
+ for (const t of types) {
315
+ const m = t.methods.find(mm => mm.name === methodName && sinkLine >= mm.start_line && sinkLine <= mm.end_line);
316
+ if (!m)
317
+ continue;
318
+ const pos = m.parameters.findIndex(p => p.name === paramName);
319
+ if (pos < 0)
320
+ return false;
321
+ return calls.some(c => {
322
+ if (c.method_name !== methodName)
323
+ return false;
324
+ if (c.receiver != null && c.receiver !== 'this')
325
+ return false;
326
+ if (c.location.line < t.start_line || c.location.line > t.end_line)
327
+ return false;
328
+ if (c.arguments.length !== m.parameters.length)
329
+ return false;
330
+ // A call inside a nested type that declares its own `methodName` of the
331
+ // same arity calls that method, not this wrapper (the outer type's line
332
+ // span covers the nested one). A nested call with no such method still
333
+ // resolves to the outer wrapper and counts.
334
+ const shadowed = types.some(u => u !== t &&
335
+ u.start_line >= t.start_line && u.end_line <= t.end_line &&
336
+ (u.start_line > t.start_line || u.end_line < t.end_line) &&
337
+ c.location.line >= u.start_line && c.location.line <= u.end_line &&
338
+ u.methods.some(um => um.name === methodName && um.parameters.length === c.arguments.length));
339
+ if (shadowed)
340
+ return false;
341
+ const expr = c.arguments[pos]?.expression;
342
+ return typeof expr === 'string' && argvFirstLiteralIsShell(`new ProcessBuilder(${expr}`);
343
+ });
344
+ }
345
+ return false;
346
+ }
294
347
  // ---------------------------------------------------------------------------
295
348
  // Stage 12 — Java throw-statement FP suppression.
296
349
  // (cognium-dev #157 — Sprint 45)
@@ -464,6 +517,131 @@ const SERVER_SIGNAL_RE = /["']use server["']|\b(?:getServerSideProps|getStaticPr
464
517
  // interpolated, the destination host is not attacker-controlled. Matches a
465
518
  // backtick + scheme + a literal host run (no `${` before the first `/`).
466
519
  const FIXED_HOST_TEMPLATE_RE = /`https?:\/\/[^/${}\s`]+[/`]/;
520
+ /** A bash URL word whose scheme and host are literal and closed by `/` (#495). */
521
+ const BASH_FIXED_HOST_URL_RE = /^(?:https?|ftp):\/\/[A-Za-z0-9.-]+(?::\d+)?\//;
522
+ /**
523
+ * True when the string literal that ended at `end` is the WHOLE initializer:
524
+ * the next code token is `;`, `,`, end of input, or (ASI) a token on a later
525
+ * line that cannot continue the expression. A same-line operator (`+`, `.`,
526
+ * `||` …) or a next-line `+` / `.` / `(` / `[` / template continuation means
527
+ * the literal is only the start of a larger expression, so it is not a
528
+ * constant. An optional TS `as const` / `as string` suffix is skipped (it may
529
+ * sit on the next line).
530
+ * (cognium-dev#680, #681)
531
+ */
532
+ function literalEndsInitializer(code, end) {
533
+ let i = end;
534
+ let crossedNewline = false;
535
+ const asConst = /^\s+as\s+(?:const|string)\b/.exec(code.slice(i, i + 64));
536
+ if (asConst) {
537
+ i += asConst[0].length;
538
+ crossedNewline = asConst[0].includes('\n');
539
+ }
540
+ for (;;) {
541
+ while (i < code.length && /\s/.test(code[i])) {
542
+ if (code[i] === '\n')
543
+ crossedNewline = true;
544
+ i++;
545
+ }
546
+ if (code.startsWith('//', i)) {
547
+ const nl = code.indexOf('\n', i);
548
+ i = nl === -1 ? code.length : nl;
549
+ }
550
+ else if (code.startsWith('/*', i)) {
551
+ const close = code.indexOf('*/', i + 2);
552
+ if (close === -1)
553
+ return false;
554
+ if (code.slice(i, close).includes('\n'))
555
+ crossedNewline = true;
556
+ i = close + 2;
557
+ }
558
+ else
559
+ break;
560
+ }
561
+ if (i >= code.length)
562
+ return true;
563
+ const c = code[i];
564
+ if (c === ';' || c === ',')
565
+ return true;
566
+ // ASI: a following line that begins a new statement terminates the initializer.
567
+ // A line that starts with `as` / `satisfies` / `in` / `instanceof` continues it.
568
+ if (/^(?:as|satisfies|in|instanceof)(?![\w$])/.test(code.slice(i, i + 12)))
569
+ return false;
570
+ return crossedNewline && !/[-+*/%.?:|&<>=!~^(\[`]/.test(c);
571
+ }
572
+ /** Decode `\xNN` / `\uNNNN` / `\u{…}` JS string escapes; other escapes are kept verbatim. */
573
+ function decodeJsHexEscapes(value) {
574
+ return value.replace(/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|u\{([0-9a-fA-F]{1,6})\}|[\s\S])/g, (m, x, u4, ub) => {
575
+ const hex = x ?? u4 ?? ub;
576
+ if (hex === undefined)
577
+ return m;
578
+ const cp = parseInt(hex, 16);
579
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
580
+ });
581
+ }
582
+ /**
583
+ * The value of `name` when it is bound exactly once in the file, by `const`,
584
+ * to a plain string literal (no interpolation) that is the whole initializer,
585
+ * and has no other `let` / `var` / `function` binding; otherwise null.
586
+ * (cognium-dev#681)
587
+ */
588
+ function constStringLiteralValue(code, name) {
589
+ const esc = name.replace(/\$/g, '\\$');
590
+ const decl = new RegExp(`(?:^|[^\\w$.])const\\s+${esc}\\s*(?::\\s*string\\s*)?=\\s*(?:'((?:\\\\.|[^'\\\\\\n])*)'|"((?:\\\\.|[^"\\\\\\n])*)"|\`([^\`$\\\\]*)\`)`, 'g');
591
+ const decls = [...code.matchAll(decl)];
592
+ if (decls.length !== 1)
593
+ return null;
594
+ if (!literalEndsInitializer(code, decls[0].index + decls[0][0].length))
595
+ return null;
596
+ const anyBinding = new RegExp(`(?:^|[^\\w$.])(?:const|let|var|function)\\s+${esc}\\b`, 'g');
597
+ if ([...code.matchAll(anyBinding)].length !== 1)
598
+ return null;
599
+ return decodeJsHexEscapes(decls[0][1] ?? decls[0][2] ?? decls[0][3] ?? '');
600
+ }
601
+ /**
602
+ * The body of the template literal that is the first argument of the
603
+ * `new RegExp(` on `line` (1-indexed), or null. The literal may start on a
604
+ * following line, after whitespace or comments, as a formatter wraps a long
605
+ * constructor call; the `new RegExp(` itself must be on the sink line.
606
+ * (cognium-dev#717)
607
+ */
608
+ function regExpTemplateArgument(code, lines, line) {
609
+ const text = lines[line - 1] ?? '';
610
+ let offset = 0;
611
+ for (let i = 0; i < line - 1; i++)
612
+ offset += lines[i].length + 1;
613
+ const argument = /^(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*`((?:\\.|[^`\\])*)`(?=\s*[,)])/;
614
+ for (const start of text.matchAll(/new\s+RegExp\s*\(/g)) {
615
+ // The literal must be the whole first argument: `` `…` + text `` is not constant.
616
+ const m = argument.exec(code.slice(offset + start.index + start[0].length));
617
+ if (m)
618
+ return m[1];
619
+ }
620
+ return null;
621
+ }
622
+ /** A group that holds an unbounded quantifier and is itself quantified: `(a+)+`, `(x*)*`, `(\w{2,})+`, `(a+){2,}`. */
623
+ const NESTED_QUANTIFIER_RE = /\([^()]*(?:[+*]|\{\d+,\d*\})[^()]*\)\s*[+*{]/;
624
+ /** A template literal that starts with `${IDENT}/` (cognium-dev#680). */
625
+ const CONST_BASE_TEMPLATE_RE = /`\$\{\s*([A-Za-z_$][\w$]*)\s*\}\//g;
626
+ /**
627
+ * True when `name` is bound exactly once in the file, by `const`, to a string
628
+ * literal http(s) origin with a literal host, and is never reassigned.
629
+ */
630
+ function isConstLiteralOrigin(code, name) {
631
+ const esc = name.replace(/\$/g, '\\$');
632
+ const decl = new RegExp(`(?:^|[^\\w$.])(const|let|var)\\s+${esc}\\s*(?::\\s*string\\s*)?=\\s*(['"\`])(https?://[^/'"\`\\s$]+(?:/[^'"\`$]*)?)\\2`, 'gm');
633
+ const decls = [...code.matchAll(decl)];
634
+ if (decls.length !== 1 || decls[0][1] !== 'const')
635
+ return false;
636
+ if (!literalEndsInitializer(code, decls[0].index + decls[0][0].length))
637
+ return false;
638
+ const anyBinding = new RegExp(`(?:^|[^\\w$.])(?:const|let|var)\\s+${esc}\\b`, 'g');
639
+ if ([...code.matchAll(anyBinding)].length !== 1)
640
+ return false;
641
+ const reassign = new RegExp(`(?:^|[^\\w$.])${esc}\\s*(?:[-+]?=)(?!=)`, 'g');
642
+ const rest = code.slice(0, decls[0].index) + code.slice(decls[0].index + decls[0][0].length);
643
+ return !reassign.test(rest);
644
+ }
467
645
  /**
468
646
  * Returns the 1-indexed line numbers that a Java `if (x.contains("..")) throw/
469
647
  * return` reject-guard makes path-traversal-safe (cognium-dev#269). A line is
@@ -604,8 +782,10 @@ function jsSsrfHostGuardedLines(code) {
604
782
  return covered;
605
783
  }
606
784
  /**
607
- * Is this `plugin_param` source a map read of a key that was never written, on
608
- * a map whose entire contents we can see? (cognium-dev#328 prerequisite.)
785
+ * Is this `plugin_param` source a map read that provably yields `null` or a
786
+ * string literal — a key that was never written, or any key of a literal-only
787
+ * map (#584) — on a map whose entire contents we can see? (cognium-dev#328
788
+ * prerequisite.)
609
789
  *
610
790
  * Sound only when the map's provenance is fully known, so all three hold:
611
791
  *
@@ -616,10 +796,11 @@ function jsSsrfHostGuardedLines(code) {
616
796
  * (b) the receiver never escapes — it is not passed as an argument to any
617
797
  * call. `helper(m)` could `put` into it out of sight, so once it escapes
618
798
  * the contents are no longer knowable from this file.
619
- * (c) the read key is a string literal with **no** `put` of that same literal
620
- * anywhere in the file. A matching `put` keeps the source regardless of
621
- * whether the stored value looks tainted — this gate only removes reads
622
- * that provably yield `null`, never reads whose value it judged clean.
799
+ * (c) the read key is a string literal. A matching `put` of that literal no
800
+ * longer keeps the source automatically: when every put value is itself a
801
+ * string literal (`isLiteralOnlyLocalMap`), the read is still dropped as
802
+ * non-tainted (#584). This gate only removes reads that provably yield
803
+ * `null` or a literal-only local map, never reads whose value is uncertain.
623
804
  *
624
805
  * Deliberately narrow: `plugin_param` only, literal keys only, and any
625
806
  * uncertainty leaves the source untouched. Non-literal keys (`m.get(k)`) are
@@ -650,12 +831,183 @@ function isUnwrittenLocalMapKeyRead(code, source) {
650
831
  if (escapeRe.test(stripped))
651
832
  return false;
652
833
  }
653
- // (c2) was this literal key ever written?
834
+ // (c2) was this literal key ever written? If so, drop only a literal-only map.
654
835
  const putRe = new RegExp(`\\b${recv}\\s*\\.\\s*put\\s*\\(\\s*"${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`);
655
836
  if (lines.some(l => putRe.test(l)))
837
+ return isLiteralOnlyLocalMap(lines, recv);
838
+ return true;
839
+ }
840
+ /**
841
+ * cognium-dev#584 (slice) — the map's whole contents are string literals.
842
+ *
843
+ * Every mutation of `recv` in the file is a `put(<key>, "<string literal>")`:
844
+ * no `put` of a non-literal value under any key (so no key-shuffle can place a
845
+ * tainted value under the key being read), and no bulk or computing mutator
846
+ * (`putAll`, `putIfAbsent`, `compute*`, `merge`, `replace*`) that could store
847
+ * a value out of sight. Combined with (a) local construction and (b) no escape
848
+ * in the caller, a read of such a map yields a literal and is not a source.
849
+ */
850
+ function isLiteralOnlyLocalMap(lines, recv) {
851
+ const anyPut = new RegExp(`\\b${recv}\\s*\\.\\s*put\\s*\\(`, 'g');
852
+ const literalPut = new RegExp(`\\b${recv}\\s*\\.\\s*put\\s*\\(\\s*(?:"(?:[^"\\\\]|\\\\.)*"|[A-Za-z_][\\w.]*)\\s*,\\s*"(?:[^"\\\\]|\\\\.)*"\\s*\\)`, 'g');
853
+ const otherMutator = new RegExp(`\\b${recv}\\s*\\.\\s*(?:putAll|putIfAbsent|compute|computeIfAbsent|computeIfPresent|merge|replace|replaceAll|entrySet|values)\\s*\\(`);
854
+ let puts = 0;
855
+ let literalPuts = 0;
856
+ for (const l of lines) {
857
+ if (otherMutator.test(l))
858
+ return false;
859
+ puts += (l.match(anyPut) ?? []).length;
860
+ literalPuts += (l.match(literalPut) ?? []).length;
861
+ }
862
+ return puts > 0 && puts === literalPuts;
863
+ }
864
+ /**
865
+ * Is this C# `Content(...)` call the controller helper writing an HTML body
866
+ * (cognium-dev#580)?
867
+ *
868
+ * - The helper is inherited (`ControllerBase.Content`, `PageModel.Content`),
869
+ * so it is called unqualified or on `this` / `base`. `note.Content(q)` is
870
+ * some other type's method.
871
+ * - The content type is argument 1. A literal or `MediaTypeNames` constant
872
+ * that is not HTML or a script-capable XML type serves the body as data. A computed content type is unknown and keeps the sink, as does the
873
+ * one-argument overload.
874
+ */
875
+ function csharpContentServesHtml(call) {
876
+ if (call.receiver !== null && call.receiver !== 'this' && call.receiver !== 'base')
656
877
  return false;
878
+ const contentType = call.arguments.find(a => a.position === 1)?.expression.trim();
879
+ if (contentType === undefined)
880
+ return true;
881
+ const literal = /^@?"([^"]*)"$/.exec(contentType);
882
+ // XML types (`text/xml`, `application/xml`, `image/svg+xml`, any `+xml`)
883
+ // render XHTML / SVG markup and run its scripts, so they count as HTML.
884
+ if (literal)
885
+ return /^\s*(?:text\/html|text\/xml|application\/xml|[\w.-]+\/[\w.-]+\+xml)\b/i.test(literal[1]);
886
+ const named = /^(?:System\s*\.\s*Net\s*\.\s*Mime\s*\.\s*)?MediaTypeNames\s*\.\s*(Text|Application)\s*\.\s*(\w+)$/.exec(contentType);
887
+ if (named)
888
+ return named[2] === 'Html' || named[2] === 'Xml';
657
889
  return true;
658
890
  }
891
+ /**
892
+ * True when an unqualified / `this.` / `base.` call to `Content` sits in a type
893
+ * that can inherit the MVC helper (cognium-dev#580). The C# extractor does not
894
+ * record base lists, so they are read from the declaration text.
895
+ * - a base named `Controller`, `ControllerBase` or `PageModel`: the helper;
896
+ * - a `partial` class: the base may be in another part, so treat as unknown and keep;
897
+ * - no base list, or only `I`-prefixed interface names: nothing is inherited,
898
+ * so `Content(...)` is the type's own method;
899
+ * - any other base: unknown, kept, unless the type declares its own `Content`
900
+ * name, which hides an inherited one.
901
+ * Non-class IR kinds (and a missing owner) keep the sink. A controller that
902
+ * declares its own `Content` overload keeps the helper call.
903
+ */
904
+ function csharpContentCallInheritsHelper(call, types, sourceLines) {
905
+ const line = call.location.line;
906
+ let owner;
907
+ for (const t of types) {
908
+ if (line < t.start_line || line > t.end_line)
909
+ continue;
910
+ if (!owner || t.end_line - t.start_line < owner.end_line - owner.start_line)
911
+ owner = t;
912
+ }
913
+ if (!owner || owner.kind !== 'class')
914
+ return true;
915
+ const header = sourceLines.slice(owner.start_line - 1, owner.start_line + 4).join(' ');
916
+ const decl = new RegExp(`\\bclass\\s+${owner.name}\\b(?:\\s*<[^>]*>)?\\s*(?::\\s*([^{]*?))?\\s*(?:where\\b[^{]*)?\\{`).exec(header);
917
+ if (!decl)
918
+ return true;
919
+ const bases = (decl[1] ?? '').split(',').map(b => b.trim().replace(/<.*$/, '').split('.').pop() ?? '').filter(Boolean);
920
+ if (bases.some(b => b === 'Controller' || b === 'ControllerBase' || b === 'PageModel'))
921
+ return true;
922
+ // Partial types may declare their base in another part of the type — unknown, keep.
923
+ const classAt = header.search(/\bclass\b/);
924
+ if (classAt >= 0 && /\bpartial\b/.test(header.slice(0, classAt)))
925
+ return true;
926
+ if (bases.every(b => /^I[A-Z]/.test(b)))
927
+ return false;
928
+ return !owner.methods.some(m => m.name === 'Content');
929
+ }
930
+ /**
931
+ * Does this Rust `.body(x)` call write a response whose builder chain declared
932
+ * a literal non-HTML content type (cognium-dev#478)? actix-web does not add a
933
+ * `Content-Type` of its own for `.body(...)`, so only an explicit
934
+ * `.content_type(<literal>)` earlier in the same chain proves the body is data:
935
+ *
936
+ * - a string literal `type/subtype` that is not HTML, XHTML or a
937
+ * script-capable XML type (`text/xml`, `application/xml`, any `+xml`);
938
+ * - `ContentType::plaintext()` / `json()` / `octet_stream()` /
939
+ * `form_url_encoded()` and the matching `mime::` constants.
940
+ *
941
+ * No content type, a computed one, `ContentType::html()` and every XML type
942
+ * keep the sink. When the chain sets the type more than once the last wins.
943
+ * Only a call made at the top level of the chain counts, so a nested
944
+ * `content_type(...)` inside an argument is never read as the response type.
945
+ */
946
+ function rustBodyDeclaresNonHtmlType(call) {
947
+ const recv = call.receiver;
948
+ if (!recv)
949
+ return false;
950
+ let arg;
951
+ let depth = 0;
952
+ let quote = false;
953
+ for (let i = 0; i < recv.length; i++) {
954
+ const ch = recv[i];
955
+ if (quote) {
956
+ if (ch === '\\')
957
+ i++;
958
+ else if (ch === '"')
959
+ quote = false;
960
+ continue;
961
+ }
962
+ if (ch === '"') {
963
+ quote = true;
964
+ continue;
965
+ }
966
+ if (ch === '(' || ch === '[' || ch === '{') {
967
+ depth++;
968
+ continue;
969
+ }
970
+ if (ch === ')' || ch === ']' || ch === '}') {
971
+ depth--;
972
+ continue;
973
+ }
974
+ if (depth === 0 && recv.startsWith('.content_type(', i)) {
975
+ const start = i + '.content_type('.length;
976
+ let d = 1;
977
+ let q = false;
978
+ let j = start;
979
+ for (; j < recv.length && d > 0; j++) {
980
+ const c = recv[j];
981
+ if (q) {
982
+ if (c === '\\')
983
+ j++;
984
+ else if (c === '"')
985
+ q = false;
986
+ }
987
+ else if (c === '"')
988
+ q = true;
989
+ else if (c === '(')
990
+ d++;
991
+ else if (c === ')')
992
+ d--;
993
+ }
994
+ arg = d === 0 ? recv.slice(start, j - 1).trim() : undefined;
995
+ i = j - 1;
996
+ }
997
+ }
998
+ if (arg === undefined)
999
+ return false;
1000
+ const literal = /^"([^"]*)"$/.exec(arg);
1001
+ if (literal) {
1002
+ const type = literal[1];
1003
+ if (!/^\s*[\w.-]+\/[\w.+-]+/.test(type))
1004
+ return false;
1005
+ return !/^\s*(?:text\/html|application\/xhtml\+xml|text\/xml|application\/xml|[\w.-]+\/[\w.-]+\+xml)\b/i.test(type);
1006
+ }
1007
+ if (/^(?:(?:actix_web\s*::\s*)?(?:http\s*::\s*header\s*::\s*)?)ContentType\s*::\s*(?:plaintext|json|octet_stream|form_url_encoded)\s*\(\s*\)$/.test(arg))
1008
+ return true;
1009
+ return /^(?:::)?mime\s*::\s*(?:TEXT_PLAIN|TEXT_PLAIN_UTF_8|APPLICATION_JSON|APPLICATION_OCTET_STREAM)$/.test(arg);
1010
+ }
659
1011
  /**
660
1012
  * Compute, per sink type, the set of C# variables whose value has passed through
661
1013
  * a sanitizer for that type (cognium-dev C#/.NET Phase-1). Seeds from
@@ -669,6 +1021,7 @@ function csharpSanitizedVarsByType(code) {
669
1021
  const lines = code.split('\n');
670
1022
  const byType = new Map();
671
1023
  const assignRe = /^\s*(?:var\s+|[A-Za-z_][\w.<>\[\]]*\s+)?([A-Za-z_]\w*)\s*=\s*(.+?);?\s*$/;
1024
+ const sanitizers = csharpSanitizersFor(code);
672
1025
  for (const { type } of CSHARP_SANITIZER_RES) {
673
1026
  if (!byType.has(type))
674
1027
  byType.set(type, new Set());
@@ -679,7 +1032,7 @@ function csharpSanitizedVarsByType(code) {
679
1032
  if (!m)
680
1033
  continue;
681
1034
  const [, lhs, rhs] = m;
682
- for (const { re, type } of CSHARP_SANITIZER_RES) {
1035
+ for (const { re, type } of sanitizers) {
683
1036
  if (re.test(rhs))
684
1037
  byType.get(type).add(lhs);
685
1038
  }
@@ -1039,13 +1392,26 @@ export class SinkFilterPass {
1039
1392
  // That is SecuriBench Micro `Collections6:47`, an `/* OK */` line. The
1040
1393
  // false positive is currently masked by the const-prop veto in #328, so
1041
1394
  // this gate is behaviour-neutral today; its purpose is to make lifting that
1042
- // veto safe, which is the sequencing chosen on #328.
1395
+ // veto safe, which is the sequencing chosen on #328. #584 widened it: a read
1396
+ // of a key that WAS put is also dropped when every value put on that local,
1397
+ // non-escaping map is a string literal (`isLiteralOnlyLocalMap`).
1043
1398
  const sources = ctx.code
1044
1399
  ? executableSources.filter(s => !isUnwrittenLocalMapKeyRead(ctx.code, s))
1045
1400
  : executableSources;
1046
1401
  // Build merged sinks, deduplicating JS DOM sinks that may overlap with config sinks.
1047
1402
  const sinks = [...taintMatcher.sinks];
1048
1403
  for (const s of langSources.additionalSinks) {
1404
+ const springReturn = s.type === 'xss' &&
1405
+ typeof s.location === 'string' &&
1406
+ s.location.startsWith('Spring handler returns HTML');
1407
+ if (springReturn) {
1408
+ const i = sinks.findIndex(x => x.line === s.line && x.type === 'xss');
1409
+ if (i >= 0)
1410
+ sinks[i] = s;
1411
+ else if (!sinks.some(x => x.line === s.line && x.cwe === s.cwe && x.type === s.type))
1412
+ sinks.push(s);
1413
+ continue;
1414
+ }
1049
1415
  if (!sinks.some(x => x.line === s.line && x.cwe === s.cwe && x.type === s.type)) {
1050
1416
  sinks.push(s);
1051
1417
  }
@@ -1059,7 +1425,7 @@ export class SinkFilterPass {
1059
1425
  // Stage 2 — clean array elements
1060
1426
  filtered = filterCleanArraySinks(filtered, calls, constProp.taintedArrayElements, constProp.symbols);
1061
1427
  // Stage 3 — clean variables
1062
- filtered = filterCleanVariableSinks(filtered, calls, constProp.tainted, constProp.symbols, dfg, constProp.sanitizedVars, constProp.synchronizedLines, language);
1428
+ filtered = filterCleanVariableSinks(filtered, calls, constProp.tainted, constProp.symbols, dfg, constProp.sanitizedVars, constProp.synchronizedLines, language, constProp.sanitizedVarUncovered);
1063
1429
  // Stage 4 — sanitized sinks. A sanitizer on one operand does not drop
1064
1430
  // the sink while another source operand is still raw (#641).
1065
1431
  filtered = filterSanitizedSinks(filtered, sanitizers, calls, sources);
@@ -1582,8 +1948,12 @@ export class SinkFilterPass {
1582
1948
  // Bare identifier — parameter/field lookup.
1583
1949
  if (varName !== '' && argExpr === varName && !/[(.]/.test(argExpr)) {
1584
1950
  const t = resolveIdentifierType(varName, ctorCall.in_method, irTypes);
1585
- if (isArgvContainerType(t))
1951
+ // cognium-dev#626: a `List<String>` PARAMETER is argv-form only while no
1952
+ // same-file caller hands it a shell argv (`/bin/sh -c <script>`): then
1953
+ // the tainted element is the script, so the suppression proves nothing.
1954
+ if (isArgvContainerType(t) && !sameFileCallerPassesShellArgv(varName, ctorCall.in_method, ctorCall.location.line, irTypes, irCalls)) {
1586
1955
  return false;
1956
+ }
1587
1957
  }
1588
1958
  // Method call — return-type lookup. `variable` field carries the
1589
1959
  // callee name for call-expression args.
@@ -2042,7 +2412,51 @@ export class SinkFilterPass {
2042
2412
  if (sink.type !== 'ssrf')
2043
2413
  return true;
2044
2414
  const sinkLineText = sourceLines[sink.line - 1] ?? '';
2045
- return !FIXED_HOST_TEMPLATE_RE.test(sinkLineText);
2415
+ if (FIXED_HOST_TEMPLATE_RE.test(sinkLineText))
2416
+ return false;
2417
+ // `${API_BASE}/path` where API_BASE is bound once by `const` to a
2418
+ // literal origin: the authority is constant and closed by `/` (#680).
2419
+ for (const m of sinkLineText.matchAll(CONST_BASE_TEMPLATE_RE)) {
2420
+ if (isConstLiteralOrigin(ctx.code, m[1]))
2421
+ return false;
2422
+ }
2423
+ return true;
2424
+ });
2425
+ }
2426
+ // Stage 15k — JS/TS redos: `new RegExp(`…${CONST}…`)` whose every
2427
+ // interpolation is a module `const` bound to a string literal has a
2428
+ // constant pattern; a tainted subject alone is not ReDoS. (cognium-dev#681)
2429
+ if (['javascript', 'typescript'].includes(language)) {
2430
+ const sourceLines = ctx.code.split('\n');
2431
+ filtered = filtered.filter(sink => {
2432
+ if (sink.type !== 'redos')
2433
+ return true;
2434
+ const tpl = regExpTemplateArgument(ctx.code, sourceLines, sink.line);
2435
+ if (tpl === null)
2436
+ return true;
2437
+ let pattern = tpl;
2438
+ let interpolations = 0;
2439
+ let allConst = true;
2440
+ // Replace each `${ NAME }` as written (whitespace inside the braces
2441
+ // included) by the const's literal value.
2442
+ pattern = pattern.replace(/\$\{([^}]*)\}/g, (_full, raw) => {
2443
+ interpolations++;
2444
+ const name = raw.trim();
2445
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
2446
+ allConst = false;
2447
+ return '';
2448
+ }
2449
+ const value = constStringLiteralValue(ctx.code, name);
2450
+ if (value === null) {
2451
+ allConst = false;
2452
+ return '';
2453
+ }
2454
+ return value;
2455
+ });
2456
+ if (interpolations === 0 || !allConst)
2457
+ return true;
2458
+ // A constant pattern can still backtrack on a tainted subject.
2459
+ return NESTED_QUANTIFIER_RE.test(pattern);
2046
2460
  });
2047
2461
  }
2048
2462
  // Stage 15f — JS/TS ssrf: anchored host allow-list reject-guard.
@@ -2118,6 +2532,50 @@ export class SinkFilterPass {
2118
2532
  return !covered || credit.rawTaintReaches(sink.type, sink.line, sanitizedVars, csSeeds);
2119
2533
  });
2120
2534
  }
2535
+ // Stage 15k' — C# `Content(body, contentType)` serves HTML only when the
2536
+ // content type is HTML (cognium-dev#580). The `Content` xss sink stays when
2537
+ // at least one `Content` call on its line is the controller helper with an
2538
+ // HTML or unknown content type.
2539
+ if (language === 'csharp') {
2540
+ const csContentLines = ctx.code.split('\n');
2541
+ filtered = filtered.filter(sink => {
2542
+ if (sink.type !== 'xss' || sink.method !== 'Content')
2543
+ return true;
2544
+ const contentCalls = callsByLine.get(sink.line)?.filter(c => c.method_name === 'Content') ?? [];
2545
+ // The call must also sit in a type that can inherit the MVC helper (#580).
2546
+ return contentCalls.length === 0 || contentCalls.some(c => csharpContentCallInheritsHelper(c, ctx.graph.ir.types, csContentLines) && csharpContentServesHtml(c));
2547
+ });
2548
+ }
2549
+ // Stage 15k'' — Rust `.body(x)` with a literal non-HTML content type is
2550
+ // data, not markup (cognium-dev#478). The xss sink stays unless every
2551
+ // `body` call on its line sits on a chain that declared such a type.
2552
+ if (language === 'rust') {
2553
+ filtered = filtered.filter(sink => {
2554
+ if (sink.type !== 'xss' || sink.method !== 'body')
2555
+ return true;
2556
+ const bodyCalls = callsByLine.get(sink.line)?.filter(c => c.method_name === 'body') ?? [];
2557
+ return bodyCalls.length === 0 || !bodyCalls.every(rustBodyDeclaresNonHtmlType);
2558
+ });
2559
+ }
2560
+ // Stage 15m — bash ssrf: fixed-host URL word (cognium-dev#495, #669).
2561
+ // The bash analogue of Stage 15i: `curl -- "https://api.example.com/${p}"`
2562
+ // can only reach the literal host, whatever `$p` holds. Every word on the
2563
+ // line that expands a variable must be such a URL; a word like `"$url"`,
2564
+ // `"https://${host}/x"` or `"https://api.example.com${p}"` (no `/` to
2565
+ // close the authority, so `$p` may be `@evil.com`) keeps the sink.
2566
+ if (language === 'bash') {
2567
+ const sourceLines = ctx.code.split('\n');
2568
+ filtered = filtered.filter(sink => {
2569
+ if (sink.type !== 'ssrf')
2570
+ return true;
2571
+ const words = (sourceLines[sink.line - 1] ?? '').match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? [];
2572
+ const expanding = words.filter(w => !w.startsWith("'") && w.includes('$'));
2573
+ // Unquoted (`https://h/$p`), the expansion is word-split, so `$p`
2574
+ // can add a second URL or a flag: only a double-quoted word counts.
2575
+ return expanding.length === 0 ||
2576
+ !expanding.every(w => w.startsWith('"') && BASH_FIXED_HOST_URL_RE.test(w.slice(1, -1)));
2577
+ });
2578
+ }
2121
2579
  // Stage 15l — C# ssrf: HttpClient with a constant BaseAddress and a
2122
2580
  // relative request path. (cognium-dev#286 D)
2123
2581
  //
@@ -2779,14 +3237,13 @@ function filterCleanArraySinks(sinks, calls, taintedArrayElements, symbols) {
2779
3237
  const callsAtSink = callsByLine.get(sink.line) ?? [];
2780
3238
  for (const call of callsAtSink) {
2781
3239
  for (const arg of call.arguments) {
2782
- const arrayAccessMatch = arg.expression?.match(/^(\w+)\[(\d+|[^[\]]+)\]$/);
2783
- if (arrayAccessMatch) {
2784
- const arrayName = arrayAccessMatch[1];
2785
- let indexStr = arrayAccessMatch[2];
2786
- indexStr = evaluateSimpleExpression(indexStr, symbols);
2787
- const taintedIndices = taintedArrayElements.get(arrayName);
3240
+ const arrayAccess = parseJavaArrayAccessExpression(arg.expression);
3241
+ if (arrayAccess) {
3242
+ let indexKey = arrayAccess.indexKey;
3243
+ indexKey = evaluateSimpleExpression(indexKey, symbols);
3244
+ const taintedIndices = taintedArrayElements.get(arrayAccess.arrayName);
2788
3245
  if (taintedIndices !== undefined) {
2789
- const isTainted = taintedIndices.has(indexStr) || taintedIndices.has('*');
3246
+ const isTainted = taintedIndices.has(indexKey) || taintedIndices.has('*');
2790
3247
  if (!isTainted)
2791
3248
  return false;
2792
3249
  }
@@ -2796,7 +3253,7 @@ function filterCleanArraySinks(sinks, calls, taintedArrayElements, symbols) {
2796
3253
  return true;
2797
3254
  });
2798
3255
  }
2799
- export function filterCleanVariableSinks(sinks, calls, taintedVars, symbols, dfg, sanitizedVars, synchronizedLines, language) {
3256
+ export function filterCleanVariableSinks(sinks, calls, taintedVars, symbols, dfg, sanitizedVars, synchronizedLines, language, sanitizedVarUncovered) {
2800
3257
  const fieldNames = new Set();
2801
3258
  if (dfg) {
2802
3259
  for (const def of dfg.defs) {
@@ -2862,8 +3319,33 @@ export function filterCleanVariableSinks(sinks, calls, taintedVars, symbols, dfg
2862
3319
  argClean = false;
2863
3320
  break;
2864
3321
  }
2865
- if (sanitizedVars?.has(scopedName) || sanitizedVars?.has(varName))
2866
- continue;
3322
+ // #589 review: when any `method:var` sanitizer exists, decide from
3323
+ // the scoped name only — do not treat a sibling method's encoder
3324
+ // (or its unscoped alias) as sanitizing this method's local.
3325
+ const suffix = `:${varName}`;
3326
+ let hasScopedSanitizer = false;
3327
+ if (sanitizedVars) {
3328
+ for (const s of sanitizedVars) {
3329
+ if (s !== varName && s.endsWith(suffix)) {
3330
+ hasScopedSanitizer = true;
3331
+ break;
3332
+ }
3333
+ }
3334
+ }
3335
+ const isSanitizedHere = sanitizedVars?.has(scopedName)
3336
+ || (!hasScopedSanitizer && !!sanitizedVars?.has(varName));
3337
+ if (isSanitizedHere) {
3338
+ // #589: a context-specific encoder (`escapeHtml`, `encodeForHTML`, …)
3339
+ // does not clean `trust_boundary`; that sink on the same
3340
+ // variable stays.
3341
+ const uncovered = hasScopedSanitizer
3342
+ ? sanitizedVarUncovered?.get(scopedName)
3343
+ : (sanitizedVarUncovered?.get(scopedName) ?? sanitizedVarUncovered?.get(varName));
3344
+ if (!uncovered || !uncovered.has(sink.type))
3345
+ continue;
3346
+ argClean = false;
3347
+ break;
3348
+ }
2867
3349
  if (taintedVars.has(scopedName) || taintedVars.has(varName)) {
2868
3350
  argClean = false;
2869
3351
  break;