circle-ir 4.14.1 → 4.15.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 (95) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +9 -2
  3. package/dist/analysis/config-loader.js.map +1 -1
  4. package/dist/analysis/const-table-lookup.d.ts +29 -0
  5. package/dist/analysis/const-table-lookup.d.ts.map +1 -0
  6. package/dist/analysis/const-table-lookup.js +145 -0
  7. package/dist/analysis/const-table-lookup.js.map +1 -0
  8. package/dist/analysis/findings.d.ts.map +1 -1
  9. package/dist/analysis/findings.js +15 -4
  10. package/dist/analysis/findings.js.map +1 -1
  11. package/dist/analysis/go-strings-replacer.d.ts +21 -0
  12. package/dist/analysis/go-strings-replacer.d.ts.map +1 -0
  13. package/dist/analysis/go-strings-replacer.js +145 -0
  14. package/dist/analysis/go-strings-replacer.js.map +1 -0
  15. package/dist/analysis/interprocedural.d.ts.map +1 -1
  16. package/dist/analysis/interprocedural.js +53 -0
  17. package/dist/analysis/interprocedural.js.map +1 -1
  18. package/dist/analysis/java-canonical-prefix.d.ts +42 -0
  19. package/dist/analysis/java-canonical-prefix.d.ts.map +1 -0
  20. package/dist/analysis/java-canonical-prefix.js +145 -0
  21. package/dist/analysis/java-canonical-prefix.js.map +1 -0
  22. package/dist/analysis/java-crlf-strip.d.ts +30 -0
  23. package/dist/analysis/java-crlf-strip.d.ts.map +1 -0
  24. package/dist/analysis/java-crlf-strip.js +195 -0
  25. package/dist/analysis/java-crlf-strip.js.map +1 -0
  26. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts +2 -0
  27. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts.map +1 -1
  28. package/dist/analysis/passes/cleartext-credential-transport-pass.js +64 -12
  29. package/dist/analysis/passes/cleartext-credential-transport-pass.js.map +1 -1
  30. package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
  31. package/dist/analysis/passes/cross-file-pass.js +43 -0
  32. package/dist/analysis/passes/cross-file-pass.js.map +1 -1
  33. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  34. package/dist/analysis/passes/csharp-sanitizer-credit.js +18 -0
  35. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
  36. package/dist/analysis/passes/insecure-cookie-pass.d.ts +11 -4
  37. package/dist/analysis/passes/insecure-cookie-pass.d.ts.map +1 -1
  38. package/dist/analysis/passes/insecure-cookie-pass.js +30 -15
  39. package/dist/analysis/passes/insecure-cookie-pass.js.map +1 -1
  40. package/dist/analysis/passes/interprocedural-pass.d.ts +28 -0
  41. package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
  42. package/dist/analysis/passes/interprocedural-pass.js +31 -3
  43. package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
  44. package/dist/analysis/passes/language-sources-pass.d.ts +41 -2
  45. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  46. package/dist/analysis/passes/language-sources-pass.js +894 -10
  47. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  48. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts +8 -0
  49. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
  50. package/dist/analysis/passes/param-source-flow-gate-pass.js +162 -6
  51. package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
  52. package/dist/analysis/passes/scan-secrets-pass.d.ts.map +1 -1
  53. package/dist/analysis/passes/scan-secrets-pass.js +24 -3
  54. package/dist/analysis/passes/scan-secrets-pass.js.map +1 -1
  55. package/dist/analysis/passes/sink-filter-pass.d.ts +1 -1
  56. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  57. package/dist/analysis/passes/sink-filter-pass.js +255 -25
  58. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  59. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  60. package/dist/analysis/passes/taint-propagation-pass.js +268 -5
  61. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  62. package/dist/analysis/scalar-derivation.d.ts +25 -0
  63. package/dist/analysis/scalar-derivation.d.ts.map +1 -0
  64. package/dist/analysis/scalar-derivation.js +196 -0
  65. package/dist/analysis/scalar-derivation.js.map +1 -0
  66. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  67. package/dist/analysis/taint-matcher.js +172 -8
  68. package/dist/analysis/taint-matcher.js.map +1 -1
  69. package/dist/analysis/taint-propagation.d.ts +9 -0
  70. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  71. package/dist/analysis/taint-propagation.js +260 -11
  72. package/dist/analysis/taint-propagation.js.map +1 -1
  73. package/dist/analyzer.d.ts.map +1 -1
  74. package/dist/analyzer.js +8 -1
  75. package/dist/analyzer.js.map +1 -1
  76. package/dist/browser/circle-ir.js +3403 -1225
  77. package/dist/core/circle-ir-core.cjs +598 -26
  78. package/dist/core/circle-ir-core.js +598 -26
  79. package/dist/core/extractors/calls.js +19 -0
  80. package/dist/core/extractors/calls.js.map +1 -1
  81. package/dist/core/extractors/dfg.js +240 -6
  82. package/dist/core/extractors/dfg.js.map +1 -1
  83. package/dist/graph/code-graph.d.ts +9 -0
  84. package/dist/graph/code-graph.d.ts.map +1 -1
  85. package/dist/graph/code-graph.js +11 -0
  86. package/dist/graph/code-graph.js.map +1 -1
  87. package/dist/languages/plugins/go.d.ts.map +1 -1
  88. package/dist/languages/plugins/go.js +19 -0
  89. package/dist/languages/plugins/go.js.map +1 -1
  90. package/dist/resolution/cross-file.d.ts +7 -0
  91. package/dist/resolution/cross-file.d.ts.map +1 -1
  92. package/dist/resolution/cross-file.js +10 -1
  93. package/dist/resolution/cross-file.js.map +1 -1
  94. package/dist/wasm/web-tree-sitter.wasm +0 -0
  95. package/package.json +3 -3
