circle-ir 4.14.2 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/analysis/const-table-lookup.d.ts +29 -0
  2. package/dist/analysis/const-table-lookup.d.ts.map +1 -0
  3. package/dist/analysis/const-table-lookup.js +145 -0
  4. package/dist/analysis/const-table-lookup.js.map +1 -0
  5. package/dist/analysis/findings.d.ts.map +1 -1
  6. package/dist/analysis/findings.js +15 -4
  7. package/dist/analysis/findings.js.map +1 -1
  8. package/dist/analysis/go-strings-replacer.d.ts +21 -0
  9. package/dist/analysis/go-strings-replacer.d.ts.map +1 -0
  10. package/dist/analysis/go-strings-replacer.js +145 -0
  11. package/dist/analysis/go-strings-replacer.js.map +1 -0
  12. package/dist/analysis/interprocedural.d.ts.map +1 -1
  13. package/dist/analysis/interprocedural.js +53 -0
  14. package/dist/analysis/interprocedural.js.map +1 -1
  15. package/dist/analysis/java-canonical-prefix.d.ts +42 -0
  16. package/dist/analysis/java-canonical-prefix.d.ts.map +1 -0
  17. package/dist/analysis/java-canonical-prefix.js +145 -0
  18. package/dist/analysis/java-canonical-prefix.js.map +1 -0
  19. package/dist/analysis/java-crlf-strip.d.ts +30 -0
  20. package/dist/analysis/java-crlf-strip.d.ts.map +1 -0
  21. package/dist/analysis/java-crlf-strip.js +195 -0
  22. package/dist/analysis/java-crlf-strip.js.map +1 -0
  23. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts +2 -0
  24. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts.map +1 -1
  25. package/dist/analysis/passes/cleartext-credential-transport-pass.js +64 -12
  26. package/dist/analysis/passes/cleartext-credential-transport-pass.js.map +1 -1
  27. package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
  28. package/dist/analysis/passes/cross-file-pass.js +43 -0
  29. package/dist/analysis/passes/cross-file-pass.js.map +1 -1
  30. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  31. package/dist/analysis/passes/csharp-sanitizer-credit.js +18 -0
  32. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
  33. package/dist/analysis/passes/interprocedural-pass.d.ts +28 -0
  34. package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
  35. package/dist/analysis/passes/interprocedural-pass.js +20 -1
  36. package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
  37. package/dist/analysis/passes/language-sources-pass.d.ts +41 -2
  38. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  39. package/dist/analysis/passes/language-sources-pass.js +842 -10
  40. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  41. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts +8 -0
  42. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
  43. package/dist/analysis/passes/param-source-flow-gate-pass.js +162 -6
  44. package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
  45. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  46. package/dist/analysis/passes/sink-filter-pass.js +207 -1
  47. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  48. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  49. package/dist/analysis/passes/taint-propagation-pass.js +257 -2
  50. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  51. package/dist/analysis/scalar-derivation.d.ts +25 -0
  52. package/dist/analysis/scalar-derivation.d.ts.map +1 -0
  53. package/dist/analysis/scalar-derivation.js +196 -0
  54. package/dist/analysis/scalar-derivation.js.map +1 -0
  55. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  56. package/dist/analysis/taint-matcher.js +167 -6
  57. package/dist/analysis/taint-matcher.js.map +1 -1
  58. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  59. package/dist/analysis/taint-propagation.js +149 -19
  60. package/dist/analysis/taint-propagation.js.map +1 -1
  61. package/dist/analyzer.d.ts.map +1 -1
  62. package/dist/analyzer.js +7 -0
  63. package/dist/analyzer.js.map +1 -1
  64. package/dist/browser/circle-ir.js +3140 -1186
  65. package/dist/core/circle-ir-core.cjs +485 -27
  66. package/dist/core/circle-ir-core.js +485 -27
  67. package/dist/core/extractors/dfg.js +240 -6
  68. package/dist/core/extractors/dfg.js.map +1 -1
  69. package/dist/graph/code-graph.d.ts +9 -0
  70. package/dist/graph/code-graph.d.ts.map +1 -1
  71. package/dist/graph/code-graph.js +11 -0
  72. package/dist/graph/code-graph.js.map +1 -1
  73. package/dist/resolution/cross-file.d.ts +7 -0
  74. package/dist/resolution/cross-file.d.ts.map +1 -1
  75. package/dist/resolution/cross-file.js +10 -1
  76. package/dist/resolution/cross-file.js.map +1 -1
  77. package/dist/wasm/web-tree-sitter.wasm +0 -0
  78. package/package.json +3 -3
@@ -15,6 +15,10 @@
15
15
  */
16
16
  import { DOM_ASSIGNMENT_SINK_METHODS, isProvablyLiteralExpression, } from '../literal-expression.js';
17
17
  import { attachSourceLineCode } from '../taint-matcher.js';
18
+ import { javaReplaceCrLfCoverage } from '../java-crlf-strip.js';
19
+ import { javaCallArgument, javaCanonicalBaseLacksSeparator } from '../java-canonical-prefix.js';
20
+ import { findGoStringsReplacerSanitizers } from '../go-strings-replacer.js';
21
+ import { constLookupTables, isConstTableLookup } from '../const-table-lookup.js';
18
22
  // ---------------------------------------------------------------------------
19
23
  // Pattern tables (moved from analyzer.ts)
20
24
  // ---------------------------------------------------------------------------
