circle-ir 4.15.0 → 4.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/configs/sinks/golang.json +4 -4
  2. package/dist/analysis/config-loader.d.ts.map +1 -1
  3. package/dist/analysis/config-loader.js +11 -7
  4. package/dist/analysis/config-loader.js.map +1 -1
  5. package/dist/analysis/constant-caller-gate.d.ts +39 -0
  6. package/dist/analysis/constant-caller-gate.d.ts.map +1 -0
  7. package/dist/analysis/constant-caller-gate.js +298 -0
  8. package/dist/analysis/constant-caller-gate.js.map +1 -0
  9. package/dist/analysis/constant-propagation/evaluator.d.ts.map +1 -1
  10. package/dist/analysis/constant-propagation/evaluator.js +3 -1
  11. package/dist/analysis/constant-propagation/evaluator.js.map +1 -1
  12. package/dist/analysis/constant-propagation/patterns.d.ts +19 -0
  13. package/dist/analysis/constant-propagation/patterns.d.ts.map +1 -1
  14. package/dist/analysis/constant-propagation/patterns.js +33 -0
  15. package/dist/analysis/constant-propagation/patterns.js.map +1 -1
  16. package/dist/analysis/constant-propagation/propagator.d.ts +59 -0
  17. package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
  18. package/dist/analysis/constant-propagation/propagator.js +396 -49
  19. package/dist/analysis/constant-propagation/propagator.js.map +1 -1
  20. package/dist/analysis/constant-propagation/types.d.ts +9 -0
  21. package/dist/analysis/constant-propagation/types.d.ts.map +1 -1
  22. package/dist/analysis/findings.d.ts.map +1 -1
  23. package/dist/analysis/findings.js +134 -0
  24. package/dist/analysis/findings.js.map +1 -1
  25. package/dist/analysis/interprocedural.d.ts.map +1 -1
  26. package/dist/analysis/interprocedural.js +127 -3
  27. package/dist/analysis/interprocedural.js.map +1 -1
  28. package/dist/analysis/java-array-element-keys.d.ts +14 -0
  29. package/dist/analysis/java-array-element-keys.d.ts.map +1 -0
  30. package/dist/analysis/java-array-element-keys.js +36 -0
  31. package/dist/analysis/java-array-element-keys.js.map +1 -0
  32. package/dist/analysis/js-crlf-strip.d.ts +26 -0
  33. package/dist/analysis/js-crlf-strip.d.ts.map +1 -0
  34. package/dist/analysis/js-crlf-strip.js +244 -0
  35. package/dist/analysis/js-crlf-strip.js.map +1 -0
  36. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +25 -0
  37. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  38. package/dist/analysis/passes/csharp-sanitizer-credit.js +157 -12
  39. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
  40. package/dist/analysis/passes/language-sources-pass.d.ts +38 -3
  41. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  42. package/dist/analysis/passes/language-sources-pass.js +1234 -127
  43. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  44. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
  45. package/dist/analysis/passes/param-source-flow-gate-pass.js +53 -1
  46. package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
  47. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts +6 -0
  48. package/dist/analysis/passes/plaintext-password-storage-pass.d.ts.map +1 -1
  49. package/dist/analysis/passes/plaintext-password-storage-pass.js +160 -0
  50. package/dist/analysis/passes/plaintext-password-storage-pass.js.map +1 -1
  51. package/dist/analysis/passes/scan-secrets-pass.d.ts +1 -1
  52. package/dist/analysis/passes/scan-secrets-pass.d.ts.map +1 -1
  53. package/dist/analysis/passes/scan-secrets-pass.js +7 -1
  54. package/dist/analysis/passes/scan-secrets-pass.js.map +1 -1
  55. package/dist/analysis/passes/sink-filter-pass.d.ts +1 -1
  56. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  57. package/dist/analysis/passes/sink-filter-pass.js +326 -34
  58. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  59. package/dist/analysis/passes/taint-matcher-pass.d.ts.map +1 -1
  60. package/dist/analysis/passes/taint-matcher-pass.js +2 -2
  61. package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
  62. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  63. package/dist/analysis/passes/taint-propagation-pass.js +1376 -58
  64. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  65. package/dist/analysis/passes/weak-hash-pass.d.ts +6 -0
  66. package/dist/analysis/passes/weak-hash-pass.d.ts.map +1 -1
  67. package/dist/analysis/passes/weak-hash-pass.js +191 -4
  68. package/dist/analysis/passes/weak-hash-pass.js.map +1 -1
  69. package/dist/analysis/passes/weak-random-pass.d.ts +5 -0
  70. package/dist/analysis/passes/weak-random-pass.d.ts.map +1 -1
  71. package/dist/analysis/passes/weak-random-pass.js +30 -0
  72. package/dist/analysis/passes/weak-random-pass.js.map +1 -1
  73. package/dist/analysis/project-callee-escape-gate.d.ts +40 -0
  74. package/dist/analysis/project-callee-escape-gate.d.ts.map +1 -0
  75. package/dist/analysis/project-callee-escape-gate.js +243 -0
  76. package/dist/analysis/project-callee-escape-gate.js.map +1 -0
  77. package/dist/analysis/rust-literal-guard-rebind.d.ts +34 -0
  78. package/dist/analysis/rust-literal-guard-rebind.d.ts.map +1 -0
  79. package/dist/analysis/rust-literal-guard-rebind.js +197 -0
  80. package/dist/analysis/rust-literal-guard-rebind.js.map +1 -0
  81. package/dist/analysis/rust-literal-guard.d.ts +33 -0
  82. package/dist/analysis/rust-literal-guard.d.ts.map +1 -0
  83. package/dist/analysis/rust-literal-guard.js +422 -0
  84. package/dist/analysis/rust-literal-guard.js.map +1 -0
  85. package/dist/analysis/scalar-derivation.d.ts +8 -0
  86. package/dist/analysis/scalar-derivation.d.ts.map +1 -1
  87. package/dist/analysis/scalar-derivation.js +172 -0
  88. package/dist/analysis/scalar-derivation.js.map +1 -1
  89. package/dist/analysis/taint-matcher.d.ts +2 -2
  90. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  91. package/dist/analysis/taint-matcher.js +531 -31
  92. package/dist/analysis/taint-matcher.js.map +1 -1
  93. package/dist/analysis/taint-propagation.d.ts +26 -1
  94. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  95. package/dist/analysis/taint-propagation.js +234 -13
  96. package/dist/analysis/taint-propagation.js.map +1 -1
  97. package/dist/analyzer.d.ts +16 -2
  98. package/dist/analyzer.d.ts.map +1 -1
  99. package/dist/analyzer.js +13 -3
  100. package/dist/analyzer.js.map +1 -1
  101. package/dist/browser/circle-ir.js +24432 -19928
  102. package/dist/core/circle-ir-core.cjs +4936 -3575
  103. package/dist/core/circle-ir-core.js +4936 -3575
  104. package/dist/core/extractors/calls.d.ts +12 -1
  105. package/dist/core/extractors/calls.d.ts.map +1 -1
  106. package/dist/core/extractors/calls.js +332 -22
  107. package/dist/core/extractors/calls.js.map +1 -1
  108. package/dist/core/extractors/dfg.js +102 -10
  109. package/dist/core/extractors/dfg.js.map +1 -1
  110. package/dist/core/extractors/types.d.ts +5 -1
  111. package/dist/core/extractors/types.d.ts.map +1 -1
  112. package/dist/core/extractors/types.js +62 -0
  113. package/dist/core/extractors/types.js.map +1 -1
  114. package/dist/graph/analysis-pass.d.ts +6 -1
  115. package/dist/graph/analysis-pass.d.ts.map +1 -1
  116. package/dist/graph/analysis-pass.js +2 -1
  117. package/dist/graph/analysis-pass.js.map +1 -1
  118. package/dist/navigation/navigation-index.d.ts +134 -14
  119. package/dist/navigation/navigation-index.d.ts.map +1 -1
  120. package/dist/navigation/navigation-index.js +1158 -104
  121. package/dist/navigation/navigation-index.js.map +1 -1
  122. package/dist/navigation/types.d.ts +59 -2
  123. package/dist/navigation/types.d.ts.map +1 -1
  124. package/dist/resolution/cross-file.d.ts +9 -0
  125. package/dist/resolution/cross-file.d.ts.map +1 -1
  126. package/dist/resolution/cross-file.js +102 -43
  127. package/dist/resolution/cross-file.js.map +1 -1
  128. package/dist/resolution/type-hierarchy.d.ts +3 -0
  129. package/dist/resolution/type-hierarchy.d.ts.map +1 -1
  130. package/dist/resolution/type-hierarchy.js +45 -1
  131. package/dist/resolution/type-hierarchy.js.map +1 -1
  132. package/dist/types/index.d.ts +33 -0
  133. package/dist/types/index.d.ts.map +1 -1
  134. package/dist/types/index.js.map +1 -1
  135. package/package.json +1 -1
