circle-ir 3.215.0 → 4.0.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 (40) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +68 -0
  3. package/dist/analysis/config-loader.js.map +1 -1
  4. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  5. package/dist/analysis/passes/language-sources-pass.js +42 -0
  6. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  7. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  8. package/dist/analysis/passes/sink-filter-pass.js +277 -0
  9. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  10. package/dist/analysis/passes/taint-propagation-pass.js +1 -1
  11. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  12. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  13. package/dist/analysis/taint-matcher.js +18 -3
  14. package/dist/analysis/taint-matcher.js.map +1 -1
  15. package/dist/browser/circle-ir.js +592 -5
  16. package/dist/core/circle-ir-core.cjs +338 -4
  17. package/dist/core/circle-ir-core.js +338 -4
  18. package/dist/core/extractors/calls.d.ts.map +1 -1
  19. package/dist/core/extractors/calls.js +133 -0
  20. package/dist/core/extractors/calls.js.map +1 -1
  21. package/dist/core/extractors/dfg.d.ts.map +1 -1
  22. package/dist/core/extractors/dfg.js +86 -1
  23. package/dist/core/extractors/dfg.js.map +1 -1
  24. package/dist/core/extractors/types.d.ts.map +1 -1
  25. package/dist/core/extractors/types.js +89 -0
  26. package/dist/core/extractors/types.js.map +1 -1
  27. package/dist/languages/plugins/csharp.d.ts +35 -0
  28. package/dist/languages/plugins/csharp.d.ts.map +1 -0
  29. package/dist/languages/plugins/csharp.js +72 -0
  30. package/dist/languages/plugins/csharp.js.map +1 -0
  31. package/dist/languages/plugins/index.d.ts +1 -0
  32. package/dist/languages/plugins/index.d.ts.map +1 -1
  33. package/dist/languages/plugins/index.js +3 -0
  34. package/dist/languages/plugins/index.js.map +1 -1
  35. package/dist/languages/types.d.ts +1 -1
  36. package/dist/languages/types.d.ts.map +1 -1
  37. package/dist/types/index.d.ts +1 -1
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/wasm/tree-sitter-csharp.wasm +0 -0
  40. 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;AA2qB9E,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;AAED,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;CAwiCxC;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;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;AAED,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;CAuoCxC;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"}
@@ -355,6 +355,42 @@ const HTML_CONTENT_TYPE_RE = /text\/html|TEXT_HTML/;
355
355
  // entries, so dropping the member-receiver form loses no modelled SSRF sink.
