circle-ir 4.15.0 → 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 (135) hide show
  1. package/configs/sinks/golang.json +4 -4
  2. package/dist/analysis/config-loader.d.ts.map +1 -1
  3. package/dist/analysis/config-loader.js +11 -7
  4. package/dist/analysis/config-loader.js.map +1 -1
  5. package/dist/analysis/constant-caller-gate.d.ts +39 -0
  6. package/dist/analysis/constant-caller-gate.d.ts.map +1 -0
  7. package/dist/analysis/constant-caller-gate.js +298 -0
  8. package/dist/analysis/constant-caller-gate.js.map +1 -0
  9. package/dist/analysis/constant-propagation/evaluator.d.ts.map +1 -1
  10. package/dist/analysis/constant-propagation/evaluator.js +3 -1
  11. package/dist/analysis/constant-propagation/evaluator.js.map +1 -1
  12. package/dist/analysis/constant-propagation/patterns.d.ts +19 -0
  13. package/dist/analysis/constant-propagation/patterns.d.ts.map +1 -1
  14. package/dist/analysis/constant-propagation/patterns.js +33 -0
  15. package/dist/analysis/constant-propagation/patterns.js.map +1 -1
  16. package/dist/analysis/constant-propagation/propagator.d.ts +59 -0
  17. package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
  18. package/dist/analysis/constant-propagation/propagator.js +396 -49
  19. package/dist/analysis/constant-propagation/propagator.js.map +1 -1
  20. package/dist/analysis/constant-propagation/types.d.ts +9 -0
  21. package/dist/analysis/constant-propagation/types.d.ts.map +1 -1
  22. package/dist/analysis/findings.d.ts.map +1 -1
  23. package/dist/analysis/findings.js +134 -0
  24. package/dist/analysis/findings.js.map +1 -1
  25. package/dist/analysis/interprocedural.d.ts.map +1 -1
  26. package/dist/analysis/interprocedural.js +98 -3
  27. package/dist/analysis/interprocedural.js.map +1 -1
  28. package/dist/analysis/java-array-element-keys.d.ts +14 -0
  29. package/dist/analysis/java-array-element-keys.d.ts.map +1 -0
  30. package/dist/analysis/java-array-element-keys.js +36 -0
  31. package/dist/analysis/java-array-element-keys.js.map +1 -0
  32. package/dist/analysis/js-crlf-strip.d.ts +26 -0
  33. package/dist/analysis/js-crlf-strip.d.ts.map +1 -0
  34. package/dist/analysis/js-crlf-strip.js +244 -0
  35. package/dist/analysis/js-crlf-strip.js.map +1 -0
  36. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +12 -0
  37. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  38. package/dist/analysis/passes/csharp-sanitizer-credit.js +109 -12
  39. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
  40. package/dist/analysis/passes/language-sources-pass.d.ts +38 -3
  41. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  42. package/dist/analysis/passes/language-sources-pass.js +1176 -119
  43. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  44. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
  45. package/dist/analysis/passes/param-source-flow-gate-pass.js +53 -1
  46. package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
  47. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts +6 -0
  48. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts.map +1 -1
  49. package/dist/analysis/passes/plaintext-password-storage-pass.js +160 -0
  50. package/dist/analysis/passes/plaintext-password-storage-pass.js.map +1 -1
  51. package/dist/analysis/passes/scan-secrets-pass.d.ts +1 -1
  52. package/dist/analysis/passes/scan-secrets-pass.d.ts.map +1 -1
  53. package/dist/analysis/passes/scan-secrets-pass.js +7 -1
  54. package/dist/analysis/passes/scan-secrets-pass.js.map +1 -1
  55. package/dist/analysis/passes/sink-filter-pass.d.ts +1 -1
  56. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  57. package/dist/analysis/passes/sink-filter-pass.js +307 -31
  58. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  59. package/dist/analysis/passes/taint-matcher-pass.d.ts.map +1 -1
  60. package/dist/analysis/passes/taint-matcher-pass.js +2 -2
  61. package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
  62. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  63. package/dist/analysis/passes/taint-propagation-pass.js +1349 -58
  64. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  65. package/dist/analysis/passes/weak-hash-pass.d.ts +6 -0
  66. package/dist/analysis/passes/weak-hash-pass.d.ts.map +1 -1
  67. package/dist/analysis/passes/weak-hash-pass.js +191 -4
  68. package/dist/analysis/passes/weak-hash-pass.js.map +1 -1
  69. package/dist/analysis/passes/weak-random-pass.d.ts +5 -0
  70. package/dist/analysis/passes/weak-random-pass.d.ts.map +1 -1
  71. package/dist/analysis/passes/weak-random-pass.js +30 -0
  72. package/dist/analysis/passes/weak-random-pass.js.map +1 -1
  73. package/dist/analysis/project-callee-escape-gate.d.ts +40 -0
  74. package/dist/analysis/project-callee-escape-gate.d.ts.map +1 -0
  75. package/dist/analysis/project-callee-escape-gate.js +243 -0
  76. package/dist/analysis/project-callee-escape-gate.js.map +1 -0
  77. package/dist/analysis/rust-literal-guard-rebind.d.ts +34 -0
  78. package/dist/analysis/rust-literal-guard-rebind.d.ts.map +1 -0
  79. package/dist/analysis/rust-literal-guard-rebind.js +197 -0
  80. package/dist/analysis/rust-literal-guard-rebind.js.map +1 -0
  81. package/dist/analysis/rust-literal-guard.d.ts +33 -0
  82. package/dist/analysis/rust-literal-guard.d.ts.map +1 -0
  83. package/dist/analysis/rust-literal-guard.js +422 -0
  84. package/dist/analysis/rust-literal-guard.js.map +1 -0
  85. package/dist/analysis/scalar-derivation.d.ts +8 -0
  86. package/dist/analysis/scalar-derivation.d.ts.map +1 -1
  87. package/dist/analysis/scalar-derivation.js +172 -0
  88. package/dist/analysis/scalar-derivation.js.map +1 -1
  89. package/dist/analysis/taint-matcher.d.ts +2 -2
  90. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  91. package/dist/analysis/taint-matcher.js +531 -31
  92. package/dist/analysis/taint-matcher.js.map +1 -1
  93. package/dist/analysis/taint-propagation.d.ts +26 -1
  94. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  95. package/dist/analysis/taint-propagation.js +234 -13
  96. package/dist/analysis/taint-propagation.js.map +1 -1
  97. package/dist/analyzer.d.ts +16 -2
  98. package/dist/analyzer.d.ts.map +1 -1
  99. package/dist/analyzer.js +13 -3
  100. package/dist/analyzer.js.map +1 -1
  101. package/dist/browser/circle-ir.js +24319 -19934
  102. package/dist/core/circle-ir-core.cjs +4936 -3575
  103. package/dist/core/circle-ir-core.js +4936 -3575
  104. package/dist/core/extractors/calls.d.ts +12 -1
  105. package/dist/core/extractors/calls.d.ts.map +1 -1
  106. package/dist/core/extractors/calls.js +332 -22
  107. package/dist/core/extractors/calls.js.map +1 -1
  108. package/dist/core/extractors/dfg.js +102 -10
  109. package/dist/core/extractors/dfg.js.map +1 -1
  110. package/dist/core/extractors/types.d.ts +5 -1
  111. package/dist/core/extractors/types.d.ts.map +1 -1
  112. package/dist/core/extractors/types.js +62 -0
  113. package/dist/core/extractors/types.js.map +1 -1
  114. package/dist/graph/analysis-pass.d.ts +6 -1
  115. package/dist/graph/analysis-pass.d.ts.map +1 -1
  116. package/dist/graph/analysis-pass.js +2 -1
  117. package/dist/graph/analysis-pass.js.map +1 -1
  118. package/dist/navigation/navigation-index.d.ts +134 -14
  119. package/dist/navigation/navigation-index.d.ts.map +1 -1
  120. package/dist/navigation/navigation-index.js +1158 -104
  121. package/dist/navigation/navigation-index.js.map +1 -1
  122. package/dist/navigation/types.d.ts +59 -2
  123. package/dist/navigation/types.d.ts.map +1 -1
  124. package/dist/resolution/cross-file.d.ts +9 -0
  125. package/dist/resolution/cross-file.d.ts.map +1 -1
  126. package/dist/resolution/cross-file.js +102 -43
  127. package/dist/resolution/cross-file.js.map +1 -1
  128. package/dist/resolution/type-hierarchy.d.ts +3 -0
  129. package/dist/resolution/type-hierarchy.d.ts.map +1 -1
  130. package/dist/resolution/type-hierarchy.js +45 -1
  131. package/dist/resolution/type-hierarchy.js.map +1 -1
  132. package/dist/types/index.d.ts +33 -0
  133. package/dist/types/index.d.ts.map +1 -1
  134. package/dist/types/index.js.map +1 -1
  135. package/package.json +1 -1
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * Matches method calls and annotations against taint configurations.
5
5
  */
