circle-ir 4.9.12 → 4.9.14

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 (28) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +25 -5
  3. package/dist/analysis/config-loader.js.map +1 -1
  4. package/dist/analysis/passes/blocking-main-thread-pass.d.ts +19 -0
  5. package/dist/analysis/passes/blocking-main-thread-pass.d.ts.map +1 -1
  6. package/dist/analysis/passes/blocking-main-thread-pass.js +75 -2
  7. package/dist/analysis/passes/blocking-main-thread-pass.js.map +1 -1
  8. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  9. package/dist/analysis/passes/language-sources-pass.js +219 -18
  10. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  11. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  12. package/dist/analysis/passes/sink-filter-pass.js +225 -4
  13. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  14. package/dist/analysis/passes/taint-propagation-pass.d.ts +48 -0
  15. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  16. package/dist/analysis/passes/taint-propagation-pass.js +166 -1
  17. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  18. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  19. package/dist/analysis/taint-propagation.js +17 -2
  20. package/dist/analysis/taint-propagation.js.map +1 -1
  21. package/dist/browser/circle-ir.js +405 -34
  22. package/dist/core/circle-ir-core.cjs +60 -6
  23. package/dist/core/circle-ir-core.js +60 -6
  24. package/dist/core/extractors/calls.js +19 -0
  25. package/dist/core/extractors/calls.js.map +1 -1
  26. package/dist/core/extractors/dfg.js +78 -1
  27. package/dist/core/extractors/dfg.js.map +1 -1
  28. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"sink-filter-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/sink-filter-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AACzG,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AA61B9E,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,sBAAsB;IACtB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AA6BD,qBAAa,cAAe,YAAW,YAAY,CAAC,gBAAgB,CAAC;IACnE,QAAQ,CAAC,IAAI,iBAAiB;IAC9B,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,gBAAgB;CAmqCxC;AAkRD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,KAAK,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AA4I1G,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,EACxB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EACxB,OAAO,EAAE,OAAO,EAChB,GAAG,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC,EACrB,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC3B,iBAAiB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,CAAC,EAAE,MAAM,GAChB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA6E5B;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,YAAY,CAAC,EAC3C,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,GACvB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA2C5B"}