@@ -20,6 +20,7 @@ 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
+ import { identifierOutsideSanitizerCalls } from '../taint-propagation.js';
23
24
  import { resolveIdentifierType, resolveCallReturnType, isArgvContainerType, } from '../arg-type-resolver.js';
24
25
  /** Escape regex metacharacters for safe pattern building. */
25
26
  function escapeReSf(s) {
@@ -463,6 +464,110 @@ const SERVER_SIGNAL_RE = /["']use server["']|\b(?:getServerSideProps|getStaticPr
463
464
  // interpolated, the destination host is not attacker-controlled. Matches a
464
465
  // backtick + scheme + a literal host run (no `${` before the first `/`).
465
466
  const FIXED_HOST_TEMPLATE_RE = /`https?:\/\/[^/${}\s`]+[/`]/;
467
+ /** A bash URL word whose scheme and host are literal and closed by `/` (#495). */
468
+ const BASH_FIXED_HOST_URL_RE = /^(?:https?|ftp):\/\/[A-Za-z0-9.-]+(?::\d+)?\//;
469
+ /**
470
+ * True when the string literal that ended at `end` is the WHOLE initializer:
471
+ * the next code token is `;`, `,`, end of input, or (ASI) a token on a later
472
+ * line that cannot continue the expression. A same-line operator (`+`, `.`,
473
+ * `||` …) or a next-line `+` / `.` / `(` / `[` / template continuation means
474
+ * the literal is only the start of a larger expression, so it is not a
475
+ * constant. An optional TS `as const` / `as string` suffix is skipped (it may
476
+ * sit on the next line).
477
+ * (cognium-dev#680, #681)
478
+ */
479
+ function literalEndsInitializer(code, end) {
480
+ let i = end;
481
+ let crossedNewline = false;
482
+ const asConst = /^\s+as\s+(?:const|string)\b/.exec(code.slice(i, i + 64));
483
+ if (asConst) {
484
+ i += asConst[0].length;
485
+ crossedNewline = asConst[0].includes('\n');
486
+ }
487
+ for (;;) {
488
+ while (i < code.length && /\s/.test(code[i])) {
489
+ if (code[i] === '\n')
490
+ crossedNewline = true;
491
+ i++;
492
+ }
493
+ if (code.startsWith('//', i)) {
494
+ const nl = code.indexOf('\n', i);
495
+ i = nl === -1 ? code.length : nl;
496
+ }
497
+ else if (code.startsWith('/*', i)) {
498
+ const close = code.indexOf('*/', i + 2);
499
+ if (close === -1)
500
+ return false;
501
+ if (code.slice(i, close).includes('\n'))
502
+ crossedNewline = true;
503
+ i = close + 2;
504
+ }
505
+ else
506
+ break;
507
+ }
508
+ if (i >= code.length)
509
+ return true;
510
+ const c = code[i];
511
+ if (c === ';' || c === ',')
512
+ return true;
513
+ // ASI: a following line that begins a new statement terminates the initializer.
514
+ // A line that starts with `as` / `satisfies` / `in` / `instanceof` continues it.
515
+ if (/^(?:as|satisfies|in|instanceof)(?![\w$])/.test(code.slice(i, i + 12)))
516
+ return false;
517
+ return crossedNewline && !/[-+*/%.?:|&<>=!~^(\[`]/.test(c);
518
+ }
519
+ /** Decode `\xNN` / `\uNNNN` / `\u{…}` JS string escapes; other escapes are kept verbatim. */
520
+ function decodeJsHexEscapes(value) {
521
+ 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) => {
522
+ const hex = x ?? u4 ?? ub;
523
+ if (hex === undefined)
524
+ return m;
525
+ const cp = parseInt(hex, 16);
526
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
527
+ });
528
+ }
529
+ /**
530
+ * The value of `name` when it is bound exactly once in the file, by `const`,
531
+ * to a plain string literal (no interpolation) that is the whole initializer,
532
+ * and has no other `let` / `var` / `function` binding; otherwise null.
533
+ * (cognium-dev#681)
534
+ */
535
+ function constStringLiteralValue(code, name) {
536
+ const esc = name.replace(/\$/g, '\\$');
537
+ const decl = new RegExp(`(?:^|[^\\w$.])const\\s+${esc}\\s*(?::\\s*string\\s*)?=\\s*(?:'((?:\\\\.|[^'\\\\\\n])*)'|"((?:\\\\.|[^"\\\\\\n])*)"|\`([^\`$\\\\]*)\`)`, 'g');
538
+ const decls = [...code.matchAll(decl)];
539
+ if (decls.length !== 1)
540
+ return null;
541
+ if (!literalEndsInitializer(code, decls[0].index + decls[0][0].length))
542
+ return null;
543
+ const anyBinding = new RegExp(`(?:^|[^\\w$.])(?:const|let|var|function)\\s+${esc}\\b`, 'g');
544
+ if ([...code.matchAll(anyBinding)].length !== 1)
545
+ return null;
546
+ return decodeJsHexEscapes(decls[0][1] ?? decls[0][2] ?? decls[0][3] ?? '');
547
+ }
548
+ /** A group that holds an unbounded quantifier and is itself quantified: `(a+)+`, `(x*)*`, `(\w{2,})+`, `(a+){2,}`. */
549
+ const NESTED_QUANTIFIER_RE = /\([^()]*(?:[+*]|\{\d+,\d*\})[^()]*\)\s*[+*{]/;
550
+ /** A template literal that starts with `${IDENT}/` (cognium-dev#680). */
551
+ const CONST_BASE_TEMPLATE_RE = /`\$\{\s*([A-Za-z_$][\w$]*)\s*\}\//g;
552
+ /**
553
+ * True when `name` is bound exactly once in the file, by `const`, to a string
554
+ * literal http(s) origin with a literal host, and is never reassigned.
555
+ */
556
+ function isConstLiteralOrigin(code, name) {
557
+ const esc = name.replace(/\$/g, '\\$');
558
+ const decl = new RegExp(`(?:^|[^\\w$.])(const|let|var)\\s+${esc}\\s*(?::\\s*string\\s*)?=\\s*(['"\`])(https?://[^/'"\`\\s$]+(?:/[^'"\`$]*)?)\\2`, 'gm');
559
+ const decls = [...code.matchAll(decl)];
560
+ if (decls.length !== 1 || decls[0][1] !== 'const')
561
+ return false;
562
+ if (!literalEndsInitializer(code, decls[0].index + decls[0][0].length))
563
+ return false;
564
+ const anyBinding = new RegExp(`(?:^|[^\\w$.])(?:const|let|var)\\s+${esc}\\b`, 'g');
565
+ if ([...code.matchAll(anyBinding)].length !== 1)
566
+ return false;
567
+ const reassign = new RegExp(`(?:^|[^\\w$.])${esc}\\s*(?:[-+]?=)(?!=)`, 'g');
568
+ const rest = code.slice(0, decls[0].index) + code.slice(decls[0].index + decls[0][0].length);
569
+ return !reassign.test(rest);
570
+ }
466
571
  /**
467
572
  * Returns the 1-indexed line numbers that a Java `if (x.contains("..")) throw/
468
573
  * return` reject-guard makes path-traversal-safe (cognium-dev#269). A line is
@@ -655,6 +760,33 @@ function isUnwrittenLocalMapKeyRead(code, source) {
655
760
  return false;
656
761
  return true;
657
762
  }
763
+ /**
764
+ * Is this C# `Content(...)` call the controller helper writing an HTML body
765
+ * (cognium-dev#580)?
766
+ *
767
+ * - The helper is inherited (`ControllerBase.Content`, `PageModel.Content`),
768
+ * so it is called unqualified or on `this` / `base`. `note.Content(q)` is
769
+ * some other type's method.
770
+ * - The content type is argument 1. A literal or `MediaTypeNames` constant
771
+ * 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
772
+ * one-argument overload.
773
+ */
774
+ function csharpContentServesHtml(call) {
775
+ if (call.receiver !== null && call.receiver !== 'this' && call.receiver !== 'base')
776
+ return false;
777
+ const contentType = call.arguments.find(a => a.position === 1)?.expression.trim();
778
+ if (contentType === undefined)
779
+ return true;
780
+ const literal = /^@?"([^"]*)"$/.exec(contentType);
781
+ // XML types (`text/xml`, `application/xml`, `image/svg+xml`, any `+xml`)
782
+ // render XHTML / SVG markup and run its scripts, so they count as HTML.
783
+ if (literal)
784
+ return /^\s*(?:text\/html|text\/xml|application\/xml|[\w.-]+\/[\w.-]+\+xml)\b/i.test(literal[1]);
785
+ const named = /^(?:System\s*\.\s*Net\s*\.\s*Mime\s*\.\s*)?MediaTypeNames\s*\.\s*(Text|Application)\s*\.\s*(\w+)$/.exec(contentType);
786
+ if (named)
787
+ return named[2] === 'Html' || named[2] === 'Xml';
788
+ return true;
789
+ }
658
790
  /**
659
791
  * Compute, per sink type, the set of C# variables whose value has passed through
660
792
  * a sanitizer for that type (cognium-dev C#/.NET Phase-1). Seeds from
@@ -1059,8 +1191,9 @@ export class SinkFilterPass {
1059
1191
  filtered = filterCleanArraySinks(filtered, calls, constProp.taintedArrayElements, constProp.symbols);
1060
1192
  // Stage 3 — clean variables
1061
1193
  filtered = filterCleanVariableSinks(filtered, calls, constProp.tainted, constProp.symbols, dfg, constProp.sanitizedVars, constProp.synchronizedLines, language);
1062
- // Stage 4 — sanitized sinks
1063
- filtered = filterSanitizedSinks(filtered, sanitizers, calls);
1194
+ // Stage 4 — sanitized sinks. A sanitizer on one operand does not drop
1195
+ // the sink while another source operand is still raw (#641).
1196
+ filtered = filterSanitizedSinks(filtered, sanitizers, calls, sources);
1064
1197
  // Stage 5 — Python XPath FP reduction
1065
1198
  if (language === 'python') {
1066
1199
  const { pyTaintedVars, pySanitizedVars } = langSources;
@@ -1166,9 +1299,17 @@ export class SinkFilterPass {
1166
1299
  if (sink.type !== 'xss')
1167
1300
  return true;
1168
1301
  const sinkLineText = sourceLines[sink.line - 1] ?? '';
1169
- // 6a. If a sanitizer is used on this line, suppress the finding
1170
- if (JS_XSS_SANITIZERS.some(p => p.test(sinkLineText)))
1171
- return false;
1302
+ // 6a. If a sanitizer is used on this line, suppress the finding.
1303
+ // The sanitizer covers its own operand. A source operand that is
1304
+ // still outside that call keeps the sink (#641).
1305
+ if (JS_XSS_SANITIZERS.some(p => p.test(sinkLineText))) {
1306
+ const callsAtSink = callsByLine.get(sink.line) ?? [];
1307
+ const covering = sanitizers.filter(s => s.line === sink.line && sanitizerCoversSink(s, sink.type));
1308
+ const rawOperand = sources.some(s => !!s.variable &&
1309
+ identifierOutsideSanitizerCalls(s.variable, callsAtSink, covering, sink.type));
1310
+ if (!rawOperand)
1311
+ return false;
1312
+ }
1172
1313
  // 6b. If the RHS is a pure string literal, suppress (e.g., `.innerHTML = "<div>Hello</div>"`)
1173
1314
  // Match: `.innerHTML = "..."` or `.innerHTML = '...'` or `.innerHTML = `...``
1174
1315
  const assignmentMatch = sinkLineText.match(/\.(?:innerHTML|outerHTML)\s*=\s*(.+)/);
@@ -2032,7 +2173,51 @@ export class SinkFilterPass {
2032
2173
  if (sink.type !== 'ssrf')
2033
2174
  return true;
2034
2175
  const sinkLineText = sourceLines[sink.line - 1] ?? '';
2035
- return !FIXED_HOST_TEMPLATE_RE.test(sinkLineText);
2176
+ if (FIXED_HOST_TEMPLATE_RE.test(sinkLineText))
2177
+ return false;
2178
+ // `${API_BASE}/path` where API_BASE is bound once by `const` to a
2179
+ // literal origin: the authority is constant and closed by `/` (#680).
2180
+ for (const m of sinkLineText.matchAll(CONST_BASE_TEMPLATE_RE)) {
2181
+ if (isConstLiteralOrigin(ctx.code, m[1]))
2182
+ return false;
2183
+ }
2184
+ return true;
2185
+ });
2186
+ }
2187
+ // Stage 15k — JS/TS redos: `new RegExp(`…${CONST}…`)` whose every
2188
+ // interpolation is a module `const` bound to a string literal has a
2189
+ // constant pattern; a tainted subject alone is not ReDoS. (cognium-dev#681)
2190
+ if (['javascript', 'typescript'].includes(language)) {
2191
+ const sourceLines = ctx.code.split('\n');
2192
+ filtered = filtered.filter(sink => {
2193
+ if (sink.type !== 'redos')
2194
+ return true;
2195
+ const m = (sourceLines[sink.line - 1] ?? '').match(/new\s+RegExp\s*\(\s*`((?:\\.|[^`\\])*)`/);
2196
+ if (!m)
2197
+ return true;
2198
+ let pattern = m[1];
2199
+ let interpolations = 0;
2200
+ let allConst = true;
2201
+ // Replace each `${ NAME }` as written (whitespace inside the braces
2202
+ // included) by the const's literal value.
2203
+ pattern = pattern.replace(/\$\{([^}]*)\}/g, (_full, raw) => {
2204
+ interpolations++;
2205
+ const name = raw.trim();
2206
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
2207
+ allConst = false;
2208
+ return '';
2209
+ }
2210
+ const value = constStringLiteralValue(ctx.code, name);
2211
+ if (value === null) {
2212
+ allConst = false;
2213
+ return '';
2214
+ }
2215
+ return value;
2216
+ });
2217
+ if (interpolations === 0 || !allConst)
2218
+ return true;
2219
+ // A constant pattern can still backtrack on a tainted subject.
2220
+ return NESTED_QUANTIFIER_RE.test(pattern);
2036
2221
  });
2037
2222
  }
2038
2223
  // Stage 15f — JS/TS ssrf: anchored host allow-list reject-guard.
@@ -2108,6 +2293,37 @@ export class SinkFilterPass {
2108
2293
  return !covered || credit.rawTaintReaches(sink.type, sink.line, sanitizedVars, csSeeds);
2109
2294
  });
2110
2295
  }
2296
+ // Stage 15k' — C# `Content(body, contentType)` serves HTML only when the
2297
+ // content type is HTML (cognium-dev#580). The `Content` xss sink stays when
2298
+ // at least one `Content` call on its line is the controller helper with an
2299
+ // HTML or unknown content type.
2300
+ if (language === 'csharp') {
2301
+ filtered = filtered.filter(sink => {
2302
+ if (sink.type !== 'xss' || sink.method !== 'Content')
2303
+ return true;
2304
+ const contentCalls = callsByLine.get(sink.line)?.filter(c => c.method_name === 'Content') ?? [];
2305
+ return contentCalls.length === 0 || contentCalls.some(csharpContentServesHtml);
2306
+ });
2307
+ }
2308
+ // Stage 15m — bash ssrf: fixed-host URL word (cognium-dev#495, #669).
2309
+ // The bash analogue of Stage 15i: `curl -- "https://api.example.com/${p}"`
2310
+ // can only reach the literal host, whatever `$p` holds. Every word on the
2311
+ // line that expands a variable must be such a URL; a word like `"$url"`,
2312
+ // `"https://${host}/x"` or `"https://api.example.com${p}"` (no `/` to
2313
+ // close the authority, so `$p` may be `@evil.com`) keeps the sink.
2314
+ if (language === 'bash') {
2315
+ const sourceLines = ctx.code.split('\n');
2316
+ filtered = filtered.filter(sink => {
2317
+ if (sink.type !== 'ssrf')
2318
+ return true;
2319
+ const words = (sourceLines[sink.line - 1] ?? '').match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? [];
2320
+ const expanding = words.filter(w => !w.startsWith("'") && w.includes('$'));
2321
+ // Unquoted (`https://h/$p`), the expansion is word-split, so `$p`
2322
+ // can add a second URL or a flag: only a double-quoted word counts.
2323
+ return expanding.length === 0 ||
2324
+ !expanding.every(w => w.startsWith('"') && BASH_FIXED_HOST_URL_RE.test(w.slice(1, -1)));
2325
+ });
2326
+ }
2111
2327
  // Stage 15l — C# ssrf: HttpClient with a constant BaseAddress and a
2112
2328
  // relative request path. (cognium-dev#286 D)
2113
2329
  //
@@ -2881,9 +3097,14 @@ export function filterCleanVariableSinks(sinks, calls, taintedVars, symbols, dfg
2881
3097
  return true;
2882
3098
  });
2883
3099
  }
2884
- export function filterSanitizedSinks(sinks, sanitizers, calls) {
3100
+ export function filterSanitizedSinks(sinks, sanitizers, calls, sources) {
2885
3101
  if (!sanitizers || sanitizers.length === 0)
2886
3102
  return sinks;
3103
+ const sourceVars = [];
3104
+ for (const source of sources ?? []) {
3105
+ if (source.variable && /^[A-Za-z_$][\w$]*$/.test(source.variable))
3106
+ sourceVars.push(source.variable);
3107
+ }
2887
3108
  const sanitizersByLine = new Map();
2888
3109
  for (const san of sanitizers) {
2889
3110
  const existing = sanitizersByLine.get(san.line) ?? [];
@@ -2900,24 +3121,33 @@ export function filterSanitizedSinks(sinks, sanitizers, calls) {
2900
3121
  const lineSanitizers = sanitizersByLine.get(sink.line);
2901
3122
  if (!lineSanitizers || lineSanitizers.length === 0)
2902
3123
  return true;
2903
- for (const san of lineSanitizers) {
2904
- if (sanitizerCoversSink(san, sink.type)) {
2905
- const lineCalls = callsByLine.get(sink.line) ?? [];
2906
- for (const call of lineCalls) {
2907
- for (const arg of call.arguments) {
2908
- const expr = arg.expression || '';
2909
- const sanMethodMatch = san.method.match(/(?:(\w+)\.)?(\w+)\(\)/);
2910
- if (sanMethodMatch) {
2911
- const sanMethodName = sanMethodMatch[2];
2912
- const sanClassName = sanMethodMatch[1];
2913
- if (sanClassName) {
2914
- if (expr.includes(`${sanClassName}.${sanMethodName}(`))
2915
- return false;
2916
- }
2917
- else if (expr.includes(`${sanMethodName}(`)) {
2918
- return false;
2919
- }
2920
- }
3124
+ const lineCalls = callsByLine.get(sink.line) ?? [];
3125
+ const covering = lineSanitizers.filter(san => sanitizerCoversSink(san, sink.type));
3126
+ if (covering.length === 0)
3127
+ return true;
3128
+ // A raw source operand keeps the sink (#641). The fully wrapped twin,
3129
+ // and a sanitizer call with no known source operand, still drops it.
3130
+ for (const variable of sourceVars) {
3131
+ if (identifierOutsideSanitizerCalls(variable, lineCalls, covering, sink.type))
3132
+ return true;
3133
+ }
3134
+ for (const san of covering) {
3135
+ const sanMethodMatch = san.method.match(/(?:(\w+)\.)?(\w+)\(\)/);
3136
+ if (!sanMethodMatch)
3137
+ continue;
3138
+ const sanMethodName = sanMethodMatch[2];
3139
+ const sanClassName = sanMethodMatch[1];
3140
+ for (const call of lineCalls) {
3141
+ if (call.method_name === sanMethodName)
3142
+ continue;
3143
+ for (const arg of call.arguments) {
3144
+ const expr = arg.expression || '';
3145
+ if (sanClassName) {
3146
+ if (expr.includes(`${sanClassName}.${sanMethodName}(`))
3147
+ return false;
3148
+ }
3149
+ else if (expr.includes(`${sanMethodName}(`)) {
3150
+ return false;
2921
3151
  }
2922
3152
  }
2923
3153
  }