6
+ import { javaWikiTagInheritedContextReceiver } from './passes/language-sources-pass.js';
6
7
  import { DOM_ASSIGNMENT_SINK_METHODS, isProvablyLiteralExpression } from './literal-expression.js';
7
8
  import { getDefaultConfig } from './config-loader.js';
8
9
  import { javaReplaceCrLfCoverage } from './java-crlf-strip.js';
10
+ import { jsChainTextAt, jsReplaceChainVerdict } from './js-crlf-strip.js';
11
+ import { HTML_CONTEXT_ENCODER_METHODS } from './constant-propagation/patterns.js';
9
12
  import { resolveIdentifierType, resolveCallReturnType, isBoundedClassType, } from './arg-type-resolver.js';
10
13
  /**
11
14
  * Python tainted access patterns (regex-based).
@@ -80,9 +83,9 @@ function dropCSharpObjectCarriedWaypoints(sinks, calls) {
80
83
  return true;
81
84
  });
82
85
  }
83
- export function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language, code) {
86
+ export function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language, code, imports) {
84
87
  const sourceLines = code !== undefined ? code.split('\n') : undefined;
85
- const sources = findSources(calls, types, config.sources, sourceLines, language);
88
+ const sources = findSources(calls, types, config.sources, sourceLines, language, imports);
86
89
  let sinkPatterns = expandPromisifyAliases(config.sinks, sourceLines, language);
87
90
  sinkPatterns = expandIndirectEvalAliases(sinkPatterns, sourceLines, language);
88
91
  const sinks = findSinks(calls, sinkPatterns, typeHierarchy, language, sourceLines, types);
@@ -265,7 +268,7 @@ export function attachSourceLineCode(sources, sinks, code) {
265
268
  /**
266
269
  * Find taint sources in method calls and annotated parameters.
267
270
  */