@@ -285,13 +289,19 @@ export class LanguageSourcesPass {
285
289
  additionalSanitizers.push(...findGoMapAllowlistGuardSanitizers(code));
286
290
  additionalSanitizers.push(...findGoHtmlTemplateImportSanitizers(code));
287
291
  additionalSanitizers.push(...findGoPathContainmentGuardSanitizers(code));
292
+ // #348 / #293: strings.NewReplacer CR/LF strip and < > entity escape.
293
+ additionalSanitizers.push(...findGoStringsReplacerSanitizers(code));
288
294
  // Sprint 78 (#190): Go ECB-mode weak-crypto detection.
289
295
  const goMisconfigFindings = findGoPatternFindings(code, graph.ir.meta.file);
290
296
  for (const finding of goMisconfigFindings) {
291
297
  ctx.addFinding(finding);
292
298
  }
293
299
  // Sprint 81 (#189): Go xss — fmt.Fprint(f|ln) to http.ResponseWriter.
294
- for (const finding of findGoXssFindings(code, graph.ir.meta.file)) {
300
+ // #568: an arg that is an int/bool measure local carries no payload.
301
+ const scalarVarsAt = (line) => new Set(graph.usesAtLine(line)
302
+ .filter(u => u.def_id != null && graph.taintBarrierDefIds.has(u.def_id))
303
+ .map(u => u.variable));
304
+ for (const finding of findGoXssFindings(code, graph.ir.meta.file, scalarVarsAt)) {
295
305
  ctx.addFinding(finding);
296
306
  }
297
307
  // Sprint 82 (#189): Go open_redirect — Header().Set("Location", taint).
@@ -523,6 +533,28 @@ export class LanguageSourcesPass {
523
533
  // cognium-dev#269: `!file.getCanonicalPath().startsWith(base)` reject
524
534
  // guard (the java.io.File OWASP-recommended containment defence).
525
535
  additionalSanitizers.push(...findJavaCanonicalPathStartsWithGuardSanitizers(code));
536
+ // cognium-dev#293: `if (!Set.of("a", "b").contains(key)) throw` allow-list guard.
537
+ additionalSanitizers.push(...findJavaLiteralAllowlistGuardSanitizers(code));
538
+ // cognium-dev#668: `!<literal set>.contains(cls)` reject guard before reflection.
539
+ additionalSanitizers.push(...findJavaReflectionAllowlistGuardSanitizers(code, types));
540
+ // cognium-dev#566: a Spring @RestController / @ResponseBody / ResponseEntity
541
+ // handler's returned String is the response body. TaintPropagationPass
542
+ // links each synthesised sink to its source.
543
+ {
544
+ const tm = ctx.getResult('taint-matcher');
545
+ const xssSan = tm.sanitizers.filter(sa => sa.sanitizes.includes('xss')).map(sa => sa.line);
546
+ for (const r of findJavaSpringReturnXSSSinks(code, types, tm.sources, xssSan)) {
547
+ if (additionalSinks.some(s => s.line === r.sinkLine && s.type === 'xss'))
548
+ continue;
549
+ additionalSinks.push({
550
+ type: 'xss',
551
+ cwe: 'CWE-79',
552
+ line: r.sinkLine,
553
+ location: `Spring handler returns HTML with request input at line ${r.sinkLine}`,
554
+ confidence: 0.9,
555
+ });
556
+ }
557
+ }
526
558
  additionalSanitizers.push(...findJavaInlineCrlfStripLogSanitizers(code));
527
559
  // cognium-dev#293: the same strip, but living in a user-defined helper.
528
560
  additionalSanitizers.push(...findCrlfWrapperFunctionSanitizers(code, 'java'));
@@ -1968,6 +2000,9 @@ function findJavaScriptAssignmentSources(sourceCode, language) {
1968
2000
  const patterns = isRouteHandler
1969
2001
  ? [...JS_TAINTED_PATTERNS, { pattern: /\bparams\s*[.[]/, type: 'http_path' }]
1970
2002
  : JS_TAINTED_PATTERNS;
2003
+ // #667: `REGISTRY[req.query.mod]` on a constant table yields a value the
2004
+ // program wrote, not request data.
2005
+ const lookupTables = constLookupTables(sourceCode, language);
1971
2006
  for (let lineNum = 0; lineNum < lines.length; lineNum++) {
1972
2007
  const line = lines[lineNum];
1973
2008
  const lineNumber = lineNum + 1;
@@ -1975,6 +2010,8 @@ function findJavaScriptAssignmentSources(sourceCode, language) {
1975
2010
  if (!assignmentMatch)
1976
2011
  continue;
1977
2012
  const [, varName, rhs] = assignmentMatch;
2013
+ if (isConstTableLookup(rhs, lookupTables))
2014
+ continue;
1978
2015
  for (const { pattern, type } of patterns) {
1979
2016
  if (pattern.test(rhs)) {
1980
2017
  const alreadyExists = sources.some(s => s.line === lineNumber && s.type === type);
@@ -2004,6 +2041,174 @@ function findJavaScriptAssignmentSources(sourceCode, language) {
2004
2041
  // event-name allowlist to avoid firing on unrelated `.on('drain', cb)`
2005
2042
  // / `.on('close', cb)` etc. that carry non-user-input.
2006
2043
  sources.push(...findJavaScriptCallbackParamSources(sourceCode));
2044
+ if (language === 'javascript') {
2045
+ sources.push(...findExpressDestructuredRequestSources(sourceCode));
2046
+ sources.push(...findGraphqlResolverArgSources(sourceCode));
2047
+ }
2048
+ return sources;
2049
+ }
2050
+ /** Index just past the `}` matching the `{` at `open`, or -1. */
2051
+ function matchBrace(text, open) {
2052
+ let depth = 0;
2053
+ for (let i = open; i < text.length; i++) {
2054
+ const ch = text[i];
2055
+ if (ch === '{')
2056
+ depth++;
2057
+ else if (ch === '}') {
2058
+ depth--;
2059
+ if (depth === 0)
2060
+ return i + 1;
2061
+ }
2062
+ }
2063
+ return -1;
2064
+ }
2065
+ const EXPRESS_REQUEST_KEYS = {
2066
+ query: 'http_param',
2067
+ params: 'http_param',
2068
+ body: 'http_body',
2069
+ headers: 'http_header',
2070
+ cookies: 'http_cookie',
2071
+ };
2072
+ /**
2073
+ * cognium-dev#587 — Express handler whose first parameter destructures the
2074
+ * request: `app.get(path, ({ query }, res) => …)`, `({ query: q }, res)`,
2075
+ * `({ query: { id } }, res)`. Seeds the bound names of the allowlisted request
2076
+ * keys only. Gated on a receiver bound to `express()` / `express.Router()` /
2077
+ * `Router()` in the same file, so an arbitrary `({ query }) => …` callback is
2078
+ * not a source.
2079
+ */
2080
+ function findExpressDestructuredRequestSources(sourceCode) {
2081
+ const sources = [];
2082
+ const receivers = new Set();
2083
+ for (const m of sourceCode.matchAll(/\b(?:const|let|var)\s+(\w+)\s*=\s*(?:express\s*\(\s*\)|express\s*\.\s*Router\s*\(\s*\)|Router\s*\(\s*\))/g)) {
2084
+ receivers.add(m[1]);
2085
+ }
2086
+ if (receivers.size === 0)
2087
+ return sources;
2088
+ const REG_RE = /\b(\w+)\s*\.\s*(?:get|post|put|patch|delete|all)\s*\(\s*(['"`])[^'"`]*\2\s*,\s*(?:async\s+)?(?:function\s*\w*\s*)?\(\s*\{/g;
2089
+ for (const m of sourceCode.matchAll(REG_RE)) {
2090
+ if (!receivers.has(m[1]))
2091
+ continue;
2092
+ const open = m.index + m[0].length - 1;
2093
+ const close = matchBrace(sourceCode, open);
2094
+ if (close < 0)
2095
+ continue;
2096
+ // The pattern must be followed by another parameter or `)`, i.e. it is
2097
+ // the handler's first parameter.
2098
+ if (!/^\s*[,)]/.test(sourceCode.slice(close)))
2099
+ continue;
2100
+ const line = sourceCode.slice(0, m.index + m[0].length).split('\n').length;
2101
+ const inner = sourceCode.slice(open + 1, close - 1);
2102
+ // Split top-level entries.
2103
+ const entries = [];
2104
+ let depth = 0;
2105
+ let start = 0;
2106
+ for (let i = 0; i < inner.length; i++) {
2107
+ if (inner[i] === '{')
2108
+ depth++;
2109
+ else if (inner[i] === '}')
2110
+ depth--;
2111
+ else if (inner[i] === ',' && depth === 0) {
2112
+ entries.push(inner.slice(start, i));
2113
+ start = i + 1;
2114
+ }
2115
+ }
2116
+ entries.push(inner.slice(start));
2117
+ for (const raw of entries) {
2118
+ const entry = raw.trim();
2119
+ const km = entry.match(/^(\w+)\s*(?::\s*([\s\S]+))?$/);
2120
+ if (!km)
2121
+ continue;
2122
+ const type = EXPRESS_REQUEST_KEYS[km[1]];
2123
+ if (!type)
2124
+ continue;
2125
+ const target = (km[2] ?? km[1]).replace(/=[\s\S]*$/, '').trim();
2126
+ const names = target.startsWith('{')
2127
+ ? [...target.slice(1, -1).matchAll(/(?:^|,)\s*(?:\w+\s*:\s*)?(\w+)/g)].map(x => x[1])
2128
+ : /^\w+$/.test(target) ? [target] : [];
2129
+ for (const name of names) {
2130
+ sources.push({
2131
+ type,
2132
+ location: `Express handler destructured request '${km[1]}' -> '${name}' at line ${line}`,
2133
+ severity: 'high',
2134
+ line,
2135
+ confidence: 0.95,
2136
+ variable: name,
2137
+ });
2138
+ }
2139
+ }
2140
+ }
2141
+ return sources;
2142
+ }
2143
+ /**
2144
+ * cognium-dev#586 — GraphQL resolver-map arguments. The second parameter of a
2145
+ * function under `Query` / `Mutation` / `Subscription` in the object passed as
2146
+ * `resolvers` to `new ApolloServer({…})` / `makeExecutableSchema({…})` (inline,
2147
+ * or a same-file `const resolvers = {…}`) is client-supplied. Parameter 0
2148
+ * (`parent`) and 2 (`context`) are never seeded.
2149
+ */
2150
+ function findGraphqlResolverArgSources(sourceCode) {
2151
+ const sources = [];
2152
+ const ctor = /(?:new\s+ApolloServer|makeExecutableSchema)\s*\(\s*\{/g;
2153
+ const maps = [];
2154
+ for (const m of sourceCode.matchAll(ctor)) {
2155
+ const open = m.index + m[0].length - 1;
2156
+ const close = matchBrace(sourceCode, open);
2157
+ if (close < 0)
2158
+ continue;
2159
+ const opts = sourceCode.slice(open, close);
2160
+ const inline = opts.match(/\bresolvers\s*:\s*\{/);
2161
+ if (inline) {
2162
+ maps.push(open + inline.index + inline[0].length - 1);
2163
+ continue;
2164
+ }
2165
+ const named = opts.match(/\bresolvers\s*(?::\s*(\w+))?\s*[,}]/);
2166
+ if (!named)
2167
+ continue;
2168
+ const ident = named[1] ?? 'resolvers';
2169
+ const decl = new RegExp(`\\b(?:const|let|var)\\s+${ident}\\s*=\\s*\\{`).exec(sourceCode);
2170
+ if (decl)
2171
+ maps.push(decl.index + decl[0].length - 1);
2172
+ }
2173
+ const FN_RE = /(?:^|[\s,{])(\w+)\s*(?::\s*(?:async\s+)?(?:function\s*\w*\s*)?\(|\()\s*\w+\s*,\s*(\w+)\s*[,)]/g;
2174
+ for (const open of maps) {
2175
+ const close = matchBrace(sourceCode, open);
2176
+ if (close < 0)
2177
+ continue;
2178
+ const body = sourceCode.slice(open, close);
2179
+ for (const t of body.matchAll(/\b(Query|Mutation|Subscription)\s*:\s*\{/g)) {
2180
+ const tOpen = open + t.index + t[0].length - 1;
2181
+ const tClose = matchBrace(sourceCode, tOpen);
2182
+ if (tClose < 0)
2183
+ continue;
2184
+ // Only direct members of the type object (depth 1).
2185
+ const typeBody = sourceCode.slice(tOpen + 1, tClose - 1);
2186
+ let depth = 0;
2187
+ const depthAt = [];
2188
+ for (let i = 0; i < typeBody.length; i++) {
2189
+ depthAt.push(depth);
2190
+ const ch = typeBody[i];
2191
+ if (ch === '{' || ch === '(')
2192
+ depth++;
2193
+ else if (ch === '}' || ch === ')')
2194
+ depth--;
2195
+ }
2196
+ for (const f of typeBody.matchAll(FN_RE)) {
2197
+ const at = f.index + f[0].indexOf(f[1]);
2198
+ if (depthAt[at] !== 0)
2199
+ continue;
2200
+ const line = sourceCode.slice(0, tOpen + 1 + at).split('\n').length;
2201
+ sources.push({
2202
+ type: 'http_param',
2203
+ location: `GraphQL ${t[1]}.${f[1]} resolver args '${f[2]}' at line ${line}`,
2204
+ severity: 'high',
2205
+ line,
2206
+ confidence: 0.9,
2207
+ variable: f[2],
2208
+ });
2209
+ }
2210
+ }
2211
+ }
2007
2212
  return sources;
2008
2213
  }
2009
2214
  /**
@@ -2811,6 +3016,7 @@ export function buildJavaScriptTaintedVars(sourceCode, language) {
2811
3016
  return new Map();
2812
3017
  const tainted = new Map();
2813
3018
  const lines = sourceCode.split('\n');
3019
+ const lookupTables = constLookupTables(sourceCode, language);
2814
3020
  // Seed with WebSocket `.on('message', cb)` callback-param names (#213
2815
3021
  // second slice). Same event allowlist as findJavaScriptCallbackParamSources.
2816
3022
  const CB_EVENT_RE = /\.on\s*\(\s*['"](?:message|text|binary)['"]\s*,\s*(?:async\s+)?(?:function\s*\*?\s*(?:\w+\s*)?\(\s*(\w+)|\(\s*(\w+)\s*(?:[,):]|$)|(\w+)\s*=>)/;
@@ -2834,6 +3040,8 @@ export function buildJavaScriptTaintedVars(sourceCode, language) {
2834
3040
  const [, lhs, rhs] = assignMatch;
2835
3041
  if (['if', 'while', 'for', 'return', 'true', 'false', 'null', 'undefined', 'case'].includes(lhs))
2836
3042
  continue;
3043
+ if (isConstTableLookup(rhs, lookupTables))
3044
+ continue; // #667
2837
3045
  const isDirectSource = JS_TAINTED_PATTERNS.some(p => p.pattern.test(rhs));
2838
3046
  const isTaintedPropagation = tainted.size > 0 && [...tainted.keys()].some(v => new RegExp(`\\b${v}\\b`).test(rhs));
2839
3047
  if (isDirectSource || isTaintedPropagation)
@@ -3825,9 +4033,11 @@ function findBashRealpathPrefixGuardSanitizers(code) {
3825
4033
  function findGoMapAllowlistGuardSanitizers(code) {
3826
4034
  const sanitizers = [];
3827
4035
  const lines = code.split('\n');
3828
- // `if !<mapName>[<key>] {` — optional whitespace, optional `_ ,`
3829
- // ignored. Captures the map identifier in group 1.
3830
- const guardOpen = /^\s*if\s+!\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[\s*[A-Za-z_][A-Za-z0-9_]*\s*\]\s*\{/;
4036
+ // `if !<mapName>[<key>] {`, or that negated lookup as one `||` disjunct
4037
+ // (`if err != nil || !<mapName>[u.Host] {`, #293): the block runs when any
4038
+ // disjunct holds, so falling past it proves the key is in the map. The key
4039
+ // may be a selector (`u.Host`). Captures the map identifier in group 1.
4040
+ const guardOpen = /^\s*if\s+(?:[^{&|]+\|\|\s*)*!\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[\s*[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?\s*\]\s*(?:\|\|[^{&]+)?\{/;
3831
4041
  // Allow-list naming heuristic.
3832
4042
  const allowlistName = /^(?:[A-Z][A-Z0-9_]+|.*?(allowed|accepted|whitelist|permitted|valid|approved).*)$/i;
3833
4043
  for (let i = 0; i < lines.length; i++) {
@@ -3863,9 +4073,24 @@ function findGoMapAllowlistGuardSanitizers(code) {
3863
4073
  if (closeLine === -1 || !bodyHasTerminator)
3864
4074
  continue;
3865
4075
  // Emit per-line sanitizers for every line AFTER the guard's closing
3866
- // brace through end of file. `checkSanitized` is line-keyed so this
3867
- // suppresses the relevant sink types in the safe branch.
3868
- for (let l = closeLine + 2; l <= lines.length; l++) {
4076
+ // brace to the end of the enclosing block (the function body, #293 —
4077
+ // it used to run to the end of the file, crediting later functions).
4078
+ // `checkSanitized` is line-keyed so this suppresses the relevant sink
4079
+ // types in the safe branch.
4080
+ let end = lines.length;
4081
+ for (let j = closeLine + 1, d = 0; j < lines.length; j++) {
4082
+ for (const ch of lines[j]) {
4083
+ if (ch === '{')
4084
+ d++;
4085
+ else if (ch === '}')
4086
+ d--;
4087
+ }
4088
+ if (d < 0) {
4089
+ end = j + 1;
4090
+ break;
4091
+ }
4092
+ }
4093
+ for (let l = closeLine + 2; l <= end; l++) {
3869
4094
  sanitizers.push({
3870
4095
  type: 'go_map_allowlist_guard',
3871
4096
  method: 'if',
@@ -5677,6 +5902,11 @@ function findJavaCanonicalPathStartsWithGuardSanitizers(code) {
5677
5902
  continue;
5678
5903
  if (!guardRejects(lines, i, terminatorRe, 6))
5679
5904
  continue;
5905
+ // cognium-dev#618: a string-prefix check against a canonical base with no
5906
+ // trailing separator is the prefix-confusion bypass, not a guard.
5907
+ const base = javaCallArgument(lines.slice(i).join('\n'), m.index + m[0].length - 1);
5908
+ if (base !== null && javaCanonicalBaseLacksSeparator(lines, i, base))
5909
+ continue;
5680
5910
  const guarded = new Set([m[1]]);
5681
5911
  const mentions = (t) => {
5682
5912
  for (const n of guarded)
@@ -5710,6 +5940,147 @@ function findJavaCanonicalPathStartsWithGuardSanitizers(code) {
5710
5940
  }
5711
5941
  return sanitizers;
5712
5942
  }
5943
+ /**
5944
+ * Identifiers allowed in the initializer of a constant allowlist collection.
5945
+ * Anything else (a method parameter, a request read, a config lookup) means
5946
+ * the membership set is not a fixed set of literals.
5947
+ */
5948
+ const JAVA_LITERAL_COLLECTION_IDENTS = new Set([
5949
+ 'new', 'String', 'Set', 'List', 'of', 'copyOf', 'Arrays', 'asList',
5950
+ 'HashSet', 'TreeSet', 'LinkedHashSet', 'ArrayList', 'LinkedList',
5951
+ 'Collections', 'unmodifiableSet', 'unmodifiableList',
5952
+ 'ImmutableSet', 'ImmutableList', 'java', 'util',
5953
+ ]);
5954
+ /**
5955
+ * cognium-dev#668 — Java reflection allowlist guard.
5956
+ *
5957
+ * private static final Set<String> ALLOWED = Set.of("a.Csv", "a.Json");
5958
+ * if (!ALLOWED.contains(cls)) { throw new SecurityException("denied"); }
5959
+ * return Class.forName(cls).getDeclaredConstructor().newInstance();
5960
+ *
5961
+ * Past a rejecting `!<SET>.contains(<v>)` guard, `<v>` is one of the set's
5962
+ * literals, so the class it names is chosen by the code, not the requester.
5963
+ * Positive evidence only: `<SET>` must be a `final` field or local of this
5964
+ * file whose initializer is nothing but string literals inside the standard
5965
+ * collection factories, and the guard must reject (throw / return). Credits
5966
+ * `<v>` on later lines of the enclosing method until `<v>` is reassigned.
5967
+ * Scoped to `code_injection` (reflective class loading); other sink types
5968
+ * are untouched.
5969
+ */
5970
+ function findJavaReflectionAllowlistGuardSanitizers(code, types) {
5971
+ const sanitizers = [];
5972
+ const lines = code.split('\n');
5973
+ const guardRe = /\bif\s*\(\s*!\s*(?:this\s*\.\s*|[A-Z]\w*\s*\.\s*)?([A-Za-z_]\w*)\s*\.\s*contains\s*\(\s*([A-Za-z_]\w*)\s*\)\s*\)/;
5974
+ const terminatorRe = /\b(throw|return)\b/;
5975
+ // Factory / constructor tokens that prove the initializer is a collection of
5976
+ // literals. A bare `final String ALLOWED = "…"` leaves no idents after string
5977
+ // stripping (so `.every` would pass) and `String.contains` is substring — not
5978
+ // an allowlist. Require at least one of these.
5979
+ const LITERAL_COLLECTION_FACTORIES = new Set([
5980
+ 'of', 'copyOf', 'asList', 'HashSet', 'TreeSet', 'LinkedHashSet',
5981
+ 'ArrayList', 'LinkedList', 'unmodifiableSet', 'unmodifiableList',
5982
+ 'ImmutableSet', 'ImmutableList',
5983
+ ]);
5984
+ /**
5985
+ * Nearest declaration of `name` at or above `fromLine` (0-based). A closer
5986
+ * non-final binding (local, `var`, parameter) wins over an earlier `final`
5987
+ * field; only a nearest `final` literal collection returns true.
5988
+ */
5989
+ const isLiteralCollection = (name, fromLine) => {
5990
+ const finalDeclRe = new RegExp(`\\bfinal\\b[^=;(]*\\b${name}\\s*=(?!=)`);
5991
+ // Type/var declaration or use as a parameter (`Type name` before `=` / `;` / `,` / `)`).
5992
+ const anyDeclRe = new RegExp(`\\b(?:[A-Z][\\w.<>,\\[\\]?\\s]*|var)\\s+${name}\\s*[=;,)]`);
5993
+ for (let i = fromLine; i >= 0; i--) {
5994
+ const line = lines[i];
5995
+ if (!anyDeclRe.test(line) && !finalDeclRe.test(line))
5996
+ continue;
5997
+ const dm = finalDeclRe.exec(line);
5998
+ if (!dm)
5999
+ return false; // nearer non-final binding — not a fixed allowlist
6000
+ // Initializer up to the terminating `;` (may span a few lines).
6001
+ let init = line.slice(dm.index + dm[0].length);
6002
+ for (let j = i + 1; !init.includes(';') && j < Math.min(lines.length, i + 12); j++)
6003
+ init += ' ' + lines[j];
6004
+ if (!init.includes(';'))
6005
+ return false;
6006
+ init = init.slice(0, init.indexOf(';')).replace(/"(?:[^"\\]|\\.)*"/g, '""');
6007
+ if (!init.includes('""'))
6008
+ return false;
6009
+ const idents = init.match(/[A-Za-z_]\w*/g) ?? [];
6010
+ // Empty idents (string-only initializer) or no collection factory → not a set.
6011
+ if (!idents.some(id => LITERAL_COLLECTION_FACTORIES.has(id)))
6012
+ return false;
6013
+ if (!idents.every(id => JAVA_LITERAL_COLLECTION_IDENTS.has(id)))
6014
+ return false;
6015
+ // A mutable collection that is written anywhere is not a fixed set.
6016
+ const mutRe = new RegExp(`\\b${name}\\s*\\.\\s*(?:add|addAll|put|putAll|set|replaceAll)\\s*\\(`);
6017
+ return !lines.some(l => mutRe.test(l));
6018
+ }
6019
+ return false;
6020
+ };
6021
+ for (let i = 0; i < lines.length; i++) {
6022
+ const m = guardRe.exec(lines[i]);
6023
+ if (!m)
6024
+ continue;
6025
+ const [, setName, guarded] = m;
6026
+ if (!guardRejects(lines, i, terminatorRe, 6))
6027
+ continue;
6028
+ if (!isLiteralCollection(setName, i))
6029
+ continue;
6030
+ const method = types
6031
+ .flatMap(t => t.methods)
6032
+ .filter(mt => mt.start_line <= i + 1 && mt.end_line >= i + 1)
6033
+ .sort((a, b) => (a.end_line - a.start_line) - (b.end_line - b.start_line))[0];
6034
+ if (!method)
6035
+ continue;
6036
+ // The guard must sit in the method body's top-level block, so it dominates
6037
+ // every later line (not inside an `if` branch, loop or lambda body).
6038
+ const before = [...lines.slice(method.start_line - 1, i), lines[i].slice(0, m.index)]
6039
+ .join('\n').replace(/"(?:[^"\\]|\\.)*"/g, '""');
6040
+ const depth = (before.match(/\{/g) ?? []).length - (before.match(/\}/g) ?? []).length;
6041
+ if (depth !== 1)
6042
+ continue;
6043
+ // Braceless `if`/`for`/`while`/`else` keeps brace-depth 1, so also reject a
6044
+ // guard that is the body of a preceding braceless control statement — on the
6045
+ // same line (`if (strict) if (!SET.contains(v))`) or the previous statement
6046
+ // (`} else` / `else` / `if (x)` without `{`).
6047
+ const samePrefix = lines[i].slice(0, m.index).replace(/\/\/.*$/, '').trim();
6048
+ let prev = '';
6049
+ for (let p = i - 1; p >= method.start_line - 1; p--) {
6050
+ const t = lines[p].replace(/\/\/.*$/, '').trim();
6051
+ if (!t)
6052
+ continue;
6053
+ prev = t;
6054
+ break;
6055
+ }
6056
+ const openBraceless = (s) => {
6057
+ // Allow a leading `}` so `} else` counts as braceless else.
6058
+ const t = s.replace(/^\}+\s*/, '').trim();
6059
+ return /^(?:else(?:\s+if\b)?|if|for|while)\b/.test(t) && !s.includes('{');
6060
+ };
6061
+ if (openBraceless(samePrefix) || (prev && openBraceless(prev)))
6062
+ continue;
6063
+ const refRe = new RegExp(`(?<![\\w.])${guarded}\\b`);
6064
+ // Credit only lines where `<v>` is a whole call argument, `f(<v>)`, so a
6065
+ // second tainted value on the same line (`forName(v + other)`) still reports.
6066
+ const wholeArgRe = new RegExp(`\\(\\s*${guarded}\\s*\\)`);
6067
+ const reassignRe = new RegExp(`(?<![\\w.])${guarded}\\s*(?:=(?!=)|\\+=)`);
6068
+ for (let l = i + 1; l < Math.min(method.end_line, lines.length); l++) {
6069
+ const lt = lines[l];
6070
+ if (reassignRe.test(lt))
6071
+ break;
6072
+ if (!refRe.test(lt) || !wholeArgRe.test(lt))
6073
+ continue;
6074
+ sanitizers.push({
6075
+ type: 'java_reflection_allowlist_guard',
6076
+ method: 'contains',
6077
+ line: l + 1,
6078
+ sanitizes: ['code_injection'],
6079
+ });
6080
+ }
6081
+ }
6082
+ return sanitizers;
6083
+ }
5713
6084
  /**
5714
6085
  * Does the `if (...)` guard on `lines[i]` actually **reject**?
5715
6086
  *
@@ -5785,6 +6156,212 @@ function guardRejects(lines, i, terminatorRe, window) {
5785
6156
  * form `if (x.StartsWith(root)) { … }` still reports, and a test pins that so
5786
6157
  * the gap stays visible rather than silently assumed.
5787
6158
  */
6159
+ /**
6160
+ * cognium-dev#293 (Java cell): membership in a literal allow-list followed by
6161
+ * a reject is a guard.
6162
+ *
6163
+ * if (!Set.of("locale", "theme").contains(key)) throw new IllegalArgumentException();
6164
+ * req.getSession().setAttribute(key, value); // was trust_boundary on `key`
6165
+ *
6166
+ * Past the guard, `key` is one of the listed string constants. The set must
6167
+ * be written out as string literals: inline `Set.of` / `List.of` /
6168
+ * `Arrays.asList`, or a `static final` constant of the same file initialised
6169
+ * from one of them. A set built any other way (a parameter, a config read, a
6170
+ * mutable collection) is not credited: its contents are unknown.
6171
+ *
6172
+ * Only the guarded variable is credited, from the guard to the end of the
6173
+ * enclosing method or the variable's next reassignment, and only when the
6174
+ * guard rejects (`guardRejects`).
6175
+ */
6176
+ const JAVA_OPERAND_KEYWORDS = new Set([
6177
+ 'new', 'return', 'this', 'super', 'null', 'true', 'false', 'throw', 'if', 'else', 'instanceof',
6178
+ 'int', 'long', 'short', 'byte', 'char', 'boolean', 'double', 'float', 'var', 'final',
6179
+ ]);
6180
+ /**
6181
+ * True when `expr` is only `name`, string literals, `.length()` / `.size()`,
6182
+ * and `+` (no other locals or value-producing calls).
6183
+ */
6184
+ function javaExprOnlyGuardedAndLits(expr, name) {
6185
+ let s = expr
6186
+ .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '')
6187
+ .replace(/\.\s*(?:length|size)\s*\(\s*\)/g, '')
6188
+ .replace(new RegExp(String.raw `\b${name}\b`, 'g'), '');
6189
+ s = s.replace(/[+\s()]/g, '');
6190
+ return s.length === 0;
6191
+ }
6192
+ /** Top-level comma-separated arguments of a call, respecting quotes and nesting. */
6193
+ function javaSplitCallArgs(argsText) {
6194
+ const out = [];
6195
+ let depth = 0;
6196
+ let quote = null;
6197
+ let start = 0;
6198
+ for (let i = 0; i < argsText.length; i++) {
6199
+ const c = argsText[i];
6200
+ if (quote) {
6201
+ if (c === '\\') {
6202
+ i++;
6203
+ continue;
6204
+ }
6205
+ if (c === quote)
6206
+ quote = null;
6207
+ continue;
6208
+ }
6209
+ if (c === '"' || c === "'") {
6210
+ quote = c;
6211
+ continue;
6212
+ }
6213
+ if (c === '(' || c === '[' || c === '{')
6214
+ depth++;
6215
+ else if (c === ')' || c === ']' || c === '}')
6216
+ depth--;
6217
+ else if (c === ',' && depth === 0) {
6218
+ out.push(argsText.slice(start, i).trim());
6219
+ start = i + 1;
6220
+ }
6221
+ }
6222
+ const last = argsText.slice(start).trim();
6223
+ if (last || out.length > 0)
6224
+ out.push(last);
6225
+ return out;
6226
+ }
6227
+ /**
6228
+ * True when a call on `line` (other than length/size) has an argument that is
6229
+ * not only string literals and the guarded variable — a raw operand.
6230
+ */
6231
+ function javaCallHasRawArgument(line, name) {
6232
+ const re = /\b([A-Za-z_]\w*)\s*\(/g;
6233
+ let m;
6234
+ while ((m = re.exec(line)) !== null) {
6235
+ const method = m[1];
6236
+ if (method === 'length' || method === 'size')
6237
+ continue;
6238
+ const open = m.index + m[0].length - 1;
6239
+ let depth = 0;
6240
+ let quote = null;
6241
+ let end = -1;
6242
+ for (let i = open; i < line.length; i++) {
6243
+ const c = line[i];
6244
+ if (quote) {
6245
+ if (c === '\\') {
6246
+ i++;
6247
+ continue;
6248
+ }
6249
+ if (c === quote)
6250
+ quote = null;
6251
+ continue;
6252
+ }
6253
+ if (c === '"' || c === "'") {
6254
+ quote = c;
6255
+ continue;
6256
+ }
6257
+ if (c === '(')
6258
+ depth++;
6259
+ else if (c === ')') {
6260
+ depth--;
6261
+ if (depth === 0) {
6262
+ end = i;
6263
+ break;
6264
+ }
6265
+ }
6266
+ }
6267
+ if (end < 0)
6268
+ continue;
6269
+ for (const arg of javaSplitCallArgs(line.slice(open + 1, end))) {
6270
+ if (!arg)
6271
+ continue;
6272
+ if (!javaExprOnlyGuardedAndLits(arg, name))
6273
+ return true;
6274
+ }
6275
+ }
6276
+ return false;
6277
+ }
6278
+ /**
6279
+ * True when `line` reads a lower-case identifier other than `name` as a value
6280
+ * (not a receiver, call name, member, type, constant or the assigned local),
6281
+ * or a call whose argument is not only string literals / `name` (a call-shaped
6282
+ * raw operand such as `req.getParameter("level")` beside the guarded local).
6283
+ */
6284
+ function otherLocalOperand(line, name) {
6285
+ if (javaCallHasRawArgument(line, name))
6286
+ return true;
6287
+ const s = line
6288
+ .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""')
6289
+ .replace(/^\s*(?:[\w<>[\],.?]+\s+)?[A-Za-z_]\w*\s*=(?!=)/, ' ')
6290
+ .replace(/\.\s*[A-Za-z_]\w*/g, ' ')
6291
+ .replace(/\b[A-Za-z_]\w*(?=\s*[.(])/g, ' ');
6292
+ return (s.match(/\b[a-z_]\w*\b/g) ?? []).some(id => id !== name && !JAVA_OPERAND_KEYWORDS.has(id));
6293
+ }
6294
+ function findJavaLiteralAllowlistGuardSanitizers(code) {
6295
+ const sanitizers = [];
6296
+ const lines = code.split('\n');
6297
+ const literalList = String.raw `(?:Set|List)\s*\.\s*of|Arrays\s*\.\s*asList`;
6298
+ const literalArgs = String.raw `\(\s*"(?:[^"\\]|\\.)*"(?:\s*,\s*"(?:[^"\\]|\\.)*")*\s*\)`;
6299
+ // Constants declared from a literal list in this file.
6300
+ const constants = new Set();
6301
+ const constRe = new RegExp(String.raw `\bstatic\s+final\s+[\w<>,.\s]+?\s([A-Za-z_]\w*)\s*=\s*(?:${literalList})\s*${literalArgs}\s*;`);
6302
+ for (const l of lines) {
6303
+ const m = constRe.exec(l);
6304
+ if (m)
6305
+ constants.add(m[1]);
6306
+ }
6307
+ const guardRe = new RegExp(String.raw `\bif\s*\(\s*!\s*(?:(?:${literalList})\s*${literalArgs}|([A-Za-z_]\w*))\s*\.\s*contains\s*\(\s*([A-Za-z_]\w*)\s*\)\s*\)`);
6308
+ const terminatorRe = /\b(throw|return)\b/;
6309
+ for (let i = 0; i < lines.length; i++) {
6310
+ const m = guardRe.exec(lines[i]);
6311
+ if (!m)
6312
+ continue;
6313
+ if (m[1] !== undefined && !constants.has(m[1]))
6314
+ continue;
6315
+ if (!guardRejects(lines, i, terminatorRe, 6))
6316
+ continue;
6317
+ const name = m[2];
6318
+ const mention = new RegExp(String.raw `(?<![\w.])${name}\b`);
6319
+ const reassign = new RegExp(String.raw `^\s*(?:[\w.<>[\]]+\s+)?${name}\s*=(?!=)`);
6320
+ // Skip the guard's own block, then credit to the end of the enclosing method.
6321
+ let start = i + 1;
6322
+ if (/\{\s*$/.test(lines[i])) {
6323
+ for (let d = 0, j = i; j < lines.length; j++) {
6324
+ for (const ch of lines[j]) {
6325
+ if (ch === '{')
6326
+ d++;
6327
+ else if (ch === '}')
6328
+ d--;
6329
+ }
6330
+ if (d <= 0) {
6331
+ start = j + 1;
6332
+ break;
6333
+ }
6334
+ }
6335
+ }
6336
+ for (let j = start, d = 0; j < lines.length; j++) {
6337
+ for (const ch of lines[j]) {
6338
+ if (ch === '{')
6339
+ d++;
6340
+ else if (ch === '}')
6341
+ d--;
6342
+ }
6343
+ if (d < 0)
6344
+ break;
6345
+ if (reassign.test(lines[j]))
6346
+ break;
6347
+ if (!mention.test(lines[j]))
6348
+ continue;
6349
+ // The credit is line-keyed: a line that also carries another local
6350
+ // (`"... n='" + who + "' ORDER BY " + col`) would clean `who` too.
6351
+ if (otherLocalOperand(lines[j], name))
6352
+ continue;
6353
+ sanitizers.push({
6354
+ type: 'java_literal_allowlist_guard',
6355
+ method: 'guard',
6356
+ line: j + 1,
6357
+ sanitizes: ['trust_boundary', 'sql_injection', 'command_injection', 'path_traversal', 'ssrf',
6358
+ 'ldap_injection', 'xpath_injection', 'log_injection', 'xss', 'open_redirect',
6359
+ 'external_taint_escape'],
6360
+ });
6361
+ }
6362
+ }
6363
+ return sanitizers;
6364
+ }
5788
6365
  /**
5789
6366
  * cognium-dev#272 — a validating predicate followed by an EARLY RETURN is a
5790
6367
  * guard, and the value reaching the sink afterwards is already validated.
@@ -6052,6 +6629,19 @@ function findJavaPathGetFileNameSanitizers(code) {
6052
6629
  * strips CRLF. In Java that means an explicit `String`/`CharSequence` return
6053
6630
  * type, which keeps `void` log-and-forget methods out.
6054
6631
  */
6632
+ /** True when the Java text strips both `\r` and `\n` via replace / replaceAll (cognium-dev#605). */
6633
+ function javaBodyStripsCrAndLf(body) {
6634
+ let cr = false;
6635
+ let lf = false;
6636
+ for (const m of body.matchAll(/\.\s*(replace|replaceAll)\s*\(\s*(?:"((?:\\.|[^"\\])*)"|'(\\.|[^'\\])')/g)) {
6637
+ const cov = javaReplaceCrLfCoverage(m[1], m[2] ?? m[3] ?? '');
6638
+ if (cov.cr)
6639
+ cr = true;
6640
+ if (cov.lf)
6641
+ lf = true;
6642
+ }
6643
+ return cr && lf;
6644
+ }
6055
6645
  function findCrlfWrapperFunctionSanitizers(code, language) {
6056
6646
  const sanitizers = [];
6057
6647
  const lines = code.split('\n');
@@ -6068,8 +6658,25 @@ function findCrlfWrapperFunctionSanitizers(code, language) {
6068
6658
  const m = declRe.exec(lines[i]);
6069
6659
  if (!m)
6070
6660
  continue;
6071
- const body = lines.slice(i, Math.min(lines.length, i + bodyWindow)).join('\n');
6072
- if (stripRe.test(body))
6661
+ if (language !== 'java') {
6662
+ const body = lines.slice(i, Math.min(lines.length, i + bodyWindow)).join('\n');
6663
+ if (stripRe.test(body))
6664
+ wrappers.add(m[1]);
6665
+ continue;
6666
+ }
6667
+ // Java: stop at the next declaration so one helper's body does not
6668
+ // credit the helper above it, and require the strip to cover BOTH CR and
6669
+ // LF. An LF-only `replace("\n", "")` leaves CR, which still forges a
6670
+ // log line (cognium-dev#605).
6671
+ let end = Math.min(lines.length, i + bodyWindow);
6672
+ for (let j = i + 1; j < end; j++) {
6673
+ if (declRe.test(lines[j])) {
6674
+ end = j;
6675
+ break;
6676
+ }
6677
+ }
6678
+ const body = lines.slice(i, end).join('\n');
6679
+ if (javaBodyStripsCrAndLf(body))
6073
6680
  wrappers.add(m[1]);
6074
6681
  }
6075
6682
  if (wrappers.size === 0)
@@ -7160,8 +7767,13 @@ export function findJsPatternFindings(code, file) {
7160
7767
  * the args is wrapped in a recognized HTML escaper
7161
7768
  * (`html.EscapeString` / `template.HTMLEscapeString` /
7162
7769
  * `template.HTMLEscaper`).
7770
+ *
7771
+ * A write whose every argument is a local holding an int or bool measure of
7772
+ * a value (`scalarVarsAt(line)`, #568 — `fmt.Fprintf(w, "%d", n)` with
7773
+ * `n := len(...)`) is skipped. Any other argument, including request reads
7774
+ * the taint engine has no source for (`r.URL.Path`), still reports.
7163
7775
  */
7164
- export function findGoXssFindings(code, file) {
7776
+ export function findGoXssFindings(code, file, scalarVarsAt = () => new Set()) {
7165
7777
  const out = [];
7166
7778
  const lines = code.split('\n');
7167
7779
  const rwNames = new Set();
@@ -7192,6 +7804,11 @@ export function findGoXssFindings(code, file) {
7192
7804
  // Require at least one identifier in tail outside string literals.
7193
7805
  if (!/[A-Za-z_]\w*/.test(tail.replace(/"[^"]*"|`[^`]*`/g, '')))
7194
7806
  continue;
7807
+ const idents = tail.replace(/"(?:[^"\\]|\\.)*"|`[^`]*`/g, '')
7808
+ .match(/[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*/g) ?? [];
7809
+ const scalars = scalarVarsAt(i + 1);
7810
+ if (idents.length > 0 && idents.every(id => scalars.has(id)))
7811
+ continue;
7195
7812
  out.push({
7196
7813
  id: `xss-${file}-${i + 1}`,
7197
7814
  pass: 'language-sources',
@@ -10556,4 +11173,219 @@ export function findJsTemplateInjectionSstiFindings(code, file) {
10556
11173
  }
10557
11174
  return findings;
10558
11175
  }
11176
+ // ---------------------------------------------------------------------------
11177
+ // cognium-dev#566 — Spring MVC return-as-body XSS (Java)
11178
+ // ---------------------------------------------------------------------------
11179
+ const SPRING_MAPPING_ANN = /^(?:Get|Post|Put|Delete|Patch|Request)Mapping\b/;
11180
+ const SPRING_WEB_PARAM_ANN = /^(?:RequestParam|PathVariable|RequestHeader|CookieValue|MatrixVariable)\b/;
11181
+ const SPRING_NON_TEXT_PARAM_TYPE = /^(?:int|long|short|byte|double|float|boolean|char|Integer|Long|Short|Byte|Double|Float|Boolean|Character|BigDecimal|BigInteger|UUID|LocalDate|LocalDateTime|LocalTime|Instant|OffsetDateTime|ZonedDateTime|Date)$/;
11182
+ /**
11183
+ * Value of `produces = …` on a `@*Mapping` annotation, or null when absent.
11184
+ * Only the produces value is considered — a path like `/widgets.html` must not
11185
+ * look like an HTML media type.
11186
+ */
11187
+ function springProducesValue(annotations) {
11188
+ for (const a of annotations) {
11189
+ if (!/^\w+Mapping\b/.test(a) || !/\bproduces\s*=/.test(a))
11190
+ continue;
11191
+ const m = /\bproduces\s*=\s*(?:\{([^}]*)\}|([^,)]+))/.exec(a);
11192
+ if (!m)
11193
+ continue;
11194
+ return (m[1] ?? m[2] ?? '').trim();
11195
+ }
11196
+ return null;
11197
+ }
11198
+ /** True when a produces value names a non-HTML media type. */
11199
+ function springProducesValueIsNonHtml(value) {
11200
+ return !/html/i.test(value);
11201
+ }
11202
+ /** Markup or string concatenation around a value (the Python return-XSS gate, #368). */
11203
+ function javaExprLooksLikeHtml(expr) {
11204
+ return /"[^"]*<[^"]*"/.test(expr) || /"\s*\+/.test(expr) || /\+\s*"/.test(expr);
11205
+ }
11206
+ /** Parentheses outside string literals never close below depth 0 and end at 0. */
11207
+ function javaParensBalanced(expr) {
11208
+ let depth = 0;
11209
+ for (const ch of expr.replace(/"(?:[^"\\]|\\.)*"/g, '""')) {
11210
+ if (ch === '(')
11211
+ depth++;
11212
+ else if (ch === ')' && --depth < 0)
11213
+ return false;
11214
+ }
11215
+ return depth === 0;
11216
+ }
11217
+ /**
11218
+ * cognium-dev#566 — the value a Spring MVC handler returns is the response
11219
+ * body when the class is `@RestController` (or `@ResponseBody` sits on the
11220
+ * class or the method), and always when the handler returns a
11221
+ * `ResponseEntity`. A `String` body is written with the negotiated content
11222
+ * type, so a browser request gets it as `text/html`: request data
11223
+ * concatenated into markup there is reflected XSS.
11224
+ *
11225
+ * Positive evidence only:
11226
+ * - the method carries a `@*Mapping` annotation;
11227
+ * - it returns `String` under a body-writing controller, or
11228
+ * `ResponseEntity<String>` via `ResponseEntity.ok(x)` /
11229
+ * `new ResponseEntity<>(x, …)` (the builder `.body(x)` form is left to
11230
+ * SinkFilterPass Stage 15c's text/html content-type rule);
11231
+ * - the returned expression references a request-bound parameter
11232
+ * (`@RequestParam`, `@PathVariable`, `@RequestHeader`, `@CookieValue`,
11233
+ * `@MatrixVariable`, a `String` `@RequestBody`) or an in-method source,
11234
+ * directly or through a local assigned from it, and looks like HTML
11235
+ * (markup or string concatenation, directly or in that local's assignment).
11236
+ *
11237
+ * Not a sink: a plain `@Controller` returning a `String` (a view name),
11238
+ * `produces = ` a non-HTML media type on the method or class mapping, a
11239
+ * non-`String` return (serialised as JSON), a returned value that does not
11240
+ * look like markup, and any handler with an xss-covering sanitizer between
11241
+ * its start and the return.
11242
+ */
11243
+ export function findJavaSpringReturnXSSSinks(code, types, sources, xssSanitizerLines) {
11244
+ const out = [];
11245
+ const lines = code.split('\n');
11246
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11247
+ const stripStrings = (s) => s.replace(/"(?:[^"\\]|\\.)*"/g, '""');
11248
+ const refers = (expr, name) => new RegExp(`(?<![\\w.])${esc(name)}\\b`).test(stripStrings(expr));
11249
+ for (const t of types) {
11250
+ const ann = t.annotations ?? [];
11251
+ const isRest = ann.some(a => /^RestController\b/.test(a));
11252
+ if (!isRest && !ann.some(a => /^Controller\b/.test(a)))
11253
+ continue;
11254
+ const classProduces = springProducesValue(ann);
11255
+ const classBody = isRest || ann.some(a => /^ResponseBody\b/.test(a));
11256
+ for (const m of t.methods) {
11257
+ if (!m.annotations.some(a => SPRING_MAPPING_ANN.test(a)))
11258
+ continue;
11259
+ // Method-level produces overrides class-level; only non-HTML suppresses.
11260
+ const methodProduces = springProducesValue(m.annotations);
11261
+ const produces = methodProduces ?? classProduces;
11262
+ if (produces != null && springProducesValueIsNonHtml(produces))
11263
+ continue;
11264
+ const rt = (m.return_type ?? '').replace(/\s+/g, '');
11265
+ const entity = rt === 'ResponseEntity<String>';
11266
+ const body = rt === 'String' && (classBody || m.annotations.some(a => /^ResponseBody\b/.test(a)));
11267
+ if (!entity && !body)
11268
+ continue;
11269
+ // Seeds: request-bound parameters, then in-method sources with a variable.
11270
+ const seeds = new Map();
11271
+ for (const p of m.parameters) {
11272
+ // Spring converts to the declared type first: a number, boolean,
11273
+ // UUID or date cannot carry markup.
11274
+ if (SPRING_NON_TEXT_PARAM_TYPE.test((p.type ?? '').replace(/^(?:[\w]+\.)+/, '')))
11275
+ continue;
11276
+ const bound = p.annotations.some(a => SPRING_WEB_PARAM_ANN.test(a))
11277
+ || (p.annotations.some(a => /^RequestBody\b/.test(a)) && p.type === 'String');
11278
+ if (bound)
11279
+ seeds.set(p.name, p.line ?? m.start_line);
11280
+ }
11281
+ for (const s of sources) {
11282
+ // A plain parameter of the handler is not request data by itself.
11283
+ if (s.type === 'interprocedural_param')
11284
+ continue;
11285
+ if (!s.variable || !/^[A-Za-z_]\w*$/.test(s.variable))
11286
+ continue;
11287
+ if (s.line > m.start_line && s.line <= m.end_line && !seeds.has(s.variable))
11288
+ seeds.set(s.variable, s.line);
11289
+ }
11290
+ if (seeds.size === 0)
11291
+ continue;
11292
+ // Locals assigned from a seed (method body only), each tied to its seed's line.
11293
+ const bodyText = lines.slice(m.start_line - 1, m.end_line).join('\n');
11294
+ const carriers = new Map();
11295
+ for (const [n, l] of seeds)
11296
+ carriers.set(n, { sourceLine: l, assignLine: l });
11297
+ const derived = [...buildJavaTaintedVars(bodyText, new Set(seeds.keys()))].sort((a, b) => a[1] - b[1]);
11298
+ for (const [n, rel] of derived) {
11299
+ if (carriers.has(n))
11300
+ continue;
11301
+ const assignLine = m.start_line + rel - 1;
11302
+ const from = [...carriers].find(([c]) => refers(lines[assignLine - 1] ?? '', c));
11303
+ carriers.set(n, { sourceLine: from ? from[1].sourceLine : m.start_line, assignLine });
11304
+ }
11305
+ for (let i = m.start_line - 1; i < Math.min(m.end_line, lines.length); i++) {
11306
+ if (!/^\s*return\b/.test(lines[i] ?? ''))
11307
+ continue;
11308
+ let stmt = lines[i];
11309
+ for (let j = i + 1; !stmt.includes(';') && j < Math.min(m.end_line, lines.length); j++)
11310
+ stmt += ' ' + lines[j];
11311
+ stmt = stmt.replace(/^\s*return\b/, '').replace(/;[^;]*$/, '');
11312
+ const sinkLine = i + 1;
11313
+ let expr = stmt;
11314
+ if (entity) {
11315
+ // `ResponseEntity.ok(x)` / `new ResponseEntity<>(x, status)` only. The
11316
+ // builder `.body(x)` form stays with SinkFilterPass Stage 15c, which
11317
+ // keeps it only next to an explicit text/html signal.
11318
+ const em = /^\s*ResponseEntity\s*\.\s*ok\s*\(([\s\S]+)\)\s*$/.exec(stmt)
11319
+ ?? /^\s*new\s+ResponseEntity\s*(?:<[^>]*>)?\s*\(([\s\S]+),[^,()]*\)\s*$/.exec(stmt);
11320
+ if (!em || !javaParensBalanced(em[1]))
11321
+ continue;
11322
+ expr = em[1];
11323
+ }
11324
+ const hit = [...carriers].find(([c, info]) => info.assignLine <= sinkLine && refers(expr, c));
11325
+ if (!hit)
11326
+ continue;
11327
+ const [name, info] = hit;
11328
+ let htmlish = javaExprLooksLikeHtml(expr);
11329
+ if (!htmlish && new RegExp(`^\\s*${esc(name)}\\s*$`).test(expr) && info.assignLine !== info.sourceLine) {
11330
+ htmlish = javaExprLooksLikeHtml(lines[info.assignLine - 1] ?? '');
11331
+ }
11332
+ if (!htmlish)
11333
+ continue;
11334
+ // Suppress only when an xss sanitizer's operand is the returned carrier
11335
+ // (or the carrier local was assigned from that sanitizer). Escaping a
11336
+ // different variable must not clear a return of another carrier.
11337
+ const xssEscapeOpRe = /\b(?:Encode\.(?:forHtml|forHtmlAttribute|forHtmlContent|forJavaScript)|StringEscapeUtils\.escape(?:Html3|Html4|EcmaScript|Xml)|HtmlUtils\.htmlEscape(?:Decimal|Hex)?|Escaper\.escapeHtml|HtmlEscapers\.(?:escapeHtml|htmlEscaper)|Encoder\.encodeForHTML(?:Attribute)?|Jsoup\.clean)\s*\(\s*([A-Za-z_]\w*)/;
11338
+ const sanitizerCoversCarrier = xssSanitizerLines.some(l => {
11339
+ if (l < m.start_line || l > sinkLine)
11340
+ return false;
11341
+ const line = lines[l - 1] ?? '';
11342
+ const ops = [];
11343
+ let sm;
11344
+ const re = new RegExp(xssEscapeOpRe.source, 'g');
11345
+ while ((sm = re.exec(line)) !== null)
11346
+ ops.push(sm[1]);
11347
+ // Unparsed shapes (e.g. static-import `htmlEscape(title)`) do not suppress.
11348
+ if (ops.length === 0)
11349
+ return false;
11350
+ // Operand is the returned carrier.
11351
+ if (ops.some(op => op === name))
11352
+ return true;
11353
+ // Carrier assigned from the sanitizer call itself (`body = HtmlUtils.htmlEscape(x)`),
11354
+ // not a concat that merely mentions a sanitizer on the assignment line.
11355
+ if (l === info.assignLine) {
11356
+ const stripped = line.replace(/"(?:[^"\\]|\\.)*"/g, '""').trim();
11357
+ const am = new RegExp(`\\b${esc(name)}\\s*=\\s*(.+)$`).exec(stripped);
11358
+ if (!am)
11359
+ return false;
11360
+ const rhs = am[1].replace(/;\s*$/, '').trim();
11361
+ const call = xssEscapeOpRe.exec(rhs);
11362
+ if (!call || call.index !== 0)
11363
+ return false;
11364
+ // Whole RHS is one sanitizer invocation (no concatenation outside the call).
11365
+ let depth = 0;
11366
+ let ended = -1;
11367
+ for (let k = call.index; k < rhs.length; k++) {
11368
+ const ch = rhs[k];
11369
+ if (ch === '(')
11370
+ depth++;
11371
+ else if (ch === ')') {
11372
+ depth--;
11373
+ if (depth === 0) {
11374
+ ended = k;
11375
+ break;
11376
+ }
11377
+ }
11378
+ }
11379
+ return ended >= 0 && rhs.slice(ended + 1).trim() === '';
11380
+ }
11381
+ return false;
11382
+ });
11383
+ if (sanitizerCoversCarrier)
11384
+ continue;
11385
+ out.push({ sinkLine, sourceLine: info.sourceLine });
11386
+ }
11387
+ }
11388
+ }
11389
+ return out;
11390
+ }
10559
11391
  //# sourceMappingURL=language-sources-pass.js.map