@@ -15,12 +15,13 @@
15
15
  *
16
16
  * Depends on: taint-matcher, constant-propagation, language-sources
17
17
  */
18
- import { CSHARP_SANITIZER_RES, CSharpCredit } from './csharp-sanitizer-credit.js';
18
+ import { CSHARP_SANITIZER_RES, CSharpCredit, csharpSanitizersFor } from './csharp-sanitizer-credit.js';
19
19
  import { JS_TAINTED_PATTERNS } from './language-sources-pass.js';
20
20
  import { LIBRARY_API_SURFACE_TAG } from '../library-api-surface-downgrade.js';
21
21
  import { isNonExecutableSourceLine } from '../non-executable-lines.js';
22
22
  import { sanitizerCoversSink } from '../sanitizer-index.js';
23
23
  import { identifierOutsideSanitizerCalls } from '../taint-propagation.js';
24
+ import { parseJavaArrayAccessExpression } from '../java-array-element-keys.js';
24
25
  import { resolveIdentifierType, resolveCallReturnType, isArgvContainerType, } from '../arg-type-resolver.js';
25
26
  /** Escape regex metacharacters for safe pattern building. */
26
27
  function escapeReSf(s) {
@@ -60,10 +61,8 @@ const JS_LOG_INJECTION_SANITIZERS = [
60
61
  /\bstripCRLF\s*\(/,
61
62
  /\bremoveNewlines\s*\(/,
62
63
  /\bsanitizeLogValue\s*\(/,
63
- // Inline CRLF-stripping regex literal: .replace(/[\r\n]/g, '')
64
- /\.replace\(\s*\/\[\s*\\r\s*\\n\s*\]\/[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
65
- // Inline CRLF sequence: .replace(/\r\n/g, '')
66
- /\.replace\(\s*\/\\r\\n\/[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
64
+ // Inline CRLF-stripping regex literal: .replace(/[\r\n]/g, '') — global required
65
+ /\.replace\(\s*\/\[\s*\\r\s*\\n\s*\]\/[gimsu]*g[gimsu]*\s*,\s*['"`]['"`]\s*\)/,
67
66
  ];
68
67
  // ---------------------------------------------------------------------------
69
68
  // Stage 17 — Python ldap_injection (CWE-90) regex-strip wrapper recognition.
@@ -291,6 +290,60 @@ function argvFirstLiteralIsShell(lineText) {
291
290
  .toLowerCase();
292
291
  return SHELL_PROGRAMS.has(base);
293
292
  }
293
+ /**
294
+ * cognium-dev#626 — does a same-file call of the wrapper method that contains
295
+ * the sink pass a shell argv (`Arrays.asList("/bin/sh", "-c", ...)`,
296
+ * `List.of("bash", ...)`, `new String[]{"sh", ...}`) at the position of its
297
+ * parameter `paramName`?
298
+ *
299
+ * Reuses the #351 literal test (`argvFirstLiteralIsShell`) on the caller's
300
+ * argument text, so the criterion is the same one the inline form already
301
+ * obeys. The wrapper is the method (name and parameter list) of the declaring
302
+ * type that contains the sink line. A call counts only when it has no receiver
303
+ * or a `this` receiver, sits in that same type, and passes the same number of
304
+ * arguments, so a call on another object, another class's method of the same
305
+ * name or another overload is ignored, and so is a call inside a nested type
306
+ * that declares its own method of that name and arity (it calls that one, not
307
+ * the wrapper; `enclosing_type` is unset on the default path, so the nested
308
+ * type is found by its line range). A parameter that is not found answers
309
+ * `false` and today's suppression stands.
310
+ */
311
+ function sameFileCallerPassesShellArgv(paramName, methodName, sinkLine, types, calls) {
312
+ if (!methodName)
313
+ return false;
314
+ for (const t of types) {
315
+ const m = t.methods.find(mm => mm.name === methodName && sinkLine >= mm.start_line && sinkLine <= mm.end_line);
316
+ if (!m)
317
+ continue;
318
+ const pos = m.parameters.findIndex(p => p.name === paramName);
319
+ if (pos < 0)
320
+ return false;
321
+ return calls.some(c => {
322
+ if (c.method_name !== methodName)
323
+ return false;
324
+ if (c.receiver != null && c.receiver !== 'this')
325
+ return false;
326
+ if (c.location.line < t.start_line || c.location.line > t.end_line)
327
+ return false;
328
+ if (c.arguments.length !== m.parameters.length)
329
+ return false;
330
+ // A call inside a nested type that declares its own `methodName` of the
331
+ // same arity calls that method, not this wrapper (the outer type's line
332
+ // span covers the nested one). A nested call with no such method still
333
+ // resolves to the outer wrapper and counts.
334
+ const shadowed = types.some(u => u !== t &&
335
+ u.start_line >= t.start_line && u.end_line <= t.end_line &&
336
+ (u.start_line > t.start_line || u.end_line < t.end_line) &&
337
+ c.location.line >= u.start_line && c.location.line <= u.end_line &&
338
+ u.methods.some(um => um.name === methodName && um.parameters.length === c.arguments.length));
339
+ if (shadowed)
340
+ return false;
341
+ const expr = c.arguments[pos]?.expression;
342
+ return typeof expr === 'string' && argvFirstLiteralIsShell(`new ProcessBuilder(${expr}`);
343
+ });
344
+ }
345
+ return false;
346
+ }
294
347
  // ---------------------------------------------------------------------------
295
348
  // Stage 12 — Java throw-statement FP suppression.
296
349
  // (cognium-dev #157 — Sprint 45)
@@ -545,6 +598,27 @@ function constStringLiteralValue(code, name) {
545
598
  return null;
546
599
  return decodeJsHexEscapes(decls[0][1] ?? decls[0][2] ?? decls[0][3] ?? '');
547
600
  }
601
+ /**
602
+ * The body of the template literal that is the first argument of the
603
+ * `new RegExp(` on `line` (1-indexed), or null. The literal may start on a
604
+ * following line, after whitespace or comments, as a formatter wraps a long
605
+ * constructor call; the `new RegExp(` itself must be on the sink line.
606
+ * (cognium-dev#717)
607
+ */
608
+ function regExpTemplateArgument(code, lines, line) {
609
+ const text = lines[line - 1] ?? '';
610
+ let offset = 0;
611
+ for (let i = 0; i < line - 1; i++)
612
+ offset += lines[i].length + 1;
613
+ const argument = /^(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*`((?:\\.|[^`\\])*)`(?=\s*[,)])/;
614
+ for (const start of text.matchAll(/new\s+RegExp\s*\(/g)) {
615
+ // The literal must be the whole first argument: `` `…` + text `` is not constant.
616
+ const m = argument.exec(code.slice(offset + start.index + start[0].length));
617
+ if (m)
618
+ return m[1];
619
+ }
620
+ return null;
621
+ }
548
622
  /** A group that holds an unbounded quantifier and is itself quantified: `(a+)+`, `(x*)*`, `(\w{2,})+`, `(a+){2,}`. */
549
623
  const NESTED_QUANTIFIER_RE = /\([^()]*(?:[+*]|\{\d+,\d*\})[^()]*\)\s*[+*{]/;
550
624
  /** A template literal that starts with `${IDENT}/` (cognium-dev#680). */
@@ -708,8 +782,10 @@ function jsSsrfHostGuardedLines(code) {
708
782
  return covered;
709
783
  }
710
784
  /**
711
- * Is this `plugin_param` source a map read of a key that was never written, on
712
- * a map whose entire contents we can see? (cognium-dev#328 prerequisite.)
785
+ * Is this `plugin_param` source a map read that provably yields `null` or a
786
+ * string literal — a key that was never written, or any key of a literal-only
787
+ * map (#584) — on a map whose entire contents we can see? (cognium-dev#328
788
+ * prerequisite.)
713
789
  *
714
790
  * Sound only when the map's provenance is fully known, so all three hold:
715
791
  *
@@ -720,10 +796,11 @@ function jsSsrfHostGuardedLines(code) {
720
796
  * (b) the receiver never escapes — it is not passed as an argument to any
721
797
  * call. `helper(m)` could `put` into it out of sight, so once it escapes
722
798
  * the contents are no longer knowable from this file.
723
- * (c) the read key is a string literal with **no** `put` of that same literal
724
- * anywhere in the file. A matching `put` keeps the source regardless of
725
- * whether the stored value looks tainted — this gate only removes reads
726
- * that provably yield `null`, never reads whose value it judged clean.
799
+ * (c) the read key is a string literal. A matching `put` of that literal no
800
+ * longer keeps the source automatically: when every put value is itself a
801
+ * string literal (`isLiteralOnlyLocalMap`), the read is still dropped as
802
+ * non-tainted (#584). This gate only removes reads that provably yield
803
+ * `null` or a literal-only local map, never reads whose value is uncertain.
727
804
  *
728
805
  * Deliberately narrow: `plugin_param` only, literal keys only, and any
729
806
  * uncertainty leaves the source untouched. Non-literal keys (`m.get(k)`) are
@@ -754,12 +831,36 @@ function isUnwrittenLocalMapKeyRead(code, source) {
754
831
  if (escapeRe.test(stripped))
755
832
  return false;
756
833
  }
757
- // (c2) was this literal key ever written?
834
+ // (c2) was this literal key ever written? If so, drop only a literal-only map.
758
835
  const putRe = new RegExp(`\\b${recv}\\s*\\.\\s*put\\s*\\(\\s*"${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`);
759
836
  if (lines.some(l => putRe.test(l)))
760
- return false;
837
+ return isLiteralOnlyLocalMap(lines, recv);
761
838
  return true;
762
839
  }
840
+ /**
841
+ * cognium-dev#584 (slice) — the map's whole contents are string literals.
842
+ *
843
+ * Every mutation of `recv` in the file is a `put(<key>, "<string literal>")`:
844
+ * no `put` of a non-literal value under any key (so no key-shuffle can place a
845
+ * tainted value under the key being read), and no bulk or computing mutator
846
+ * (`putAll`, `putIfAbsent`, `compute*`, `merge`, `replace*`) that could store
847
+ * a value out of sight. Combined with (a) local construction and (b) no escape
848
+ * in the caller, a read of such a map yields a literal and is not a source.
849
+ */
850
+ function isLiteralOnlyLocalMap(lines, recv) {
851
+ const anyPut = new RegExp(`\\b${recv}\\s*\\.\\s*put\\s*\\(`, 'g');
852
+ const literalPut = new RegExp(`\\b${recv}\\s*\\.\\s*put\\s*\\(\\s*(?:"(?:[^"\\\\]|\\\\.)*"|[A-Za-z_][\\w.]*)\\s*,\\s*"(?:[^"\\\\]|\\\\.)*"\\s*\\)`, 'g');
853
+ const otherMutator = new RegExp(`\\b${recv}\\s*\\.\\s*(?:putAll|putIfAbsent|compute|computeIfAbsent|computeIfPresent|merge|replace|replaceAll|entrySet|values)\\s*\\(`);
854
+ let puts = 0;
855
+ let literalPuts = 0;
856
+ for (const l of lines) {
857
+ if (otherMutator.test(l))
858
+ return false;
859
+ puts += (l.match(anyPut) ?? []).length;
860
+ literalPuts += (l.match(literalPut) ?? []).length;
861
+ }
862
+ return puts > 0 && puts === literalPuts;
863
+ }
763
864
  /**
764
865
  * Is this C# `Content(...)` call the controller helper writing an HTML body
765
866
  * (cognium-dev#580)?
@@ -787,6 +888,126 @@ function csharpContentServesHtml(call) {
787
888
  return named[2] === 'Html' || named[2] === 'Xml';
788
889
  return true;
789
890
  }
891
+ /**
892
+ * True when an unqualified / `this.` / `base.` call to `Content` sits in a type
893
+ * that can inherit the MVC helper (cognium-dev#580). The C# extractor does not
894
+ * record base lists, so they are read from the declaration text.
895
+ * - a base named `Controller`, `ControllerBase` or `PageModel`: the helper;
896
+ * - a `partial` class: the base may be in another part, so treat as unknown and keep;
897
+ * - no base list, or only `I`-prefixed interface names: nothing is inherited,
898
+ * so `Content(...)` is the type's own method;
899
+ * - any other base: unknown, kept, unless the type declares its own `Content`
900
+ * name, which hides an inherited one.
901
+ * Non-class IR kinds (and a missing owner) keep the sink. A controller that
902
+ * declares its own `Content` overload keeps the helper call.
903
+ */
904
+ function csharpContentCallInheritsHelper(call, types, sourceLines) {
905
+ const line = call.location.line;
906
+ let owner;
907
+ for (const t of types) {
908
+ if (line < t.start_line || line > t.end_line)
909
+ continue;
910
+ if (!owner || t.end_line - t.start_line < owner.end_line - owner.start_line)
911
+ owner = t;
912
+ }
913
+ if (!owner || owner.kind !== 'class')
914
+ return true;
915
+ const header = sourceLines.slice(owner.start_line - 1, owner.start_line + 4).join(' ');
916
+ const decl = new RegExp(`\\bclass\\s+${owner.name}\\b(?:\\s*<[^>]*>)?\\s*(?::\\s*([^{]*?))?\\s*(?:where\\b[^{]*)?\\{`).exec(header);
917
+ if (!decl)
918
+ return true;
919
+ const bases = (decl[1] ?? '').split(',').map(b => b.trim().replace(/<.*$/, '').split('.').pop() ?? '').filter(Boolean);
920
+ if (bases.some(b => b === 'Controller' || b === 'ControllerBase' || b === 'PageModel'))
921
+ return true;
922
+ // Partial types may declare their base in another part of the type — unknown, keep.
923
+ const classAt = header.search(/\bclass\b/);
924
+ if (classAt >= 0 && /\bpartial\b/.test(header.slice(0, classAt)))
925
+ return true;
926
+ if (bases.every(b => /^I[A-Z]/.test(b)))
927
+ return false;
928
+ return !owner.methods.some(m => m.name === 'Content');
929
+ }
930
+ /**
931
+ * Does this Rust `.body(x)` call write a response whose builder chain declared
932
+ * a literal non-HTML content type (cognium-dev#478)? actix-web does not add a
933
+ * `Content-Type` of its own for `.body(...)`, so only an explicit
934
+ * `.content_type(<literal>)` earlier in the same chain proves the body is data:
935
+ *
936
+ * - a string literal `type/subtype` that is not HTML, XHTML or a
937
+ * script-capable XML type (`text/xml`, `application/xml`, any `+xml`);
938
+ * - `ContentType::plaintext()` / `json()` / `octet_stream()` /
939
+ * `form_url_encoded()` and the matching `mime::` constants.
940
+ *
941
+ * No content type, a computed one, `ContentType::html()` and every XML type
942
+ * keep the sink. When the chain sets the type more than once the last wins.
943
+ * Only a call made at the top level of the chain counts, so a nested
944
+ * `content_type(...)` inside an argument is never read as the response type.
945
+ */
946
+ function rustBodyDeclaresNonHtmlType(call) {
947
+ const recv = call.receiver;
948
+ if (!recv)
949
+ return false;
950
+ let arg;
951
+ let depth = 0;
952
+ let quote = false;
953
+ for (let i = 0; i < recv.length; i++) {
954
+ const ch = recv[i];
955
+ if (quote) {
956
+ if (ch === '\\')
957
+ i++;
958
+ else if (ch === '"')
959
+ quote = false;
960
+ continue;
961
+ }
962
+ if (ch === '"') {
963
+ quote = true;
964
+ continue;
965
+ }
966
+ if (ch === '(' || ch === '[' || ch === '{') {
967
+ depth++;
968
+ continue;
969
+ }
970
+ if (ch === ')' || ch === ']' || ch === '}') {
971
+ depth--;
972
+ continue;
973
+ }
974
+ if (depth === 0 && recv.startsWith('.content_type(', i)) {
975
+ const start = i + '.content_type('.length;
976
+ let d = 1;
977
+ let q = false;
978
+ let j = start;
979
+ for (; j < recv.length && d > 0; j++) {
980
+ const c = recv[j];
981
+ if (q) {
982
+ if (c === '\\')
983
+ j++;
984
+ else if (c === '"')
985
+ q = false;
986
+ }
987
+ else if (c === '"')
988
+ q = true;
989
+ else if (c === '(')
990
+ d++;
991
+ else if (c === ')')
992
+ d--;
993
+ }
994
+ arg = d === 0 ? recv.slice(start, j - 1).trim() : undefined;
995
+ i = j - 1;
996
+ }
997
+ }
998
+ if (arg === undefined)
999
+ return false;
1000
+ const literal = /^"([^"]*)"$/.exec(arg);
1001
+ if (literal) {
1002
+ const type = literal[1];
1003
+ if (!/^\s*[\w.-]+\/[\w.+-]+/.test(type))
1004
+ return false;
1005
+ return !/^\s*(?:text\/html|application\/xhtml\+xml|text\/xml|application\/xml|[\w.-]+\/[\w.-]+\+xml)\b/i.test(type);
1006
+ }
1007
+ if (/^(?:(?:actix_web\s*::\s*)?(?:http\s*::\s*header\s*::\s*)?)ContentType\s*::\s*(?:plaintext|json|octet_stream|form_url_encoded)\s*\(\s*\)$/.test(arg))
1008
+ return true;
1009
+ return /^(?:::)?mime\s*::\s*(?:TEXT_PLAIN|TEXT_PLAIN_UTF_8|APPLICATION_JSON|APPLICATION_OCTET_STREAM)$/.test(arg);
1010
+ }
790
1011
  /**
791
1012
  * Compute, per sink type, the set of C# variables whose value has passed through
792
1013
  * a sanitizer for that type (cognium-dev C#/.NET Phase-1). Seeds from
@@ -800,6 +1021,7 @@ function csharpSanitizedVarsByType(code) {
800
1021
  const lines = code.split('\n');
801
1022
  const byType = new Map();
802
1023
  const assignRe = /^\s*(?:var\s+|[A-Za-z_][\w.<>\[\]]*\s+)?([A-Za-z_]\w*)\s*=\s*(.+?);?\s*$/;
1024
+ const sanitizers = csharpSanitizersFor(code);
803
1025
  for (const { type } of CSHARP_SANITIZER_RES) {
804
1026
  if (!byType.has(type))
805
1027
  byType.set(type, new Set());
@@ -810,7 +1032,7 @@ function csharpSanitizedVarsByType(code) {
810
1032
  if (!m)
811
1033
  continue;
812
1034
  const [, lhs, rhs] = m;
813
- for (const { re, type } of CSHARP_SANITIZER_RES) {
1035
+ for (const { re, type } of sanitizers) {
814
1036
  if (re.test(rhs))
815
1037
  byType.get(type).add(lhs);
816
1038
  }
@@ -1170,13 +1392,26 @@ export class SinkFilterPass {
1170
1392
  // That is SecuriBench Micro `Collections6:47`, an `/* OK */` line. The
1171
1393
  // false positive is currently masked by the const-prop veto in #328, so
1172
1394
  // this gate is behaviour-neutral today; its purpose is to make lifting that
1173
- // veto safe, which is the sequencing chosen on #328.
1395
+ // veto safe, which is the sequencing chosen on #328. #584 widened it: a read
1396
+ // of a key that WAS put is also dropped when every value put on that local,
1397
+ // non-escaping map is a string literal (`isLiteralOnlyLocalMap`).
1174
1398
  const sources = ctx.code
1175
1399
  ? executableSources.filter(s => !isUnwrittenLocalMapKeyRead(ctx.code, s))
1176
1400
  : executableSources;
1177
1401
  // Build merged sinks, deduplicating JS DOM sinks that may overlap with config sinks.
1178
1402
  const sinks = [...taintMatcher.sinks];
1179
1403
  for (const s of langSources.additionalSinks) {
1404
+ const springReturn = s.type === 'xss' &&
1405
+ typeof s.location === 'string' &&
1406
+ s.location.startsWith('Spring handler returns HTML');
1407
+ if (springReturn) {
1408
+ const i = sinks.findIndex(x => x.line === s.line && x.type === 'xss');
1409
+ if (i >= 0)
1410
+ sinks[i] = s;
1411
+ else if (!sinks.some(x => x.line === s.line && x.cwe === s.cwe && x.type === s.type))
1412
+ sinks.push(s);
1413
+ continue;
1414
+ }
1180
1415
  if (!sinks.some(x => x.line === s.line && x.cwe === s.cwe && x.type === s.type)) {
1181
1416
  sinks.push(s);
1182
1417
  }
@@ -1190,7 +1425,7 @@ export class SinkFilterPass {
1190
1425
  // Stage 2 — clean array elements
1191
1426
  filtered = filterCleanArraySinks(filtered, calls, constProp.taintedArrayElements, constProp.symbols);
1192
1427
  // Stage 3 — clean variables
1193
- filtered = filterCleanVariableSinks(filtered, calls, constProp.tainted, constProp.symbols, dfg, constProp.sanitizedVars, constProp.synchronizedLines, language);
1428
+ filtered = filterCleanVariableSinks(filtered, calls, constProp.tainted, constProp.symbols, dfg, constProp.sanitizedVars, constProp.synchronizedLines, language, constProp.sanitizedVarUncovered);
1194
1429
  // Stage 4 — sanitized sinks. A sanitizer on one operand does not drop
1195
1430
  // the sink while another source operand is still raw (#641).
1196
1431
  filtered = filterSanitizedSinks(filtered, sanitizers, calls, sources);
@@ -1713,8 +1948,12 @@ export class SinkFilterPass {
1713
1948
  // Bare identifier — parameter/field lookup.
1714
1949
  if (varName !== '' && argExpr === varName && !/[(.]/.test(argExpr)) {
1715
1950
  const t = resolveIdentifierType(varName, ctorCall.in_method, irTypes);
1716
- if (isArgvContainerType(t))
1951
+ // cognium-dev#626: a `List<String>` PARAMETER is argv-form only while no
1952
+ // same-file caller hands it a shell argv (`/bin/sh -c <script>`): then
1953
+ // the tainted element is the script, so the suppression proves nothing.
1954
+ if (isArgvContainerType(t) && !sameFileCallerPassesShellArgv(varName, ctorCall.in_method, ctorCall.location.line, irTypes, irCalls)) {
1717
1955
  return false;
1956
+ }
1718
1957
  }
1719
1958
  // Method call — return-type lookup. `variable` field carries the
1720
1959
  // callee name for call-expression args.
@@ -2192,10 +2431,10 @@ export class SinkFilterPass {
2192
2431
  filtered = filtered.filter(sink => {
2193
2432
  if (sink.type !== 'redos')
2194
2433
  return true;
2195
- const m = (sourceLines[sink.line - 1] ?? '').match(/new\s+RegExp\s*\(\s*`((?:\\.|[^`\\])*)`/);
2196
- if (!m)
2434
+ const tpl = regExpTemplateArgument(ctx.code, sourceLines, sink.line);
2435
+ if (tpl === null)
2197
2436
  return true;
2198
- let pattern = m[1];
2437
+ let pattern = tpl;
2199
2438
  let interpolations = 0;
2200
2439
  let allConst = true;
2201
2440
  // Replace each `${ NAME }` as written (whitespace inside the braces
@@ -2244,6 +2483,15 @@ export class SinkFilterPass {
2244
2483
  const csFields = new Set(ctx.graph.ir.types.flatMap(t => (t.fields ?? []).map(f => f.name)));
2245
2484
  const credit = new CSharpCredit(ctx.code, ctx.graph.ir.types.flatMap(t => t.methods));
2246
2485
  const csSeeds = mergedSources.flatMap(s => (s.variable ? [{ variable: s.variable, line: s.line }] : []));
2486
+ const fieldWritesByType = new Map();
2487
+ const fieldWritesFor = (type) => {
2488
+ let w = fieldWritesByType.get(type);
2489
+ if (!w) {
2490
+ w = credit.fieldWrites(type, csFields);
2491
+ fieldWritesByType.set(type, w);
2492
+ }
2493
+ return w;
2494
+ };
2247
2495
  // File-level XXE hardening (cognium-dev#272). `XmlResolver = null` and
2248
2496
  // `DtdProcessing = Prohibit|Ignore` are the documented XXE mitigations —
2249
2497
  // an external entity cannot resolve once either is set. Deterministic
@@ -2272,10 +2520,17 @@ export class SinkFilterPass {
2272
2520
  const scoped = credit.sanitizedVarsAt(sink.type, sink.line);
2273
2521
  const fileWide = sanitizedByType.get(sink.type);
2274
2522
  const sanitizedVars = new Set(scoped);
2275
- if (fileWide)
2276
- for (const v of fileWide)
2277
- if (csFields.has(v))
2523
+ // A field is credited only when every write to it is credited; each
2524
+ // write is judged in its own method, one-line bodies included (#808).
2525
+ const fieldWrites = fieldWritesFor(sink.type);
2526
+ if (fileWide) {
2527
+ for (const v of fileWide) {
2528
+ if (csFields.has(v) && !fieldWrites.unsanitized.has(v))
2278
2529
  sanitizedVars.add(v);
2530
+ }
2531
+ }
2532
+ for (const v of fieldWrites.sanitized)
2533
+ sanitizedVars.add(v);
2279
2534
  // (c) a check that dominates the sink proves the value inert: an
2280
2535
  // allowlist or numeric check, or for ssrf a host allowlist (#518).
2281
2536
  for (const v of credit.guardedVarsAt(sink.type, sink.line))
@@ -2298,11 +2553,24 @@ export class SinkFilterPass {
2298
2553
  // at least one `Content` call on its line is the controller helper with an
2299
2554
  // HTML or unknown content type.
2300
2555
  if (language === 'csharp') {
2556
+ const csContentLines = ctx.code.split('\n');
2301
2557
  filtered = filtered.filter(sink => {
2302
2558
  if (sink.type !== 'xss' || sink.method !== 'Content')
2303
2559
  return true;
2304
2560
  const contentCalls = callsByLine.get(sink.line)?.filter(c => c.method_name === 'Content') ?? [];
2305
- return contentCalls.length === 0 || contentCalls.some(csharpContentServesHtml);
2561
+ // The call must also sit in a type that can inherit the MVC helper (#580).
2562
+ return contentCalls.length === 0 || contentCalls.some(c => csharpContentCallInheritsHelper(c, ctx.graph.ir.types, csContentLines) && csharpContentServesHtml(c));
2563
+ });
2564
+ }
2565
+ // Stage 15k'' — Rust `.body(x)` with a literal non-HTML content type is
2566
+ // data, not markup (cognium-dev#478). The xss sink stays unless every
2567
+ // `body` call on its line sits on a chain that declared such a type.
2568
+ if (language === 'rust') {
2569
+ filtered = filtered.filter(sink => {
2570
+ if (sink.type !== 'xss' || sink.method !== 'body')
2571
+ return true;
2572
+ const bodyCalls = callsByLine.get(sink.line)?.filter(c => c.method_name === 'body') ?? [];
2573
+ return bodyCalls.length === 0 || !bodyCalls.every(rustBodyDeclaresNonHtmlType);
2306
2574
  });
2307
2575
  }
2308
2576
  // Stage 15m — bash ssrf: fixed-host URL word (cognium-dev#495, #669).
@@ -2985,14 +3253,13 @@ function filterCleanArraySinks(sinks, calls, taintedArrayElements, symbols) {
2985
3253
  const callsAtSink = callsByLine.get(sink.line) ?? [];
2986
3254
  for (const call of callsAtSink) {
2987
3255
  for (const arg of call.arguments) {
2988
- const arrayAccessMatch = arg.expression?.match(/^(\w+)\[(\d+|[^[\]]+)\]$/);
2989
- if (arrayAccessMatch) {
2990
- const arrayName = arrayAccessMatch[1];
2991
- let indexStr = arrayAccessMatch[2];
2992
- indexStr = evaluateSimpleExpression(indexStr, symbols);
2993
- const taintedIndices = taintedArrayElements.get(arrayName);
3256
+ const arrayAccess = parseJavaArrayAccessExpression(arg.expression);
3257
+ if (arrayAccess) {
3258
+ let indexKey = arrayAccess.indexKey;
3259
+ indexKey = evaluateSimpleExpression(indexKey, symbols);
3260
+ const taintedIndices = taintedArrayElements.get(arrayAccess.arrayName);
2994
3261
  if (taintedIndices !== undefined) {
2995
- const isTainted = taintedIndices.has(indexStr) || taintedIndices.has('*');
3262
+ const isTainted = taintedIndices.has(indexKey) || taintedIndices.has('*');
2996
3263
  if (!isTainted)
2997
3264
  return false;
2998
3265
  }
@@ -3002,7 +3269,7 @@ function filterCleanArraySinks(sinks, calls, taintedArrayElements, symbols) {
3002
3269
  return true;
3003
3270
  });
3004
3271
  }
3005
- export function filterCleanVariableSinks(sinks, calls, taintedVars, symbols, dfg, sanitizedVars, synchronizedLines, language) {
3272
+ export function filterCleanVariableSinks(sinks, calls, taintedVars, symbols, dfg, sanitizedVars, synchronizedLines, language, sanitizedVarUncovered) {
3006
3273
  const fieldNames = new Set();
3007
3274
  if (dfg) {
3008
3275
  for (const def of dfg.defs) {
@@ -3068,8 +3335,33 @@ export function filterCleanVariableSinks(sinks, calls, taintedVars, symbols, dfg
3068
3335
  argClean = false;
3069
3336
  break;
3070
3337
  }
3071
- if (sanitizedVars?.has(scopedName) || sanitizedVars?.has(varName))
3072
- continue;
3338
+ // #589 review: when any `method:var` sanitizer exists, decide from
3339
+ // the scoped name only — do not treat a sibling method's encoder
3340
+ // (or its unscoped alias) as sanitizing this method's local.
3341
+ const suffix = `:${varName}`;
3342
+ let hasScopedSanitizer = false;
3343
+ if (sanitizedVars) {
3344
+ for (const s of sanitizedVars) {
3345
+ if (s !== varName && s.endsWith(suffix)) {
3346
+ hasScopedSanitizer = true;
3347
+ break;
3348
+ }
3349
+ }
3350
+ }
3351
+ const isSanitizedHere = sanitizedVars?.has(scopedName)
3352
+ || (!hasScopedSanitizer && !!sanitizedVars?.has(varName));
3353
+ if (isSanitizedHere) {
3354
+ // #589: a context-specific encoder (`escapeHtml`, `encodeForHTML`, …)
3355
+ // does not clean `trust_boundary`; that sink on the same
3356
+ // variable stays.
3357
+ const uncovered = hasScopedSanitizer
3358
+ ? sanitizedVarUncovered?.get(scopedName)
3359
+ : (sanitizedVarUncovered?.get(scopedName) ?? sanitizedVarUncovered?.get(varName));
3360
+ if (!uncovered || !uncovered.has(sink.type))
3361
+ continue;
3362
+ argClean = false;
3363
+ break;
3364
+ }
3073
3365
  if (taintedVars.has(scopedName) || taintedVars.has(varName)) {
3074
3366
  argClean = false;
3075
3367
  break;