268
- function findSources(calls, types, patterns, sourceLines, language) {
271
+ function findSources(calls, types, patterns, sourceLines, language, imports) {
269
272
  const sources = [];
270
273
  // #254 T2#7: pre-filter patterns by language once at function entry.
271
274
  // Previously the same 4-clause `pattern.languages && … !includes(language)`
@@ -282,7 +285,7 @@ function findSources(calls, types, patterns, sourceLines, language) {
282
285
  // Check method calls
283
286
  for (const call of calls) {
284
287
  for (const pattern of patternsForLanguage) {
285
- if (matchesSourcePattern(call, pattern)) {
288
+ if (matchesSourcePattern(call, pattern, language, types, imports)) {
286
289
  sources.push({
287
290
  type: pattern.type,
288
291
  location: formatCallLocation(call),
@@ -710,6 +713,10 @@ function isInterproceduralTaintableType(typeName, language) {
710
713
  if (elementType === 'String' || elementType === 'Object' || elementType === 'byte') {
711
714
  return true;
712
715
  }
716
+ // C# aliases (cognium-dev#638): `string[]` / `object[]` cross-file carriers.
717
+ if (language === 'csharp' && (elementType === 'string' || elementType === 'object')) {
718
+ return true;
719
+ }
713
720
  }
714
721
  return false;
715
722
  }
@@ -1033,6 +1040,35 @@ function processStartInfoExe(expr, sourceLines) {
1033
1040
  }
1034
1041
  return null;
1035
1042
  }
1043
+ // ---------------------------------------------------------------------------
1044
+ // Rust `serde_json` parse into `serde_json::Value` (cognium-dev#674).
1045
+ //
1046
+ // `serde_json` is a data-only parser: parsing into the dynamic `Value` tree
1047
+ // cannot instantiate an attacker-chosen type, so it is not CWE-502. A typed
1048
+ // target (`let c: Config = serde_json::from_str(..)`), an undeclared target
1049
+ // and every other format crate (`serde_yaml`, `toml`, ...) keep their sink.
1050
+ // Positive evidence only: the statement must carry an explicit `Value`
1051
+ // annotation on the binding it assigns to.
1052
+ // ---------------------------------------------------------------------------
1053
+ const RUST_VALUE_LET_RE = /\blet\s+(?:mut\s+)?[A-Za-z_]\w*\s*:\s*(?:[A-Za-z_]\w*::)*Value\s*=\s*[^;]*\bfrom_(?:str|slice|reader)\b/;
1054
+ function isRustSerdeJsonValueParse(call, pattern, language, sourceLines) {
1055
+ if (language !== 'rust' || pattern.class !== 'serde_json' || pattern.type !== 'deserialization')
1056
+ return false;
1057
+ if (!sourceLines)
1058
+ return false;
1059
+ const lineNo = call.location.line;
1060
+ const line = sourceLines[lineNo - 1];
1061
+ if (line === undefined)
1062
+ return false;
1063
+ // A `let x: Value =` that rustfmt wrapped before the call lands on the previous line.
1064
+ const prev = lineNo >= 2 ? (sourceLines[lineNo - 2] ?? '') : '';
1065
+ const stmt = (prev.trimEnd().endsWith('=') ? prev + ' ' : '') + line;
1066
+ // Two parse calls in one statement text: cannot attribute the annotation to this call.
1067
+ const parseSegments = stmt.split(';').filter((seg) => /\bfrom_(?:str|slice|reader)\b/.test(seg));
1068
+ if (parseSegments.length !== 1)
1069
+ return false;
1070
+ return RUST_VALUE_LET_RE.test(parseSegments[0]);
1071
+ }
1036
1072
  function isSafeCSharpProcessStartCall(call, pattern, language, sourceLines) {
1037
1073
  if (language !== 'csharp')
1038
1074
  return false;
@@ -1057,6 +1093,12 @@ function isSafeCSharpProcessStartCall(call, pattern, language, sourceLines) {
1057
1093
  if (method === 'Start' && call.arguments.length === 1) {
1058
1094
  const arg0 = call.arguments.find(a => a.position === 0);
1059
1095
  const expr = (arg0?.expression ?? '').trim();
1096
+ // `proc.Start()` surfaces its receiver as the only argument (#594): the
1097
+ // executable rides the `Process` object (`proc.StartInfo.FileName = …`), not a
1098
+ // `ProcessStartInfo` of the same name built elsewhere in the file, so a name
1099
+ // lookup for a constant-exe construction proves nothing here.
1100
+ if (call.receiver != null && call.receiver.trim() === expr)
1101
+ return false;
1060
1102
  return isConstNonShellExe(processStartInfoExe(expr, sourceLines));
1061
1103
  }
1062
1104
  return false;
@@ -2170,6 +2212,11 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
2170
2212
  if (isSafeCSharpProcessStartCall(call, pattern, language, sourceLines)) {
2171
2213
  continue;
2172
2214
  }
2215
+ // Skip `serde_json::from_*` parses whose declared target is
2216
+ // `serde_json::Value` — a data-only parse, not CWE-502. #674.
2217
+ if (isRustSerdeJsonValueParse(call, pattern, language, sourceLines)) {
2218
+ continue;
2219
+ }
2173
2220
  // Skip C# SSRF sinks dominated by an inline exact-equality host
2174
2221
  // allowlist (`if (host == "c") sink` / `if (host != "c") return; sink`).
2175
2222
  // cognium-ai#328 shape 1.
@@ -2368,7 +2415,7 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
2368
2415
  /**
2369
2416
  * Check if a call matches a source pattern.
2370
2417
  */
2371
- function matchesSourcePattern(call, pattern) {
2418
+ function matchesSourcePattern(call, pattern, language, types, imports) {
2372
2419
  // Method-based matching
2373
2420
  if (pattern.method) {
2374
2421
  if (call.method_name !== pattern.method) {
@@ -2401,6 +2448,14 @@ function matchesSourcePattern(call, pattern) {
2401
2448
  return false;
2402
2449
  }
2403
2450
  }
2451
+ else if (language === 'java' &&
2452
+ types &&
2453
+ imports &&
2454
+ call.receiver &&
2455
+ pattern.class &&
2456
+ javaWikiTagInheritedContextReceiver(call.receiver, pattern.class, call, types, imports)) {
2457
+ // JSPWiki tag handler inherited m_wikiContext (cognium-dev#624)
2458
+ }
2404
2459
  else if (!receiverMightBeClass(call.receiver, pattern.class)) {
2405
2460
  // The receiver might be a variable name, not the class name
2406
2461
  // For now, we do a simple match - in a full implementation,
@@ -2609,7 +2664,98 @@ const SINK_FQN_EXCLUSIONS = {
2609
2664
  // Expression.accept(ExpressionVisitor), etc.
2610
2665
  'net.sf.jsqlparser.',
2611
2666
  ],
2667
+ path_traversal: [
2668
+ // Spring `ResourceLoader.getResource(location)` resolves a classpath /
2669
+ // URL location, not a file path. A receiver named `loader` would
2670
+ // otherwise be taken for `ClassLoader` by the receiver-name heuristic
2671
+ // (the ClassLoader.getResource sink, #621).
2672
+ 'org.springframework.core.io.ResourceLoader',
2673
+ 'org.springframework.core.io.support.ResourcePatternResolver',
2674
+ ],
2675
+ };
2676
+ /**
2677
+ * Sink classes whose receiver-name heuristic is skipped when the declared
2678
+ * receiver type is positively known NOT to be the sink class.
2679
+ * `ClassLoader.getResource` is keyed on a receiver named like `loader`, which
2680
+ * would otherwise also take Spring's `ResourceLoader loader` for a ClassLoader
2681
+ * (#621 follow-up). A declared type that is merely unresolved (a user type
2682
+ * from another file, a type parameter, `var`, a chained call) keeps the
2683
+ * heuristic: it may well be a ClassLoader subclass.
2684
+ */
2685
+ const DECLARED_TYPE_STRICT_SINK_CLASSES = {
2686
+ path_traversal: ['ClassLoader'],
2612
2687
  };
2688
+ /**
2689
+ * Declared receiver types known not to be a `ClassLoader`, matched by simple
2690
+ * name so a wildcard import (`import org.springframework.core.io.*;`), which
2691
+ * leaves `receiver_type_fqn` unresolved, is covered too.
2692
+ */
2693
+ const KNOWN_NON_CLASSLOADER_SIMPLE_NAMES = new Set([
2694
+ 'ResourceLoader',
2695
+ 'ResourcePatternResolver',
2696
+ 'Object',
2697
+ ]);
2698
+ /** Spring resource abstractions: none of them is a `ClassLoader`. */
2699
+ const KNOWN_NON_CLASSLOADER_FQN_PREFIXES = ['org.springframework.core.io.'];
2700
+ /**
2701
+ * True when the type hierarchy knows `typeName` and its whole superclass chain,
2702
+ * and that chain ends without reaching a ClassLoader. Any link that is not in
2703
+ * the hierarchy (a parent declared in another file) means "unknown", never
2704
+ * "not a ClassLoader". Interfaces and enums are never ClassLoaders.
2705
+ */
2706
+ function hierarchyKnowsNotSubtype(typeName, sinkClass, typeHierarchy) {
2707
+ let node = typeHierarchy.getType(typeName);
2708
+ if (!node)
2709
+ return false;
2710
+ const seen = new Set();
2711
+ while (node) {
2712
+ if (seen.has(node.fqn))
2713
+ return false;
2714
+ seen.add(node.fqn);
2715
+ if (node.name === sinkClass || typeHierarchy.isSubtypeOf(node.fqn, sinkClass))
2716
+ return false;
2717
+ if (node.kind === 'interface' || node.kind === 'enum')
2718
+ return true;
2719
+ if (!node.extends)
2720
+ return true;
2721
+ const parent = node.extends.replace(/<.*$/, '').trim();
2722
+ if (parent.split('.').pop() === sinkClass)
2723
+ return false;
2724
+ node = typeHierarchy.getType(parent);
2725
+ }
2726
+ return false;
2727
+ }
2728
+ /**
2729
+ * True when the call's declared receiver type is positively known not to be
2730
+ * `pattern.class` (a ClassLoader): a Spring resource loader / resolver, `Object`,
2731
+ * or a type the hierarchy resolves (e.g. declared in this file) without
2732
+ * reaching ClassLoader. Unresolved types return false so the receiver-name
2733
+ * heuristic still decides.
2734
+ */
2735
+ function declaredReceiverTypeExcludes(call, pattern, typeHierarchy) {
2736
+ if (!pattern.class || !DECLARED_TYPE_STRICT_SINK_CLASSES[pattern.type]?.includes(pattern.class)) {
2737
+ return false;
2738
+ }
2739
+ const fqn = call.receiver_type_fqn?.replace(/<.*$/, '').trim();
2740
+ const simpleDeclared = call.receiver_type?.replace(/<.*$/, '').trim();
2741
+ if (fqn && KNOWN_NON_CLASSLOADER_FQN_PREFIXES.some((prefix) => fqn.startsWith(prefix))) {
2742
+ return true;
2743
+ }
2744
+ if (fqn === 'java.lang.Object')
2745
+ return true;
2746
+ const declared = fqn || simpleDeclared;
2747
+ if (!declared)
2748
+ return false;
2749
+ const simple = declared.split('.').pop() ?? '';
2750
+ if (simple === '' || simple === 'var')
2751
+ return false;
2752
+ // A simple name only counts when no other FQN was resolved for it
2753
+ // (`com.acme.ResourceLoader` is a user type, not Spring's).
2754
+ if (KNOWN_NON_CLASSLOADER_SIMPLE_NAMES.has(simple) && (!fqn || fqn === simple)) {
2755
+ return true;
2756
+ }
2757
+ return typeHierarchy !== undefined && hierarchyKnowsNotSubtype(declared, pattern.class, typeHierarchy);
2758
+ }
2613
2759
  /**
2614
2760
  * Check if a call matches a sink pattern.
2615
2761
  */
@@ -2710,6 +2856,13 @@ function matchesSinkPattern(call, pattern, typeHierarchy, language) {
2710
2856
  // If the mapped return type is a subtype of the sink class, accept.
2711
2857
  // — cognium-dev #241 Java
2712
2858
  }
2859
+ else if (declaredReceiverTypeExcludes(call, pattern, typeHierarchy)) {
2860
+ // Declared receiver type is positively known not to be the sink class
2861
+ // (Spring `ResourceLoader`, `Object`, a same-file class that does not
2862
+ // extend ClassLoader): the receiver-name heuristic below must not turn
2863
+ // it into the sink class. Unresolved types still use the heuristic.
2864
+ return false;
2865
+ }
2713
2866
  else if (call.receiver && !receiverMightBeClass(call.receiver, pattern.class)) {
2714
2867
  // Heuristic match failed; fall back to TypeHierarchyResolver if available
2715
2868
  if (typeHierarchy && typeHierarchy.couldBeType(call.receiver, pattern.class)) {
@@ -3262,9 +3415,20 @@ export function isInDangerousPosition(argPosition, pattern) {
3262
3415
  */
3263
3416
  function findSanitizers(calls, types, patterns, sourceLines, language) {
3264
3417
  const sanitizers = [];
3265
- const javaReplace = (call) => language === 'java' && sourceLines && (call.method_name === 'replace' || call.method_name === 'replaceAll')
3266
- ? javaReplaceChainCredit(javaChainTextAt(sourceLines, call.location.line, call.location.column))
3267
- : null;
3418
+ const javaReplace = (call) => {
3419
+ if (!sourceLines || (call.method_name !== 'replace' && call.method_name !== 'replaceAll'))
3420
+ return null;
3421
+ if (language === 'java') {
3422
+ return javaReplaceChainCredit(javaChainTextAt(sourceLines, call.location.line, call.location.column));
3423
+ }
3424
+ if (language === 'javascript' || language === 'typescript' || language === 'tsx') {
3425
+ // #562: the class-less `replace` row credits log_injection; keep it only
3426
+ // when the literal chain strips both CR and LF, or cannot be decided.
3427
+ const verdict = jsReplaceChainVerdict(jsChainTextAt(sourceLines, call.location.line, call.location.column));
3428
+ return verdict === 'leaves-crlf' ? [] : null;
3429
+ }
3430
+ return null;
3431
+ };
3268
3432
  // Build a set of method names with @sanitizer annotation (from Javadoc comments)
3269
3433
  const sanitizerMethods = new Set();
3270
3434
  for (const type of types) {
@@ -3275,19 +3439,22 @@ function findSanitizers(calls, types, patterns, sourceLines, language) {
3275
3439
  }
3276
3440
  }
3277
3441
  // Sprint 9 #79 (Phase L): derive wrapper sanitizers.
3278
- // A method qualifies as a derived sanitizer when its body is exactly
3279
- // `return <known_sanitizer>(<param>)` (optionally `await`-prefixed).
3442
+ // A method qualifies when its body is `return <sanitizer-expr>(<param>)`
3443
+ // (optionally `await`-prefixed), span ≤ 2 lines (signature + single return).
3280
3444
  // We require:
3281
- // 1. Method body span ≤ 2 lines (signature + single return).
3282
- // 2. Exactly one non-recursive call inside the method's line range.
3283
- // 3. That call matches a configured sanitizer pattern with non-empty
3284
- // `removes`.
3285
- // 4. The inner call's argument is exactly one of the wrapper's params.
3286
- // 5. When source is available, the inner call's source line, after
3287
- // `return ` / `return await `, starts with the inner call's
3288
- // `[receiver.]method(` prefix and ends with `)` — rejecting
3289
- // unsafe shapes like `return x + shlex.quote(x)`.
3445
+ // 1. One non-recursive call, OR every inner call is `replace` / `replaceAll`
3446
+ // (#517): use the call with the longest receiver for argument-aware credit.
3447
+ // 2. That call matches a configured sanitizer pattern with non-empty
3448
+ // `removes` (Java `replace` chains use `javaReplaceChainCredit`).
3449
+ // 3. The inner call's argument is a wrapper param, or (replace-chain only)
3450
+ // the receiver text starts with a param name.
3451
+ // 4. When source is available, the return expression is a single sanitizer
3452
+ // call or a param-rooted replace chain ending with `)` — rejecting shapes
3453
+ // like `return x + shlex.quote(x)`.
3290
3454
  const wrapperSanitizers = new Map();
3455
+ // #589: wrappers whose single inner call is a context-specific HTML encoder
3456
+ // (every same-named wrapper, so an overload cannot lend its credit).
3457
+ const wrapperEncoderOnly = new Map();
3291
3458
  for (const type of types) {
3292
3459
  for (const method of type.methods) {
3293
3460
  const bodySize = method.end_line - method.start_line;
@@ -3304,9 +3471,21 @@ function findSanitizers(calls, types, patterns, sourceLines, language) {
3304
3471
  continue; // recursion guard
3305
3472
  inside.push(c);
3306
3473
  }
3307
- if (inside.length !== 1)
3474
+ if (inside.length === 0)
3308
3475
  continue;
3309
- const innerCall = inside[0];
3476
+ const allReplaceCalls = inside.every(c => c.method_name === 'replace' || c.method_name === 'replaceAll');
3477
+ let innerCall;
3478
+ let replaceChainHelper = false;
3479
+ if (inside.length === 1) {
3480
+ innerCall = inside[0];
3481
+ }
3482
+ else if (allReplaceCalls) {
3483
+ replaceChainHelper = true;
3484
+ innerCall = inside.reduce((best, c) => (c.receiver ?? '').length > (best.receiver ?? '').length ? c : best);
3485
+ }
3486
+ else {
3487
+ continue;
3488
+ }
3310
3489
  let matched;
3311
3490
  const replaceCredit = javaReplace(innerCall);
3312
3491
  if (replaceCredit) {
@@ -3323,10 +3502,21 @@ function findSanitizers(calls, types, patterns, sourceLines, language) {
3323
3502
  if (!matched || !matched.removes || matched.removes.length === 0)
3324
3503
  continue;
3325
3504
  let argOk = false;
3326
- for (const arg of innerCall.arguments) {
3327
- if (arg.variable && paramNames.has(arg.variable)) {
3328
- argOk = true;
3329
- break;
3505
+ if (replaceChainHelper) {
3506
+ const recv = innerCall.receiver?.trim() ?? '';
3507
+ for (const p of paramNames) {
3508
+ if (recv === p || recv.startsWith(`${p}.`)) {
3509
+ argOk = true;
3510
+ break;
3511
+ }
3512
+ }
3513
+ }
3514
+ else {
3515
+ for (const arg of innerCall.arguments) {
3516
+ if (arg.variable && paramNames.has(arg.variable)) {
3517
+ argOk = true;
3518
+ break;
3519
+ }
3330
3520
  }
3331
3521
  }
3332
3522
  if (!argOk)
@@ -3338,14 +3528,24 @@ function findSanitizers(calls, types, patterns, sourceLines, language) {
3338
3528
  if (!returnMatch)
3339
3529
  continue;
3340
3530
  const after = returnMatch[1].replace(/;\s*$/, '').trimEnd();
3341
- const callPrefix = innerCall.receiver
3342
- ? `${innerCall.receiver}.${innerCall.method_name}(`
3343
- : `${innerCall.method_name}(`;
3344
- if (!after.startsWith(callPrefix))
3345
- continue;
3531
+ if (replaceChainHelper) {
3532
+ const startsWithParam = [...paramNames].some(p => after === p || after.startsWith(`${p}.`));
3533
+ if (!startsWithParam)
3534
+ continue;
3535
+ }
3536
+ else {
3537
+ const callPrefix = innerCall.receiver
3538
+ ? `${innerCall.receiver}.${innerCall.method_name}(`
3539
+ : `${innerCall.method_name}(`;
3540
+ if (!after.startsWith(callPrefix))
3541
+ continue;
3542
+ }
3346
3543
  if (!after.endsWith(')'))
3347
3544
  continue;
3348
3545
  }
3546
+ const encoderWrapper = !replaceCredit && method.parameters.length === 1 &&
3547
+ HTML_CONTEXT_ENCODER_METHODS.has(innerCall.method_name);
3548
+ wrapperEncoderOnly.set(method.name, (wrapperEncoderOnly.get(method.name) ?? true) && encoderWrapper);
3349
3549
  const existing = wrapperSanitizers.get(method.name);
3350
3550
  if (existing) {
3351
3551
  const set = new Set([...existing, ...matched.removes]);
@@ -3356,18 +3556,88 @@ function findSanitizers(calls, types, patterns, sourceLines, language) {
3356
3556
  }
3357
3557
  }
3358
3558
  }
3559
+ // #589: delegate to an encoder-only wrapper in one hop (wrapEncode → encodeForPage → escapeHtml).
3560
+ let transitiveWrapperAdded = true;
3561
+ while (transitiveWrapperAdded) {
3562
+ transitiveWrapperAdded = false;
3563
+ for (const type of types) {
3564
+ for (const method of type.methods) {
3565
+ if (wrapperSanitizers.has(method.name))
3566
+ continue;
3567
+ const bodySize = method.end_line - method.start_line;
3568
+ if (bodySize < 0 || bodySize > 2)
3569
+ continue;
3570
+ const paramNames = new Set(method.parameters.map(p => p.name));
3571
+ if (paramNames.size === 0)
3572
+ continue;
3573
+ const inside = [];
3574
+ for (const c of calls) {
3575
+ if (c.location.line < method.start_line || c.location.line > method.end_line)
3576
+ continue;
3577
+ if (c.method_name === method.name)
3578
+ continue;
3579
+ inside.push(c);
3580
+ }
3581
+ if (inside.length !== 1)
3582
+ continue;
3583
+ const innerCall = inside[0];
3584
+ const delegateRemoves = wrapperSanitizers.get(innerCall.method_name);
3585
+ if (!delegateRemoves || delegateRemoves.length === 0)
3586
+ continue;
3587
+ let argOk = false;
3588
+ for (const arg of innerCall.arguments) {
3589
+ if (arg.variable && paramNames.has(arg.variable)) {
3590
+ argOk = true;
3591
+ break;
3592
+ }
3593
+ }
3594
+ if (!argOk)
3595
+ continue;
3596
+ if (sourceLines) {
3597
+ const lineText = sourceLines[innerCall.location.line - 1] ?? '';
3598
+ const stripped = lineText.trim();
3599
+ const returnMatch = stripped.match(/^return\s+(?:await\s+)?(.*)$/);
3600
+ if (!returnMatch)
3601
+ continue;
3602
+ const after = returnMatch[1].replace(/;\s*$/, '').trimEnd();
3603
+ const callPrefix = innerCall.receiver
3604
+ ? `${innerCall.receiver}.${innerCall.method_name}(`
3605
+ : `${innerCall.method_name}(`;
3606
+ if (!after.startsWith(callPrefix) || !after.endsWith(')'))
3607
+ continue;
3608
+ }
3609
+ const encoderDelegate = wrapperEncoderOnly.get(innerCall.method_name) === true;
3610
+ wrapperEncoderOnly.set(method.name, encoderDelegate);
3611
+ wrapperSanitizers.set(method.name, [...delegateRemoves]);
3612
+ transitiveWrapperAdded = true;
3613
+ }
3614
+ }
3615
+ }
3359
3616
  // Emit a derived-sanitizer entry at each call site to a wrapper method.
3360
3617
  for (const call of calls) {
3361
3618
  const removes = wrapperSanitizers.get(call.method_name);
3362
3619
  if (!removes)
3363
3620
  continue;
3364
3621
  sanitizers.push({
3365
- type: 'derived_wrapper',
3622
+ // #589: a wrapper of an HTML encoder cleans `xss` but not `trust_boundary`,
3623
+ // like the encoder itself, so it is told apart from other wrappers.
3624
+ type: wrapperEncoderOnly.get(call.method_name) ? 'derived_html_encoder' : 'derived_wrapper',
3366
3625
  method: formatSanitizerMethod(call),
3367
3626
  line: call.location.line,
3368
3627
  sanitizes: removes,
3369
3628
  });
3370
3629
  }
3630
+ const noopLocalHelpers = !sourceLines
3631
+ ? new Set()
3632
+ : language === 'java'
3633
+ ? findJavaNoopHelpers(types, sourceLines)
3634
+ : language === 'python'
3635
+ ? findPythonNoopHelpers(types, sourceLines)
3636
+ : language === 'go'
3637
+ ? findGoNoopHelpers(types, sourceLines)
3638
+ : language === 'javascript' || language === 'typescript' || language === 'tsx'
3639
+ ? findJsNoopHelpers(types, sourceLines)
3640
+ : new Set();
3371
3641
  // Check method calls for sanitizer methods
3372
3642
  for (const call of calls) {
3373
3643
  // Check if this call is to a method with @sanitizer annotation
@@ -3394,6 +3664,9 @@ function findSanitizers(calls, types, patterns, sourceLines, language) {
3394
3664
  }
3395
3665
  for (const pattern of patterns) {
3396
3666
  if (matchesSanitizerPattern(call, pattern)) {
3667
+ if (!pattern.class && noopLocalHelpers.has(call.method_name) && (!call.receiver || call.receiver === 'this' || call.receiver === 'self')) {
3668
+ continue; // same-file helper that does no escaping: not credited by name (#581)
3669
+ }
3397
3670
  sanitizers.push({
3398
3671
  type: determineSanitizerType(pattern),
3399
3672
  method: formatSanitizerMethod(call),
@@ -3422,6 +3695,233 @@ function findSanitizers(calls, types, patterns, sourceLines, language) {
3422
3695
  }
3423
3696
  return sanitizers;
3424
3697
  }
3698
+ /**
3699
+ * Names of methods defined in this file whose every definition is a no-op
3700
+ * shape: a single `return <expr>;` built only from the method's own
3701
+ * parameters, string literals, `+` and the pass-through calls `trim`, `strip`,
3702
+ * `toString`, `toLowerCase` and `toUpperCase`. A class-less sanitizer row must
3703
+ * not credit a call to such a helper by name (#581). Any other body (a
3704
+ * `replace`, a library call, a branch, a loop) keeps the by-name credit, and so
3705
+ * does a name that has a non-no-op overload.
3706
+ */
3707
+ function findJavaNoopHelpers(types, sourceLines) {
3708
+ const noop = new Set();
3709
+ const other = new Set();
3710
+ for (const type of types) {
3711
+ for (const method of type.methods) {
3712
+ (isJavaNoopBody(method, sourceLines) ? noop : other).add(method.name);
3713
+ }
3714
+ }
3715
+ for (const name of other)
3716
+ noop.delete(name);
3717
+ return noop;
3718
+ }
3719
+ function isJavaNoopBody(method, sourceLines) {
3720
+ if (method.parameters.length === 0)
3721
+ return false;
3722
+ const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
3723
+ const open = text.indexOf('{');
3724
+ const close = text.lastIndexOf('}');
3725
+ if (open < 0 || close < open)
3726
+ return false;
3727
+ const m = /^\s*return\s+([^;{}]+);\s*$/.exec(text.slice(open + 1, close));
3728
+ if (!m)
3729
+ return false;
3730
+ const params = new Set(method.parameters.map(p => p.name));
3731
+ const expr = m[1]
3732
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
3733
+ .replace(/\.(?:trim|strip|toString|toLowerCase|toUpperCase)\(\)/g, '');
3734
+ const leftovers = expr.replace(/""|[+()\s]/g, ' ').trim();
3735
+ if (leftovers === '')
3736
+ return true;
3737
+ return leftovers.split(/\s+/).every(tok => params.has(tok));
3738
+ }
3739
+ /**
3740
+ * Python twin of `findJavaNoopHelpers` (#581): a `def` whose body is one
3741
+ * `return <expr>` built only from its own parameters, string literals, `+`, the
3742
+ * pass-through methods `strip` / `lstrip` / `rstrip` / `lower` / `upper` and the
3743
+ * `str()` call. Any other body (a `re.sub`, `replace`, `html.escape`, a branch,
3744
+ * a loop, a second statement) keeps the by-name credit, and so does a name with a
3745
+ * non-no-op definition elsewhere in the file.
3746
+ */
3747
+ function findPythonNoopHelpers(types, sourceLines) {
3748
+ const noop = new Set();
3749
+ const other = new Set();
3750
+ for (const type of types) {
3751
+ for (const method of type.methods) {
3752
+ (isPythonNoopBody(method, sourceLines) ? noop : other).add(method.name);
3753
+ }
3754
+ }
3755
+ for (const name of other)
3756
+ noop.delete(name);
3757
+ return noop;
3758
+ }
3759
+ function isPythonNoopBody(method, sourceLines) {
3760
+ if (method.parameters.length === 0)
3761
+ return false;
3762
+ const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
3763
+ // `def name(params) -> T:` then the body, on the same line or below. The header is
3764
+ // matched without crossing a newline inside a default value or annotation.
3765
+ const header = /^\s*(?:async\s+)?def\s+\w+\s*\(([^()]*)\)\s*(?:->\s*[\w.\[\], ]+)?\s*:/.exec(text);
3766
+ if (!header)
3767
+ return false;
3768
+ const body = text.slice(header[0].length).replace(/#.*$/gm, '').trim();
3769
+ const m = /^return\s+([^\n;]+)$/.exec(body);
3770
+ if (!m)
3771
+ return false;
3772
+ const params = new Set(method.parameters.map(p => p.name.replace(/^\*+/, '')));
3773
+ const expr = m[1]
3774
+ .replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""')
3775
+ .replace(/\.(?:strip|lstrip|rstrip|lower|upper)\(\)/g, '')
3776
+ .replace(/\bstr\(/g, '(');
3777
+ const leftovers = expr.replace(/""|[+()\s]/g, ' ').trim();
3778
+ if (leftovers === '')
3779
+ return true;
3780
+ return leftovers.split(/\s+/).every(tok => params.has(tok));
3781
+ }
3782
+ /**
3783
+ * Go twin of `findJavaNoopHelpers` (#581): a `func` whose body is one
3784
+ * `return <expr>` built only from its own parameters, string literals, `+`, the
3785
+ * pass-through calls `strings.TrimSpace` / `strings.ToLower` / `strings.ToUpper`
3786
+ * and the `string()` conversion. Any other body (a `strings.ReplaceAll`, an
3787
+ * escaper call, a branch, a loop, a second statement) keeps the by-name credit,
3788
+ * and so does a name with a non-no-op definition elsewhere in the file, a method
3789
+ * or a function alike.
3790
+ */
3791
+ function findGoNoopHelpers(types, sourceLines) {
3792
+ const noop = new Set();
3793
+ const other = new Set();
3794
+ for (const type of types) {
3795
+ for (const method of type.methods) {
3796
+ (isGoNoopBody(method, sourceLines) ? noop : other).add(method.name);
3797
+ }
3798
+ }
3799
+ for (const name of other)
3800
+ noop.delete(name);
3801
+ // A local of the same name (`sanitize := func(v string) string { … }`) is a
3802
+ // different helper whose body is not read here.
3803
+ const src = sourceLines.join('\n');
3804
+ for (const name of [...noop]) {
3805
+ if (new RegExp(`(?<![\\w.])${name}\\s*(?::=|=(?!=)|,\\s*\\w+\\s*:=)`).test(src))
3806
+ noop.delete(name);
3807
+ }
3808
+ return noop;
3809
+ }
3810
+ function isGoNoopBody(method, sourceLines) {
3811
+ if (method.parameters.length === 0)
3812
+ return false;
3813
+ const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
3814
+ // `func name(params) T {` — a plain function; a method (`func (s *T) name`) is judged by a
3815
+ // call through its receiver, which is never skipped, so it is not a no-op helper here.
3816
+ const header = /^\s*func\s+\w+\s*\(([^()]*)\)\s*\(?[\w.\[\]*, ]*\)?\s*\{/.exec(text);
3817
+ if (!header)
3818
+ return false;
3819
+ const close = text.lastIndexOf('}');
3820
+ if (close < header[0].length)
3821
+ return false;
3822
+ const body = text.slice(header[0].length, close).replace(/\/\/.*$/gm, '').trim();
3823
+ const m = /^return\s+([^;{}]+)$/.exec(body);
3824
+ if (!m)
3825
+ return false;
3826
+ const params = new Set(method.parameters.map(p => p.name.replace(/^\.+/, '')));
3827
+ for (const seg of header[1].split(',')) {
3828
+ const name = seg.trim().split(/\s+/)[0]?.replace(/^\.+/, '');
3829
+ if (name)
3830
+ params.add(name);
3831
+ }
3832
+ const expr = m[1]
3833
+ .replace(/"(?:\\.|[^"\\])*"|`[^`]*`/g, '""')
3834
+ .replace(/\bstrings\s*\.\s*(?:TrimSpace|ToLower|ToUpper)\s*\(/g, '(')
3835
+ .replace(/\bstring\s*\(/g, '(');
3836
+ const leftovers = expr.replace(/""|[+()\s]/g, ' ').trim();
3837
+ if (leftovers === '')
3838
+ return true;
3839
+ return leftovers.split(/\s+/).every(tok => params.has(tok));
3840
+ }
3841
+ /**
3842
+ * JavaScript / TypeScript twin of `findJavaNoopHelpers` (#581): a `function` or
3843
+ * method whose body is one `return <expr>` (or an arrow expression) built only
3844
+ * from its own parameters, string literals, `+`, and the pass-through calls
3845
+ * `trim` / `toLowerCase` / `toUpperCase` / `toString` / `String()`. Any other
3846
+ * body keeps the by-name credit, and so does a name with a non-no-op overload.
3847
+ */
3848
+ function findJsNoopHelpers(types, sourceLines) {
3849
+ const fromDecl = new Set();
3850
+ const otherDecl = new Set();
3851
+ for (const type of types) {
3852
+ for (const method of type.methods) {
3853
+ (isJsNoopBody(method, sourceLines) ? fromDecl : otherDecl).add(method.name);
3854
+ }
3855
+ }
3856
+ for (const name of otherDecl)
3857
+ fromDecl.delete(name);
3858
+ const src = sourceLines.join('\n');
3859
+ for (const name of [...fromDecl]) {
3860
+ if (new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).test(src))
3861
+ fromDecl.delete(name);
3862
+ }
3863
+ const fromBinding = new Set();
3864
+ const otherBinding = new Set();
3865
+ for (const line of sourceLines) {
3866
+ const arrow = /(?:const|let|var)\s+(\w+)\s*=\s*\(([^)]*)\)\s*=>\s*([^;{]+)\s*;/.exec(line);
3867
+ if (arrow) {
3868
+ const params = jsParamNamesFromList(arrow[2]);
3869
+ if (params.size > 0 && jsNoopReturnExpr(arrow[3], params))
3870
+ fromBinding.add(arrow[1]);
3871
+ else
3872
+ otherBinding.add(arrow[1]);
3873
+ }
3874
+ const fnExpr = /(?:const|let|var)\s+(\w+)\s*=\s*function\s*\(([^)]*)\)\s*\{\s*return\s+([^;{}]+);\s*\}\s*;/.exec(line);
3875
+ if (fnExpr) {
3876
+ const params = jsParamNamesFromList(fnExpr[2]);
3877
+ if (params.size > 0 && jsNoopReturnExpr(fnExpr[3], params))
3878
+ fromBinding.add(fnExpr[1]);
3879
+ else
3880
+ otherBinding.add(fnExpr[1]);
3881
+ }
3882
+ }
3883
+ for (const name of otherBinding)
3884
+ fromBinding.delete(name);
3885
+ return new Set([...fromDecl, ...fromBinding]);
3886
+ }
3887
+ function jsParamNamesFromList(list) {
3888
+ const params = new Set();
3889
+ for (const seg of list.split(',')) {
3890
+ const name = seg.trim().split(':')[0]?.trim().replace(/^\.\.\./, '');
3891
+ if (name)
3892
+ params.add(name);
3893
+ }
3894
+ return params;
3895
+ }
3896
+ function isJsNoopBody(method, sourceLines) {
3897
+ if (method.parameters.length === 0)
3898
+ return false;
3899
+ const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
3900
+ const params = new Set(method.parameters.map(p => p.name.replace(/^\.\.\./, '')));
3901
+ const arrowExpr = /(?:\([^)]*\)|[\w$]+)\s*=>\s*([^{;]+?)\s*$/.exec(text.trim());
3902
+ if (arrowExpr) {
3903
+ return jsNoopReturnExpr(arrowExpr[1], params);
3904
+ }
3905
+ const open = text.indexOf('{');
3906
+ const close = text.lastIndexOf('}');
3907
+ if (open < 0 || close < open)
3908
+ return false;
3909
+ const body = text.slice(open + 1, close).replace(/\/\/.*$/gm, '').trim();
3910
+ const m = /^\s*return\s+([^;{}]+);\s*$/.exec(body);
3911
+ if (!m)
3912
+ return false;
3913
+ return jsNoopReturnExpr(m[1], params);
3914
+ }
3915
+ function jsNoopReturnExpr(expr, params) {
3916
+ const stripped = expr
3917
+ .replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, '""')
3918
+ .replace(/\.(?:trim|toLowerCase|toUpperCase|toString)\(\)/g, '')
3919
+ .replace(/\bString\s*\(/g, '(');
3920
+ const leftovers = stripped.replace(/""|[+()\s]/g, ' ').trim();
3921
+ if (leftovers === '')
3922
+ return true;
3923
+ return leftovers.split(/\s+/).every(tok => params.has(tok));
3924
+ }
3425
3925
  const JAVA_LIT = String.raw `(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')`;
3426
3926
  const JAVA_REPLACE_ARGS_RE = new RegExp(String.raw `\.(replace|replaceAll)\(\s*${JAVA_LIT}\s*,\s*${JAVA_LIT}\s*\)`, 'g');
3427
3927
  /**