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
@@ -3,8 +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';
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';
8
12
  import { resolveIdentifierType, resolveCallReturnType, isBoundedClassType, } from './arg-type-resolver.js';
9
13
  /**
10
14
  * Python tainted access patterns (regex-based).
@@ -79,14 +83,14 @@ function dropCSharpObjectCarriedWaypoints(sinks, calls) {
79
83
  return true;
80
84
  });
81
85
  }
82
- export function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language, code) {
86
+ export function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language, code, imports) {
83
87
  const sourceLines = code !== undefined ? code.split('\n') : undefined;
84
- const sources = findSources(calls, types, config.sources, sourceLines, language);
88
+ const sources = findSources(calls, types, config.sources, sourceLines, language, imports);
85
89
  let sinkPatterns = expandPromisifyAliases(config.sinks, sourceLines, language);
86
90
  sinkPatterns = expandIndirectEvalAliases(sinkPatterns, sourceLines, language);
87
91
  const sinks = findSinks(calls, sinkPatterns, typeHierarchy, language, sourceLines, types);
88
92
  const gatedSinks = language === 'csharp' ? dropCSharpObjectCarriedWaypoints(sinks, calls) : sinks;
89
- const sanitizers = findSanitizers(calls, types, config.sanitizers, sourceLines);
93
+ const sanitizers = findSanitizers(calls, types, config.sanitizers, sourceLines, language);
90
94
  return { sources, sinks: gatedSinks, sanitizers };
91
95
  }
92
96
  /**
@@ -264,7 +268,7 @@ export function attachSourceLineCode(sources, sinks, code) {
264
268
  /**
265
269
  * Find taint sources in method calls and annotated parameters.
266
270
  */