1
+ {"version":3,"file":"sink-filter-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/sink-filter-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,cAAc,EAAwB,MAAM,sBAAsB,CAAC;AACzG,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAo+B9E,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,sBAAsB;IACtB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AA6BD,qBAAa,cAAe,YAAW,YAAY,CAAC,gBAAgB,CAAC;IACnE,QAAQ,CAAC,IAAI,iBAAiB;IAC9B,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,gBAAgB;CAkwCxC;AAkRD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErD,KAAK,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AA4I1G,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,EACxB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,EACxB,OAAO,EAAE,OAAO,EAChB,GAAG,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC,EACrB,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC3B,iBAAiB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,CAAC,EAAE,MAAM,GAChB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA6E5B;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,EACjC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,YAAY,CAAC,EAC3C,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,GACvB,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,CA2C5B"}
@@ -218,6 +218,47 @@ const OS_EXEC_RECEIVER_RE = /\b(?:Runtime|ProcessBuilder|DefaultExecutor|Executo
218
218
  // new ProcessBuilder(new String[]{ ... })
219
219
  // new ProcessBuilder("...", ...) (varargs ≥2 args; first is string literal)
220
220
  const PROCESS_BUILDER_ARGV_FORM_RE = /\bnew\s+ProcessBuilder\s*\(\s*(?:Arrays\.asList\b|List\.of\b|Collections\.singletonList\b|new\s+ArrayList\b|new\s+String\s*\[\s*\]\s*\{|"[^"]*"\s*,)/;
221
+ // Same argv proof, for the `pb.command(...)` setter rather than the ctor.
222
+ // `command(List<String>)` / `command(String...)` feed the same argv array, so
223
+ // a tainted element is one argv slot, not a shell string. Added with #351:
224
+ // that issue widened the `command` sink's `arg_positions` beyond `[0]`, which
225
+ // without this would report `pb.command("ls", userInput)` as CWE-78 when it is
226
+ // at most argument injection (CWE-88) — exactly the FP class #179 removed for
227
+ // the constructor.
228
+ const PROCESS_BUILDER_COMMAND_ARGV_FORM_RE = /\.\s*command\s*\(\s*(?:Arrays\.asList\b|List\.of\b|Collections\.singletonList\b|new\s+ArrayList\b|new\s+String\s*\[\s*\]\s*\{|"[^"]*"\s*,)/;
229
+ // cognium-dev#351 — the argv proof above holds only while argv[0] is the
230
+ // PROGRAM. When argv[0] is a shell, argv[1..] is a script and a tainted
231
+ // element is a shell command string, so bypassing the shell is exactly what
232
+ // does NOT happen:
233
+ //
234
+ // new ProcessBuilder("bash", "-c", "cowsay '" + input + "'") // CWE-78
235
+ // new ProcessBuilder(List.of("sh", "-c", tainted)) // CWE-78
236
+ //
237
+ // `Runtime.exec(String[])` with the identical array already reported, so the
238
+ // argv-form suppression was the only thing standing between this shape and a
239
+ // finding. Kept deliberately narrow: only a literal, recognisably-shell
240
+ // argv[0] re-opens the sink; anything else keeps #179's suppression.
241
+ const SHELL_PROGRAMS = new Set([
242
+ 'sh', 'bash', 'zsh', 'ksh', 'dash', 'ash', 'csh', 'tcsh', 'fish',
243
+ 'cmd', 'command', 'powershell', 'pwsh',
244
+ ]);
245
+ const ARGV_FIRST_LITERAL_RE = /(?:\bnew\s+ProcessBuilder\s*\(|\.\s*command\s*\()\s*(?:Arrays\.asList\s*\(|List\.of\s*\(|Collections\.singletonList\s*\(|new\s+ArrayList\s*(?:<[^>]*>)?\s*\(\s*(?:Arrays\.asList\s*\()?|new\s+String\s*\[\s*\]\s*\{)?\s*"([^"]*)"/;
246
+ /**
247
+ * True when the first argv literal on the line names a shell interpreter.
248
+ * Compares the basename so `/bin/bash` and `C:\\Windows\\System32\\cmd.exe`
249
+ * are recognised as well as a bare `bash`.
250
+ */
251
+ function argvFirstLiteralIsShell(lineText) {
252
+ const m = ARGV_FIRST_LITERAL_RE.exec(lineText);
253
+ if (!m)
254
+ return false;
255
+ const base = m[1]
256
+ .split(/[\\/]/)
257
+ .pop()
258
+ .replace(/\.exe$/i, '')
259
+ .toLowerCase();
260
+ return SHELL_PROGRAMS.has(base);
261
+ }
221
262
  // ---------------------------------------------------------------------------
222
263
  // Stage 12 — Java throw-statement FP suppression.
223
264
  // (cognium-dev #157 — Sprint 45)
@@ -533,10 +574,94 @@ function jsSsrfHostGuardedLines(code) {
533
574
  // C# sanitizers (Phase-1) and the sink types each neutralises. A tainted value
534
575
  // that flows through one of these before reaching a sink of the matching type
535
576
  // is safe. Method names are C#-distinctive (see config-loader C# sanitizers).
577
+ /**
578
+ * `Regex.Replace(x, "[^<allowlist>]", "")` — an allowlist character strip
579
+ * (cognium-dev#272, the LDAP-strip cluster).
580
+ *
581
+ * A **negated** class is the whole point: `[^a-zA-Z0-9]` deletes everything
582
+ * *not* in the set, so the surviving characters are exactly the set. That makes
583
+ * the result provably inert for the metacharacter-driven injection families
584
+ * below. A blacklist replace (`"[<>]"`) proves nothing — it enumerates what to
585
+ * remove and silently misses the rest — and must not match.
586
+ *
587
+ * Two conditions, both load-bearing:
588
+ *
589
+ * (a) the pattern is a string literal beginning `[^`. A computed pattern could
590
+ * be anything, including attacker-influenced.
591
+ * (b) the surviving set contains **no metacharacters**. `[^a-zA-Z0-9']` would
592
+ * keep the single quote alive, which breaks SQL and LDAP escaping outright
593
+ * — so the class body is restricted to alphanumerics, `\w`, `\d`, `\s`,
594
+ * underscore, hyphen and spaces. Anything else (quote, paren, backslash,
595
+ * dot, asterisk, semicolon) fails the match and the sink still fires.
596
+ *
597
+ * The replacement must be empty or a single safe character, so
598
+ * `Regex.Replace(x, "[^a-z]", "';DROP")` cannot qualify.
599
+ */
600
+ const CSHARP_ALLOWLIST_STRIP_RE = /\bRegex\s*\.\s*Replace\s*\([^,]*,\s*@?"\[\^(?:[A-Za-z0-9_\- ]|\\w|\\d|\\s)+\]"\s*,\s*@?"[A-Za-z0-9_]?"\s*\)/;
601
+ /**
602
+ * Is this `plugin_param` source a map read of a key that was never written, on
603
+ * a map whose entire contents we can see? (cognium-dev#328 prerequisite.)
604
+ *
605
+ * Sound only when the map's provenance is fully known, so all three hold:
606
+ *
607
+ * (a) the receiver is constructed locally — `new HashMap<…>()` and friends. A
608
+ * map arriving as a parameter, field or return value may already hold
609
+ * anything, which is the case the `plugin_param` registration exists for,
610
+ * so it keeps its source.
611
+ * (b) the receiver never escapes — it is not passed as an argument to any
612
+ * call. `helper(m)` could `put` into it out of sight, so once it escapes
613
+ * the contents are no longer knowable from this file.
614
+ * (c) the read key is a string literal with **no** `put` of that same literal
615
+ * anywhere in the file. A matching `put` keeps the source regardless of
616
+ * whether the stored value looks tainted — this gate only removes reads
617
+ * that provably yield `null`, never reads whose value it judged clean.
618
+ *
619
+ * Deliberately narrow: `plugin_param` only, literal keys only, and any
620
+ * uncertainty leaves the source untouched. Non-literal keys (`m.get(k)`) are
621
+ * out of scope because the key is unknown, not because it is safe.
622
+ */
623
+ function isUnwrittenLocalMapKeyRead(code, source) {
624
+ if (source.type !== 'plugin_param')
625
+ return false;
626
+ const lines = code.split('\n');
627
+ const lineText = lines[source.line - 1] ?? '';
628
+ // (c1) the read itself: `<recv>.get("<key>")`
629
+ const read = /\b([A-Za-z_]\w*)\s*\.\s*get\s*\(\s*"([^"]*)"\s*\)/.exec(lineText);
630
+ if (!read)
631
+ return false;
632
+ const [, recv, key] = read;
633
+ // (a) locally constructed map
634
+ const ctorRe = new RegExp(`\\b${recv}\\s*=\\s*new\\s+(?:Hash|Tree|LinkedHash|ConcurrentHash|Concurrent|Identity|Weak)?Map\\s*(?:<[^>]*>)?\\s*\\(`);
635
+ if (!lines.some(l => ctorRe.test(l)))
636
+ return false;
637
+ // (b) no escape: the receiver must not appear inside any call's arguments.
638
+ // `m.put(...)` / `m.get(...)` are receiver positions, not arguments.
639
+ const escapeRe = new RegExp(`\\w\\s*\\([^)]*(?<![\\w.])${recv}(?![\\w.])[^)]*\\)`);
640
+ for (const l of lines) {
641
+ if (!escapeRe.test(l))
642
+ continue;
643
+ // Allow the map's own keyed calls on this line.
644
+ const stripped = l.replace(new RegExp(`\\b${recv}\\s*\\.\\s*\\w+\\s*\\(`, 'g'), 'X(');
645
+ if (escapeRe.test(stripped))
646
+ return false;
647
+ }
648
+ // (c2) was this literal key ever written?
649
+ const putRe = new RegExp(`\\b${recv}\\s*\\.\\s*put\\s*\\(\\s*"${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`);
650
+ if (lines.some(l => putRe.test(l)))
651
+ return false;
652
+ return true;
653
+ }
536
654
  const CSHARP_SANITIZER_RES = [
537
655
  { re: /\b(?:HtmlEncode|JavaScriptStringEncode)\s*\(/, type: 'xss' },
538
656
  { re: /\bHtmlEncoder\s*\.\s*Encode\s*\(/, type: 'xss' },
539
657
  { re: /\bPath\s*\.\s*GetFileName\s*\(/, type: 'path_traversal' },
658
+ // An allowlist strip leaves only inert characters, so it covers every family
659
+ // whose exploitation needs a metacharacter. Each has a fixture; see
660
+ // `issue-272-csharp-allowlist-strip.test.ts`.
661
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: 'ldap_injection' },
662
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: 'xpath_injection' },
663
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: 'command_injection' },
664
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: 'sql_injection' },
540
665
  ];
541
666
  /**
542
667
  * Compute, per sink type, the set of C# variables whose value has passed through
@@ -903,9 +1028,28 @@ export class SinkFilterPass {
903
1028
  // `ctx.language` aren't supplied (legacy callers) — this preserves
904
1029
  // pre-3.168 behaviour for programmatic callers that pass an
905
1030
  // already-computed source list without file text.
906
- const sources = (ctx.code && ctx.language)
1031
+ const executableSources = (ctx.code && ctx.language)
907
1032
  ? mergedSources.filter(s => !isNonExecutableSourceLine(ctx.code, s.line, ctx.language))
908
1033
  : mergedSources;
1034
+ // cognium-dev#328 prerequisite — key-aware map reads.
1035
+ //
1036
+ // `Map.get` / `HashMap.get` / … are registered as unconditional
1037
+ // `plugin_param` sources with `return_tainted: true` (config-loader.ts),
1038
+ // which models reading a config/plugin parameter map populated from
1039
+ // outside. Applied to *every* map, it also makes a read of a key that was
1040
+ // never written a source:
1041
+ //
1042
+ // Map m = new HashMap();
1043
+ // m.put("a", req.getParameter("name"));
1044
+ // String s1 = (String) m.get("b"); // <- source today; yields null
1045
+ //
1046
+ // That is SecuriBench Micro `Collections6:47`, an `/* OK */` line. The
1047
+ // false positive is currently masked by the const-prop veto in #328, so
1048
+ // this gate is behaviour-neutral today; its purpose is to make lifting that
1049
+ // veto safe, which is the sequencing chosen on #328.
1050
+ const sources = ctx.code
1051
+ ? executableSources.filter(s => !isUnwrittenLocalMapKeyRead(ctx.code, s))
1052
+ : executableSources;
909
1053
  // Build merged sinks, deduplicating JS DOM sinks that may overlap with config sinks.
910
1054
  const sinks = [...taintMatcher.sinks];
911
1055
  for (const s of langSources.additionalSinks) {
@@ -1394,13 +1538,27 @@ export class SinkFilterPass {
1394
1538
  filtered = filtered.filter(sink => {
1395
1539
  if (sink.type !== 'command_injection')
1396
1540
  return true;
1397
- if (sink.method !== 'ProcessBuilder' && sink.method !== 'start')
1541
+ if (sink.method !== 'ProcessBuilder' &&
1542
+ sink.method !== 'start' &&
1543
+ sink.method !== 'command') {
1398
1544
  return true;
1545
+ }
1399
1546
  const sinkLineText = sourceLines[sink.line - 1] ?? '';
1400
- if (!/\bnew\s+ProcessBuilder\s*\(/.test(sinkLineText))
1547
+ const isCtorLine = /\bnew\s+ProcessBuilder\s*\(/.test(sinkLineText);
1548
+ const isCommandLine = /\.\s*command\s*\(/.test(sinkLineText);
1549
+ if (!isCtorLine && !isCommandLine)
1550
+ return true;
1551
+ // #351 — a shell argv[0] means the tainted element IS a shell command
1552
+ // string, so the argv form proves nothing. Keep the sink.
1553
+ if (argvFirstLiteralIsShell(sinkLineText))
1401
1554
  return true;
1402
- if (PROCESS_BUILDER_ARGV_FORM_RE.test(sinkLineText))
1555
+ if (isCtorLine && PROCESS_BUILDER_ARGV_FORM_RE.test(sinkLineText))
1403
1556
  return false;
1557
+ if (isCommandLine && PROCESS_BUILDER_COMMAND_ARGV_FORM_RE.test(sinkLineText)) {
1558
+ return false;
1559
+ }
1560
+ if (!isCtorLine)
1561
+ return true;
1404
1562
  // cognium-dev #256 (3.176.0): resolver fallback for method-call and
1405
1563
  // variable arguments whose declared type is `List<String>` /
1406
1564
  // `String[]` / etc. The regex above catches literal shapes
@@ -1912,6 +2070,69 @@ export class SinkFilterPass {
1912
2070
  return true;
1913
2071
  });
1914
2072
  }
2073
+ // Stage 15l — C# ssrf: HttpClient with a constant BaseAddress and a
2074
+ // relative request path. (cognium-dev#286 D)
2075
+ //
2076
+ // The C# analogue of Stage 15i above (JS fixed-host URL template): when the
2077
+ // host is a compile-time constant, attacker input can only extend the path,
2078
+ // so the request cannot be redirected to another host.
2079
+ //
2080
+ // var client = new HttpClient { BaseAddress = new Uri("https://api.internal/") };
2081
+ // await client.GetAsync("lookup/" + Uri.EscapeDataString(input)); // not ssrf
2082
+ //
2083
+ // Three conditions, and each one is load-bearing:
2084
+ //
2085
+ // (a) `BaseAddress` is assigned a *string-literal* Uri. `new Uri(input)`
2086
+ // means the attacker chooses the host, which is a worse bug than the
2087
+ // one being suppressed here — it must keep firing.
2088
+ // (b) the sink call's receiver is that same client variable.
2089
+ // (c) the argument starts with a *string literal* that is neither absolute
2090
+ // (`http://`, `https://`) nor protocol-relative (`//host`). This is
2091
+ // the subtle one: .NET **ignores BaseAddress entirely when the request
2092
+ // URI is absolute**, so `client.GetAsync(input)` with a bare tainted
2093
+ // variable is still a genuine SSRF — the attacker supplies
2094
+ // `https://evil.example` and the base is discarded. Only a literal
2095
+ // relative prefix proves the attacker cannot reach the host position,
2096
+ // the same reasoning as the same-origin literal prefix in #284 d1.
2097
+ if (language === 'csharp') {
2098
+ const sourceLines = ctx.code.split('\n');
2099
+ // (a) client variables whose BaseAddress is a constant Uri.
2100
+ const constBaseClients = new Set();
2101
+ const declRe = /\b(?:var|HttpClient)\s+([A-Za-z_]\w*)\s*=\s*new\s+HttpClient\s*\{[^}]*\bBaseAddress\s*=\s*new\s+Uri\s*\(\s*(?:@?"[^"]*"|\$?"[^"{}]*")\s*\)/;
2102
+ for (const line of sourceLines) {
2103
+ const m = declRe.exec(line);
2104
+ if (m)
2105
+ constBaseClients.add(m[1]);
2106
+ }
2107
+ if (constBaseClients.size > 0) {
2108
+ // (c) the argument's leading string literal must be a relative path.
2109
+ const relativeLiteralArgRe = /\.\s*(?:Get|Post|Put|Patch|Delete|Send|GetString|GetByteArray|GetStream)\w*Async\s*\(\s*(?:@?\$?)"([^"]*)"/;
2110
+ filtered = filtered.filter(sink => {
2111
+ if (sink.type !== 'ssrf')
2112
+ return true;
2113
+ const text = sourceLines[sink.line - 1] ?? '';
2114
+ // (b) receiver must be a constant-base client.
2115
+ let onConstClient = false;
2116
+ for (const v of constBaseClients) {
2117
+ if (new RegExp(`(?<![\\w])${escapeRegex(v)}\\s*\\.`).test(text)) {
2118
+ onConstClient = true;
2119
+ break;
2120
+ }
2121
+ }
2122
+ if (!onConstClient)
2123
+ return true;
2124
+ const m = relativeLiteralArgRe.exec(text);
2125
+ if (!m)
2126
+ return true; // no leading literal — keep
2127
+ const lit = m[1];
2128
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(lit))
2129
+ return true; // absolute URI
2130
+ if (lit.startsWith('//'))
2131
+ return true; // protocol-relative
2132
+ return false; // fixed host, relative path
2133
+ });
2134
+ }
2135
+ }
1915
2136
  // Stage 16 — JS log_injection (CWE-117) sanitizer suppression.
1916
2137
  // (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
1917
2138
  //