356
356
  const FETCH_GLOBAL_RECEIVER_RE = /(?:^|[^.\w])(?:window|globalThis|self|global)\s*\.\s*fetch\s*\(/;
357
357
  const FETCH_MEMBER_RECEIVER_RE = /[\w$)\]]\s*\.\s*fetch\s*\(/;
358
+ // cognium-ai#279 R-2 — `Array.prototype.find`/`filter`/… take a *function*
359
+ // predicate; a `nosql_injection` sink only makes sense when the argument is a
360
+ // query object. `user.accounts.find(a => a.providerId === x)` is an array scan,
361
+ // not a Mongo query. A function first argument (arrow or `function`) proves it
362
+ // is the Array method, mirroring the `setTimeout(fn)` "arg is a function ⇒ not a
363
+ // sink" test. Real `collection.find({ ... })` passes an object literal and is
364
+ // unaffected.
365
+ const NOSQL_ARRAY_METHODS = new Set(['find', 'filter', 'some', 'every', 'findIndex', 'findLast']);
366
+ const FUNCTION_FIRST_ARG_RE = /\.\s*(?:find|filter|some|every|findIndex|findLast)\s*\(\s*(?:async\s+)?(?:function\b|(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*=>)/;
367
+ // cognium-ai#279 R-1 — an argument that is an object literal with an ORM
368
+ // query-builder key (`where`/`select`/`populate`/…) is a *builder*, not a raw
369
+ // query string: a value in a `{ field, value }` position binds as a parameter
370
+ // and cannot carry an operator. `adapter.findOne({ where: [...] })` /
371
+ // `repo.findMany({ where: {...} })` are the Node analogue of #247's
372
+ // PreparedStatement class. A real `db.query("SELECT … WHERE …")` has no
373
+ // `key:` colon syntax and is unaffected.
374
+ const ORM_BUILDER_KEY_RE = /[{,]\s*(?:where|select|populate|include|orderBy|relations|attributes)\s*:/;
375
+ // cognium-ai#279 R-3 — SSRF requires a server-side requester; a `fetch`/`axios`
376
+ // call in a browser-rendered client component cannot be SSRF (the browser makes
377
+ // the request, subject to CORS, not the server). Suppress `ssrf` ONLY when all
378
+ // three hold, so real server-side fetches are never touched:
379
+ // (a) the file is a component extension (.jsx/.tsx/.vue/.svelte), AND
380
+ // (b) it carries a positive CLIENT signal (`"use client"`, React hooks,
381
+ // browser globals, JSX event handlers), AND
382
+ // (c) it carries NO server signal (`"use server"`, next/server, SSR data
383
+ // hooks, API-route handler, Node server imports).
384
+ // A Next.js Server Component or API route doing `fetch(userUrl)` has no client
385
+ // signal (and usually a server one), so it keeps firing — zero false negatives.
386
+ const BROWSER_COMPONENT_EXT_RE = /\.(?:jsx|tsx|vue|svelte)$/;
387
+ const CLIENT_SIGNAL_RE = /["']use client["']|\buse(?:State|Effect|Ref|Callback|Memo|Context|Reducer|LayoutEffect)\s*\(|\b(?:window|document|localStorage|sessionStorage|navigator)\s*\.|\son(?:Click|Change|Submit|Input|KeyDown|KeyUp|MouseOver|Focus|Blur)\s*=/;
388
+ const SERVER_SIGNAL_RE = /["']use server["']|\b(?:getServerSideProps|getStaticProps|getStaticPaths)\b|from\s+["']next\/server["']|\bNext(?:Api)?(?:Request|Response)\b|from\s+["'](?:node:)?(?:fs|child_process|http|https|net|dns|dgram)["']|\bexport\s+(?:default\s+)?(?:async\s+)?function\s+handler\s*\(/;
389
+ // cognium-ai#279 R-6 — a template-literal URL whose host is a fixed literal
390
+ // (`https://api.github.com/${path}`) is not SSRF: only the path is
391
+ // interpolated, the destination host is not attacker-controlled. Matches a
392
+ // backtick + scheme + a literal host run (no `${` before the first `/`).
393
+ const FIXED_HOST_TEMPLATE_RE = /`https?:\/\/[^/${}\s`]+[/`]/;
358
394
  /**
359
395
  * Returns the 1-indexed line numbers that a Java `if (x.contains("..")) throw/
360
396
  * return` reject-guard makes path-traversal-safe (cognium-dev#269). A line is
@@ -408,6 +444,154 @@ function javaTraversalRejectGuardedLines(code) {
408
444
  }
409
445
  return covered;
410
446
  }
447
+ // skillsregistry#49b — a fetch/HTTP call whose URL is constrained by an anchored
448
+ // host allow-list is not arbitrary-URL SSRF. Recognise reject-guards
449
+ // (throw/return) that pin the tainted URL to a fixed host set:
450
+ // if (!/^https:\/\/(www\.)?host\.com\//.test(url)) throw ...
451
+ // if (!url.startsWith("https://api.github.com/")) return ...
452
+ // The guard must name the scheme AND contain a literal domain label (a
453
+ // letter-led run followed by a dot) — a bare `^https?:\/\/` with no host does
454
+ // not constrain the destination and is deliberately NOT credited.
455
+ /** True if a regex source (from `/…/.test(url)`) pins the host to a literal domain. */
456
+ function isAnchoredHostAllowlistRegex(src) {
457
+ if (!src.startsWith('^'))
458
+ return false; // must be anchored
459
+ if (!/https?/i.test(src))
460
+ return false; // must name the scheme
461
+ return /[A-Za-z][A-Za-z0-9-]+\\?\./.test(src); // literal host label + (escaped) dot
462
+ }
463
+ /** True if a `startsWith` prefix pins scheme + a literal host (implicitly anchored). */
464
+ function isSchemeHostPrefix(prefix) {
465
+ if (!/^https?:\/\//i.test(prefix))
466
+ return false;
467
+ const afterScheme = prefix.replace(/^https?:\/\//i, '');
468
+ return /[A-Za-z][A-Za-z0-9-]*\./.test(afterScheme); // has a host label + dot
469
+ }
470
+ /**
471
+ * Returns the 1-indexed line numbers made SSRF-safe by an anchored host
472
+ * allow-list reject-guard (skillsregistry#49b). Mirrors the Java traversal
473
+ * reject-guard: a line is covered when it references a variable that was
474
+ * host-guarded above (following one-hop derivations), and the guard has a
475
+ * throwing/returning body (reject polarity — the safe path is everything below).
476
+ */
477
+ function jsSsrfHostGuardedLines(code) {
478
+ const covered = new Set();
479
+ const lines = code.split('\n');
480
+ // `if ( ! /regex/flags .test( VAR ) )`
481
+ const rejectRegexTest = /\bif\s*\(\s*!\s*\/((?:[^/\\]|\\.)*)\/[a-z]*\s*\.\s*test\s*\(\s*([A-Za-z_]\w*)\s*\)/;
482
+ // `if ( ! VAR.startsWith( "prefix" ) )`
483
+ const rejectStartsWith = /\bif\s*\(\s*!\s*([A-Za-z_]\w*)\s*\.\s*startsWith\s*\(\s*["'`]([^"'`]+)["'`]/;
484
+ const terminatorRe = /\b(throw|return)\b/;
485
+ const assignRe = /^\s*(?:const|let|var\s+)?\s*([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/;
486
+ for (let i = 0; i < lines.length; i++) {
487
+ let guardedVar = null;
488
+ const rm = rejectRegexTest.exec(lines[i]);
489
+ if (rm && isAnchoredHostAllowlistRegex(rm[1]))
490
+ guardedVar = rm[2];
491
+ if (!guardedVar) {
492
+ const sm = rejectStartsWith.exec(lines[i]);
493
+ if (sm && isSchemeHostPrefix(sm[2]))
494
+ guardedVar = sm[1];
495
+ }
496
+ if (!guardedVar)
497
+ continue;
498
+ let hasTerminator = terminatorRe.test(lines[i]);
499
+ if (!hasTerminator) {
500
+ for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) {
501
+ if (terminatorRe.test(lines[j])) {
502
+ hasTerminator = true;
503
+ break;
504
+ }
505
+ if (lines[j].includes('}'))
506
+ break;
507
+ }
508
+ }
509
+ if (!hasTerminator)
510
+ continue;
511
+ const guarded = new Set([guardedVar]);
512
+ const mentionsGuarded = (text) => {
513
+ for (const name of guarded)
514
+ if (new RegExp(`\\b${name}\\b`).test(text))
515
+ return true;
516
+ return false;
517
+ };
518
+ for (let l = i + 1; l < lines.length; l++) {
519
+ const lineText = lines[l];
520
+ const assign = assignRe.exec(lineText);
521
+ if (assign) {
522
+ if (mentionsGuarded(assign[2]))
523
+ guarded.add(assign[1]);
524
+ else if (assign[1] !== guardedVar)
525
+ guarded.delete(assign[1]);
526
+ }
527
+ if (mentionsGuarded(lineText))
528
+ covered.add(l + 1);
529
+ }
530
+ }
531
+ return covered;
532
+ }
533
+ // C# sanitizers (Phase-1) and the sink types each neutralises. A tainted value
534
+ // that flows through one of these before reaching a sink of the matching type
535
+ // is safe. Method names are C#-distinctive (see config-loader C# sanitizers).
536
+ const CSHARP_SANITIZER_RES = [
537
+ { re: /\b(?:HtmlEncode|JavaScriptStringEncode)\s*\(/, type: 'xss' },
538
+ { re: /\bHtmlEncoder\s*\.\s*Encode\s*\(/, type: 'xss' },
539
+ { re: /\bPath\s*\.\s*GetFileName\s*\(/, type: 'path_traversal' },
540
+ ];
541
+ /**
542
+ * Compute, per sink type, the set of C# variables whose value has passed through
543
+ * a sanitizer for that type (cognium-dev C#/.NET Phase-1). Seeds from
544
+ * `V = <sanitizer_T>(…)` assignments, then takes a transitive closure over
545
+ * `W = … V …` so downstream derivations (`s = "<div>" + safe`) stay sanitized.
546
+ * Over-approximates on mixed-source lines (`s = safe + evil`) — an accepted
547
+ * MVP recall trade for text-scan sanitizer awareness; C#-scoped, so no effect
548
+ * on other languages.
549
+ */
550
+ function csharpSanitizedVarsByType(code) {
551
+ const lines = code.split('\n');
552
+ const byType = new Map();
553
+ const assignRe = /^\s*(?:var\s+|[A-Za-z_][\w.<>\[\]]*\s+)?([A-Za-z_]\w*)\s*=\s*(.+?);?\s*$/;
554
+ for (const { type } of CSHARP_SANITIZER_RES) {
555
+ if (!byType.has(type))
556
+ byType.set(type, new Set());
557
+ }
558
+ // Seed: direct `V = sanitizer(...)`.
559
+ for (const line of lines) {
560
+ const m = assignRe.exec(line);
561
+ if (!m)
562
+ continue;
563
+ const [, lhs, rhs] = m;
564
+ for (const { re, type } of CSHARP_SANITIZER_RES) {
565
+ if (re.test(rhs))
566
+ byType.get(type).add(lhs);
567
+ }
568
+ }
569
+ // Transitive closure: `W = … V …` where V is already sanitized-for-T.
570
+ let changed = true;
571
+ let guard = 0;
572
+ while (changed && guard < lines.length + 2) {
573
+ changed = false;
574
+ guard++;
575
+ for (const line of lines) {
576
+ const m = assignRe.exec(line);
577
+ if (!m)
578
+ continue;
579
+ const [, lhs, rhs] = m;
580
+ for (const [type, set] of byType) {
581
+ if (set.has(lhs))
582
+ continue;
583
+ const refsSanitized = [...set].some(v => new RegExp(`(?<![\\w])${escapeRegex(v)}(?![\\w])`).test(rhs));
584
+ // Only carry sanitized-ness when the RHS did not itself apply the
585
+ // sanitizer (already seeded) and references a sanitized var.
586
+ if (refsSanitized) {
587
+ set.add(lhs);
588
+ changed = true;
589
+ }
590
+ }
591
+ }
592
+ }
593
+ return byType;
594
+ }
411
595
  // Recognises Java `String.matches("regex")` — implicitly anchored ^…$.
412
596
  const JAVA_INLINE_MATCHES_RE = /\.\s*matches\s*\(\s*"((?:[^"\\]|\\.)*)"\s*\)/g;
413
597
  function resolveJavaReceiverType(receiver, sinkLine, sourceLines) {
@@ -1579,6 +1763,99 @@ export class SinkFilterPass {
1579
1763
  return !FETCH_MEMBER_RECEIVER_RE.test(sinkLineText);
1580
1764
  });
1581
1765
  }
1766
+ // Stage 15g — JS/TS nosql: `.find(fn)` etc. is Array.prototype iteration,
1767
+ // not a Mongo query. (cognium-ai#279 R-2) Drop a `nosql_injection` sink on an
1768
+ // array method whose first argument is a function (arrow / `function`);
1769
+ // real `collection.find({query})` passes an object and is kept.
1770
+ if (['javascript', 'typescript'].includes(language)) {
1771
+ const sourceLines = ctx.code.split('\n');
1772
+ filtered = filtered.filter(sink => {
1773
+ if (sink.type !== 'nosql_injection')
1774
+ return true;
1775
+ if (!NOSQL_ARRAY_METHODS.has(sink.method ?? ''))
1776
+ return true;
1777
+ const sinkLineText = sourceLines[sink.line - 1] ?? '';
1778
+ return !FUNCTION_FIRST_ARG_RE.test(sinkLineText);
1779
+ });
1780
+ }
1781
+ // Stage 15h — JS/TS sql/nosql: ORM query-builder object argument.
1782
+ // (cognium-ai#279 R-1) Drop a sql/nosql injection sink whose call passes an
1783
+ // object literal with an ORM builder key (`where`/`select`/…) — a bound
1784
+ // parameter, not a raw query. Raw `db.query("… WHERE …")` has no `key:`
1785
+ // colon and is kept.
1786
+ if (['javascript', 'typescript'].includes(language)) {
1787
+ const sourceLines = ctx.code.split('\n');
1788
+ filtered = filtered.filter(sink => {
1789
+ if (sink.type !== 'sql_injection' && sink.type !== 'nosql_injection')
1790
+ return true;
1791
+ const sinkLineText = sourceLines[sink.line - 1] ?? '';
1792
+ return !ORM_BUILDER_KEY_RE.test(sinkLineText);
1793
+ });
1794
+ }
1795
+ // Stage 15j — JS/TS ssrf: client-component file. (cognium-ai#279 R-3)
1796
+ // A fetch/axios call in a browser-rendered client component is not SSRF.
1797
+ // Gated on ext + positive client signal + no server signal (zero-FN).
1798
+ if (['javascript', 'typescript', 'tsx'].includes(language)) {
1799
+ const file = ctx.graph?.ir?.meta?.file ?? '';
1800
+ if (BROWSER_COMPONENT_EXT_RE.test(file) &&
1801
+ CLIENT_SIGNAL_RE.test(ctx.code) &&
1802
+ !SERVER_SIGNAL_RE.test(ctx.code)) {
1803
+ filtered = filtered.filter(sink => sink.type !== 'ssrf');
1804
+ }
1805
+ }
1806
+ // Stage 15i — JS/TS ssrf: fixed-host URL template. (cognium-ai#279 R-6)
1807
+ // Drop an ssrf sink whose line builds the URL from a template literal with a
1808
+ // literal host (only the path is interpolated).
1809
+ if (['javascript', 'typescript'].includes(language)) {
1810
+ const sourceLines = ctx.code.split('\n');
1811
+ filtered = filtered.filter(sink => {
1812
+ if (sink.type !== 'ssrf')
1813
+ return true;
1814
+ const sinkLineText = sourceLines[sink.line - 1] ?? '';
1815
+ return !FIXED_HOST_TEMPLATE_RE.test(sinkLineText);
1816
+ });
1817
+ }
1818
+ // Stage 15f — JS/TS ssrf: anchored host allow-list reject-guard.
1819
+ // (skillsregistry#49b) A URL pinned to a fixed host set by an anchored
1820
+ // `^https://host/`.test(url) / url.startsWith("https://host/") reject-guard
1821
+ // (throw/return) before the request cannot target an arbitrary host, so it
1822
+ // is not SSRF. Applied as a sink drop (reaches the generateFindings scan path
1823
+ // too). Scheme-only guards (`^https?://`) are not credited — they don't
1824
+ // constrain the destination.
1825
+ if (['javascript', 'typescript'].includes(language)) {
1826
+ const guardedLines = jsSsrfHostGuardedLines(ctx.code);
1827
+ if (guardedLines.size > 0) {
1828
+ filtered = filtered.filter(sink => !(sink.type === 'ssrf' && guardedLines.has(sink.line)));
1829
+ }
1830
+ }
1831
+ // Stage 15k — C# sanitizer suppression (cognium-dev C#/.NET Phase-1).
1832
+ // C# rides the text-scan propagation, which is not sanitizer-aware; apply
1833
+ // sanitizers here (sink-type-aware, C#-scoped). Drop a sink when its line
1834
+ // inline-applies a sanitizer for that type, or when a variable on the sink
1835
+ // line has passed through a sanitizer for that type upstream.
1836
+ if (language === 'csharp') {
1837
+ const sourceLines = ctx.code.split('\n');
1838
+ const sanitizedByType = csharpSanitizedVarsByType(ctx.code);
1839
+ filtered = filtered.filter(sink => {
1840
+ const sinkLineText = sourceLines[sink.line - 1] ?? '';
1841
+ // (a) inline sanitizer on the sink line, e.g. Html.Raw(HtmlEncode(x)).
1842
+ for (const { re, type } of CSHARP_SANITIZER_RES) {
1843
+ if (type === sink.type && re.test(sinkLineText))
1844
+ return false;
1845
+ }
1846
+ // (b) a sink-line variable is sanitized-for-this-type upstream. Tested
1847
+ // per-var (not via extractSourceIdentifiers, which skips single-char
1848
+ // names) so `Html.Raw(s)` with a 1-letter var is covered.
1849
+ const sanitizedVars = sanitizedByType.get(sink.type);
1850
+ if (sanitizedVars && sanitizedVars.size > 0) {
1851
+ for (const v of sanitizedVars) {
1852
+ if (new RegExp(`(?<![\\w])${escapeRegex(v)}(?![\\w])`).test(sinkLineText))
1853
+ return false;
1854
+ }
1855
+ }
1856
+ return true;
1857
+ });
1858
+ }
1582
1859
  // Stage 16 — JS log_injection (CWE-117) sanitizer suppression.
1583
1860
  // (cognium-dev #216 sanitizer-wrapped FP — Sprint 52)
1584
1861
  //