267
- function findSources(calls, types, patterns, sourceLines, language) {
271
+ function findSources(calls, types, patterns, sourceLines, language, imports) {
268
272
  const sources = [];
269
273
  // #254 T2#7: pre-filter patterns by language once at function entry.
270
274
  // Previously the same 4-clause `pattern.languages && … !includes(language)`
@@ -281,7 +285,7 @@ function findSources(calls, types, patterns, sourceLines, language) {
281
285
  // Check method calls
282
286
  for (const call of calls) {
283
287
  for (const pattern of patternsForLanguage) {
284
- if (matchesSourcePattern(call, pattern)) {
288
+ if (matchesSourcePattern(call, pattern, language, types, imports)) {
285
289
  sources.push({
286
290
  type: pattern.type,
287
291
  location: formatCallLocation(call),
@@ -709,6 +713,10 @@ function isInterproceduralTaintableType(typeName, language) {
709
713
  if (elementType === 'String' || elementType === 'Object' || elementType === 'byte') {
710
714
  return true;
711
715
  }
716
+ // C# aliases (cognium-dev#638): `string[]` / `object[]` cross-file carriers.
717
+ if (language === 'csharp' && (elementType === 'string' || elementType === 'object')) {
718
+ return true;
719
+ }
712
720
  }
713
721
  return false;
714
722
  }
@@ -1032,6 +1040,35 @@ function processStartInfoExe(expr, sourceLines) {
1032
1040
  }
1033
1041
  return null;
1034
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
+ }
1035
1072
  function isSafeCSharpProcessStartCall(call, pattern, language, sourceLines) {
1036
1073
  if (language !== 'csharp')
1037
1074
  return false;
@@ -1056,6 +1093,12 @@ function isSafeCSharpProcessStartCall(call, pattern, language, sourceLines) {
1056
1093
  if (method === 'Start' && call.arguments.length === 1) {
1057
1094
  const arg0 = call.arguments.find(a => a.position === 0);
1058
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;
1059
1102
  return isConstNonShellExe(processStartInfoExe(expr, sourceLines));
1060
1103
  }
1061
1104
  return false;
@@ -1890,6 +1933,49 @@ function isGoFprintToNonResponseWriter(call, pattern, language, sourceLines) {
1890
1933
  }
1891
1934
  return true;
1892
1935
  }
1936
+ /**
1937
+ * JDK types whose `getOutputStream()` is an outbound channel (a remote server,
1938
+ * a peer socket, a child process) rather than the servlet response.
1939
+ */
1940
+ const JAVA_OUTBOUND_STREAM_OWNERS = 'HttpURLConnection|HttpsURLConnection|URLConnection|JarURLConnection|Socket|SSLSocket|Process';
1941
+ /**
1942
+ * #671 — true when a Java xss sink is `<base>.getOutputStream().<write>(...)`
1943
+ * and `<base>` is declared in this file as a `URLConnection` / `Socket` /
1944
+ * `Process` (or the chain is `….openConnection().getOutputStream()`). Bytes
1945
+ * written there leave for another host or process; nothing is rendered in the
1946
+ * requester's browser. Positive evidence only: an unresolved base keeps the
1947
+ * sink.
1948
+ */
1949
+ function isJavaOutboundOutputStreamWrite(call, pattern, language, sourceLines) {
1950
+ if (language !== 'java' || pattern.type !== 'xss')
1951
+ return false;
1952
+ const receiver = (call.receiver ?? '').replace(/\s+/g, '');
1953
+ if (!receiver.endsWith('.getOutputStream()'))
1954
+ return false;
1955
+ if (/\.openConnection\(\)\)?\.getOutputStream\(\)$/.test(receiver))
1956
+ return true;
1957
+ const m = receiver.match(/^\(?([A-Za-z_]\w*)\)?\.getOutputStream\(\)$/);
1958
+ if (!m || !sourceLines)
1959
+ return false;
1960
+ const declRe = new RegExp(`\\b(?:[\\w.]+\\.)?(?:${JAVA_OUTBOUND_STREAM_OWNERS})\\s+${m[1]}\\s*[=;,)]`);
1961
+ // Nearest declaration above the call wins; a re-declaration with another
1962
+ // type (shadowing) stops the walk.
1963
+ // `var x = …` (inferred type) also shadows, and its type is unknown here.
1964
+ const anyDeclRe = new RegExp(`\\b(?:[A-Z][\\w.<>,\\[\\]]*|var)\\s+${m[1]}\\s*[=;,)]`);
1965
+ for (let i = call.location.line - 1; i >= 0; i--) {
1966
+ // Strip string literals, `//` comments and `/* … */` block comments so a
1967
+ // commented-out declaration cannot match either regex.
1968
+ const line = (sourceLines[i] ?? '')
1969
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
1970
+ .replace(/\/\*[\s\S]*?\*\//g, '')
1971
+ .replace(/\/\/.*$/, '');
1972
+ if (declRe.test(line))
1973
+ return true;
1974
+ if (anyDeclRe.test(line))
1975
+ return false;
1976
+ }
1977
+ return false;
1978
+ }
1893
1979
  /**
1894
1980
  * Gate for Go `json.Unmarshal(data, &dst)` / `json.NewDecoder(r).Decode(&dst)`
1895
1981
  * deserialization (CWE-502) sinks. Returns `true` when the destination is a
@@ -2126,6 +2212,11 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
2126
2212
  if (isSafeCSharpProcessStartCall(call, pattern, language, sourceLines)) {
2127
2213
  continue;
2128
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
+ }
2129
2220
  // Skip C# SSRF sinks dominated by an inline exact-equality host
2130
2221
  // allowlist (`if (host == "c") sink` / `if (host != "c") return; sink`).
2131
2222
  // cognium-ai#328 shape 1.
@@ -2271,6 +2362,12 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
2271
2362
  if (isGoFprintToNonResponseWriter(call, pattern, language, sourceLines)) {
2272
2363
  continue;
2273
2364
  }
2365
+ // #671 — `conn.getOutputStream().write(...)` on an outbound
2366
+ // URLConnection / Socket / Process writes to a remote peer or child
2367
+ // process, not to the HTTP response. Not xss.
2368
+ if (isJavaOutboundOutputStreamWrite(call, pattern, language, sourceLines)) {
2369
+ continue;
2370
+ }
2274
2371
  // #147 — Jinja2 safe render-context. Template("literal").render(**ctx)
2275
2372
  // and render_template_string("literal", **ctx) and Template("literal")
2276
2373
  // alone are safe: the template SOURCE is a constant string and Jinja2
@@ -2318,7 +2415,7 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
2318
2415
  /**
2319
2416
  * Check if a call matches a source pattern.
2320
2417
  */
2321
- function matchesSourcePattern(call, pattern) {
2418
+ function matchesSourcePattern(call, pattern, language, types, imports) {
2322
2419
  // Method-based matching
2323
2420
  if (pattern.method) {
2324
2421
  if (call.method_name !== pattern.method) {
@@ -2351,6 +2448,14 @@ function matchesSourcePattern(call, pattern) {
2351
2448
  return false;
2352
2449
  }
2353
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
+ }
2354
2459
  else if (!receiverMightBeClass(call.receiver, pattern.class)) {
2355
2460
  // The receiver might be a variable name, not the class name
2356
2461
  // For now, we do a simple match - in a full implementation,
@@ -2559,7 +2664,98 @@ const SINK_FQN_EXCLUSIONS = {
2559
2664
  // Expression.accept(ExpressionVisitor), etc.
2560
2665
  'net.sf.jsqlparser.',
2561
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
+ ],
2562
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'],
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
+ }
2563
2759
  /**
2564
2760
  * Check if a call matches a sink pattern.
2565
2761
  */
@@ -2660,6 +2856,13 @@ function matchesSinkPattern(call, pattern, typeHierarchy, language) {
2660
2856
  // If the mapped return type is a subtype of the sink class, accept.
2661
2857
  // — cognium-dev #241 Java
2662
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
+ }
2663
2866
  else if (call.receiver && !receiverMightBeClass(call.receiver, pattern.class)) {
2664
2867
  // Heuristic match failed; fall back to TypeHierarchyResolver if available
2665
2868
  if (typeHierarchy && typeHierarchy.couldBeType(call.receiver, pattern.class)) {
@@ -2806,6 +3009,21 @@ function receiverMightBeClassImpl(receiver, className) {
2806
3009
  if (receiver === className) {
2807
3010
  return true;
2808
3011
  }
3012
+ // Inline CommonJS require receiver: `require('child_process').exec(x)`.
3013
+ // The module is named at the call, so match it as if it were bound to a
3014
+ // name (`const cp = require('child_process'); cp.exec(x)`). (cognium-dev#682)
3015
+ const inlineRequire = receiver.match(/^require\(\s*['"`](?:node:)?([\w./-]+)['"`]\s*\)$/);
3016
+ if (inlineRequire && inlineRequire[1] === className) {
3017
+ return true;
3018
+ }
3019
+ // Chained JDK ClassLoader accessors: `Thread.currentThread()
3020
+ // .getContextClassLoader().loadClass(x)`, `getClass().getClassLoader()
3021
+ // .loadClass(x)`, `ClassLoader.getSystemClassLoader().loadClass(x)`.
3022
+ // These return a ClassLoader, so match the receiver as one. (cognium-dev#623)
3023
+ if (className === 'ClassLoader' &&
3024
+ /(?:^|\.)(?:getContextClassLoader|getClassLoader|getSystemClassLoader)\(\s*\)$/.test(receiver.replace(/\s+/g, ''))) {
3025
+ return true;
3026
+ }
2809
3027
  // Constructor-call receiver: `ClassName(args)` — Python `Path(raw)`,
2810
3028
  // JS `new URL(s)` (handled separately), etc. When the receiver is a
2811
3029
  // direct function call whose function name equals the target class
@@ -3195,8 +3413,22 @@ export function isInDangerousPosition(argPosition, pattern) {
3195
3413
  /**
3196
3414
  * Find sanitizers in method calls and annotated parameters.
3197
3415
  */
3198
- function findSanitizers(calls, types, patterns, sourceLines) {
3416
+ function findSanitizers(calls, types, patterns, sourceLines, language) {
3199
3417
  const sanitizers = [];
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
+ };
3200
3432
  // Build a set of method names with @sanitizer annotation (from Javadoc comments)
3201
3433
  const sanitizerMethods = new Set();
3202
3434
  for (const type of types) {
@@ -3207,19 +3439,22 @@ function findSanitizers(calls, types, patterns, sourceLines) {
3207
3439
  }
3208
3440
  }
3209
3441
  // Sprint 9 #79 (Phase L): derive wrapper sanitizers.
3210
- // A method qualifies as a derived sanitizer when its body is exactly
3211
- // `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).
3212
3444
  // We require:
3213
- // 1. Method body span ≤ 2 lines (signature + single return).
3214
- // 2. Exactly one non-recursive call inside the method's line range.
3215
- // 3. That call matches a configured sanitizer pattern with non-empty
3216
- // `removes`.
3217
- // 4. The inner call's argument is exactly one of the wrapper's params.
3218
- // 5. When source is available, the inner call's source line, after
3219
- // `return ` / `return await `, starts with the inner call's
3220
- // `[receiver.]method(` prefix and ends with `)` — rejecting
3221
- // 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)`.
3222
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();
3223
3458
  for (const type of types) {
3224
3459
  for (const method of type.methods) {
3225
3460
  const bodySize = method.end_line - method.start_line;
@@ -3236,23 +3471,52 @@ function findSanitizers(calls, types, patterns, sourceLines) {
3236
3471
  continue; // recursion guard
3237
3472
  inside.push(c);
3238
3473
  }
3239
- if (inside.length !== 1)
3474
+ if (inside.length === 0)
3475
+ continue;
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 {
3240
3487
  continue;
3241
- const innerCall = inside[0];
3488
+ }
3242
3489
  let matched;
3243
- for (const pattern of patterns) {
3244
- if (matchesSanitizerPattern(innerCall, pattern)) {
3245
- matched = pattern;
3246
- break;
3490
+ const replaceCredit = javaReplace(innerCall);
3491
+ if (replaceCredit) {
3492
+ matched = replaceCredit.length > 0 ? { method: innerCall.method_name, removes: replaceCredit } : undefined;
3493
+ }
3494
+ else {
3495
+ for (const pattern of patterns) {
3496
+ if (matchesSanitizerPattern(innerCall, pattern)) {
3497
+ matched = pattern;
3498
+ break;
3499
+ }
3247
3500
  }
3248
3501
  }
3249
3502
  if (!matched || !matched.removes || matched.removes.length === 0)
3250
3503
  continue;
3251
3504
  let argOk = false;
3252
- for (const arg of innerCall.arguments) {
3253
- if (arg.variable && paramNames.has(arg.variable)) {
3254
- argOk = true;
3255
- 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
+ }
3256
3520
  }
3257
3521
  }
3258
3522
  if (!argOk)
@@ -3264,14 +3528,24 @@ function findSanitizers(calls, types, patterns, sourceLines) {
3264
3528
  if (!returnMatch)
3265
3529
  continue;
3266
3530
  const after = returnMatch[1].replace(/;\s*$/, '').trimEnd();
3267
- const callPrefix = innerCall.receiver
3268
- ? `${innerCall.receiver}.${innerCall.method_name}(`
3269
- : `${innerCall.method_name}(`;
3270
- if (!after.startsWith(callPrefix))
3271
- 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
+ }
3272
3543
  if (!after.endsWith(')'))
3273
3544
  continue;
3274
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);
3275
3549
  const existing = wrapperSanitizers.get(method.name);
3276
3550
  if (existing) {
3277
3551
  const set = new Set([...existing, ...matched.removes]);
@@ -3282,18 +3556,88 @@ function findSanitizers(calls, types, patterns, sourceLines) {
3282
3556
  }
3283
3557
  }
3284
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
+ }
3285
3616
  // Emit a derived-sanitizer entry at each call site to a wrapper method.
3286
3617
  for (const call of calls) {
3287
3618
  const removes = wrapperSanitizers.get(call.method_name);
3288
3619
  if (!removes)
3289
3620
  continue;
3290
3621
  sanitizers.push({
3291
- 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',
3292
3625
  method: formatSanitizerMethod(call),
3293
3626
  line: call.location.line,
3294
3627
  sanitizes: removes,
3295
3628
  });
3296
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();
3297
3641
  // Check method calls for sanitizer methods
3298
3642
  for (const call of calls) {
3299
3643
  // Check if this call is to a method with @sanitizer annotation
@@ -3306,8 +3650,23 @@ function findSanitizers(calls, types, patterns, sourceLines) {
3306
3650
  });
3307
3651
  continue; // Skip pattern matching - already added as sanitizer
3308
3652
  }
3653
+ const replaceCredit = javaReplace(call);
3654
+ if (replaceCredit) {
3655
+ if (replaceCredit.length > 0) {
3656
+ sanitizers.push({
3657
+ type: 'method_call',
3658
+ method: formatSanitizerMethod(call),
3659
+ line: call.location.line,
3660
+ sanitizes: replaceCredit,
3661
+ });
3662
+ }
3663
+ continue;
3664
+ }
3309
3665
  for (const pattern of patterns) {
3310
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
+ }
3311
3670
  sanitizers.push({
3312
3671
  type: determineSanitizerType(pattern),
3313
3672
  method: formatSanitizerMethod(call),
@@ -3336,6 +3695,308 @@ function findSanitizers(calls, types, patterns, sourceLines) {
3336
3695
  }
3337
3696
  return sanitizers;
3338
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
+ }
3925
+ const JAVA_LIT = String.raw `(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')`;
3926
+ const JAVA_REPLACE_ARGS_RE = new RegExp(String.raw `\.(replace|replaceAll)\(\s*${JAVA_LIT}\s*,\s*${JAVA_LIT}\s*\)`, 'g');
3927
+ /**
3928
+ * The text of the call chain that starts at (line, column): the receiver
3929
+ * expression plus the `.method(...)` segments after it, up to the first
3930
+ * character that cannot continue the chain at nesting depth 0 (`;`, `,`, an
3931
+ * operator, an unmatched closing bracket). A call reports the start of the
3932
+ * whole chain it ends, so `a.replace("\n", "")` and `b.replace("\r", "")` on one
3933
+ * line start at different columns and never credit each other. The chain may
3934
+ * continue over following lines.
3935
+ */
3936
+ function javaChainTextAt(sourceLines, line, column) {
3937
+ const text = [(sourceLines[line - 1] ?? '').slice(column), ...sourceLines.slice(line, line + 7)].join('\n');
3938
+ let depth = 0;
3939
+ let i = 0;
3940
+ for (; i < text.length; i++) {
3941
+ const c = text[i];
3942
+ if (c === '"' || c === "'") {
3943
+ for (i++; i < text.length && text[i] !== c; i++)
3944
+ if (text[i] === '\\')
3945
+ i++;
3946
+ continue;
3947
+ }
3948
+ if (c === '(' || c === '[') {
3949
+ depth++;
3950
+ continue;
3951
+ }
3952
+ if (c === ')' || c === ']') {
3953
+ if (depth === 0)
3954
+ break;
3955
+ depth--;
3956
+ continue;
3957
+ }
3958
+ if (depth === 0 && !/[\w$.\s]/.test(c))
3959
+ break;
3960
+ }
3961
+ return text.slice(0, i);
3962
+ }
3963
+ /**
3964
+ * Java `String.replace` / `replaceAll` credit, decided from the literal
3965
+ * arguments of the replace chain that contains the call instead of the method name
3966
+ * (cognium-dev#562 / #605 / #517). The class-less `replace` row credited any
3967
+ * replace as a log-injection sanitizer, so an LF-only strip silenced the
3968
+ * finding while `replaceAll("[\r\n]", "")` got no credit at all.
3969
+ * - `log_injection`: the chain strips both CR and LF (double-quoted String or single-quoted char literals).
3970
+ * - `xss`: the chain maps both `<` and `>` to an entity.
3971
+ * Returns the credited sink types (possibly empty).
3972
+ */
3973
+ function javaReplaceChainCredit(chainText) {
3974
+ let cr = false;
3975
+ let lf = false;
3976
+ let lt = false;
3977
+ let gt = false;
3978
+ for (const m of chainText.matchAll(JAVA_REPLACE_ARGS_RE)) {
3979
+ const kind = m[1];
3980
+ const rawFrom = m[2] ?? m[3] ?? '';
3981
+ const from = rawFrom.replace(/\\\\/g, '\\');
3982
+ const to = m[4] ?? m[5] ?? '';
3983
+ const cov = javaReplaceCrLfCoverage(kind, rawFrom);
3984
+ if (cov.cr)
3985
+ cr = true;
3986
+ if (cov.lf)
3987
+ lf = true;
3988
+ if (from === '<' && to.startsWith('&'))
3989
+ lt = true;
3990
+ if (from === '>' && to.startsWith('&'))
3991
+ gt = true;
3992
+ }
3993
+ const out = [];
3994
+ if (cr && lf)
3995
+ out.push('log_injection');
3996
+ if (lt && gt)
3997
+ out.push('xss');
3998
+ return out;
3999
+ }
3339
4000
  /**
3340
4001
  * Check if a call matches a sanitizer pattern.
3341
4002
  */