circle-ir 4.9.26 → 4.9.28

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 (33) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +23 -3
  3. package/dist/analysis/config-loader.js.map +1 -1
  4. package/dist/analysis/findings.d.ts.map +1 -1
  5. package/dist/analysis/findings.js +154 -0
  6. package/dist/analysis/findings.js.map +1 -1
  7. package/dist/analysis/interprocedural.d.ts.map +1 -1
  8. package/dist/analysis/interprocedural.js +63 -0
  9. package/dist/analysis/interprocedural.js.map +1 -1
  10. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  11. package/dist/analysis/passes/language-sources-pass.js +156 -4
  12. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  13. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  14. package/dist/analysis/passes/taint-propagation-pass.js +97 -4
  15. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  16. package/dist/analysis/passes/variable-shadowing-pass.d.ts.map +1 -1
  17. package/dist/analysis/passes/variable-shadowing-pass.js +15 -0
  18. package/dist/analysis/passes/variable-shadowing-pass.js.map +1 -1
  19. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  20. package/dist/analysis/taint-matcher.js +21 -1
  21. package/dist/analysis/taint-matcher.js.map +1 -1
  22. package/dist/analyzer.d.ts +36 -0
  23. package/dist/analyzer.d.ts.map +1 -1
  24. package/dist/analyzer.js +30 -0
  25. package/dist/analyzer.js.map +1 -1
  26. package/dist/browser/circle-ir.js +360 -13
  27. package/dist/core/circle-ir-core.cjs +94 -5
  28. package/dist/core/circle-ir-core.js +94 -5
  29. package/dist/core/extractors/calls.js +127 -1
  30. package/dist/core/extractors/calls.js.map +1 -1
  31. package/dist/types/index.d.ts +17 -0
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/package.json +3 -3
@@ -506,7 +506,7 @@ export class LanguageSourcesPass {
506
506
  // guard (the canonicalize-then-contain path-traversal defence). Java and
507
507
  // JS/TS already credit their equivalents above; C# was the gap.
508
508
  if (language === 'csharp') {
509
- additionalSanitizers.push(...findCSharpFullPathStartsWithGuardSanitizers(code));
509
+ additionalSanitizers.push(...findCSharpFullPathStartsWithGuardSanitizers(code), ...findCSharpEarlyReturnGuardSanitizers(code, language));
510
510
  }
511
511
  // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
512
512
  // fromJson recognized as ETE terminator (does not affect
@@ -1542,7 +1542,9 @@ function findCSharpRequestSources(sourceCode, language) {
1542
1542
  // of defect as #350 (Java try-with-resources): a resource-declaration syntax
1543
1543
  // the scanner never looked at.
1544
1544
  const assignRe = /^\s*(?:await\s+)?(?:using\s*\(?\s*)?(?:var\s+|[A-Za-z_][\w.<>\[\]]*\s+)?([A-Za-z_]\w*)\s*=\s*(.+?);?\s*$/;
1545
- const requestReadRe = /\bRequest\s*\.\s*(?:Query|Form|Headers|Cookies|QueryString|RouteValues|Body|Files|Params)\b/;
1545
+ // `Request.*` is the inherited controller member. A parameter typed
1546
+ // `HttpRequest` / `HttpRequestBase` is the same surface under whatever
1547
+ // name the method uses (`req` in Juliet) — cognium-dev#501.
1546
1548
  // Console/stdin reads bind their LHS too (dominant source shape in the NIST
1547
1549
  // Juliet C# corpus). Like `Request.*`, a bare `Console.ReadLine()` returns a
1548
1550
  // value that nothing else seeds a variable for, so bind it as `io_input`.
@@ -1562,15 +1564,24 @@ function findCSharpRequestSources(sourceCode, language) {
1562
1564
  // `\s+` after the type name keeps `IFormFileCollection files` from matching.
1563
1565
  const formFileParams = new Set();
1564
1566
  const formFileDeclRe = /\bIFormFile\s+([A-Za-z_]\w*)/g;
1567
+ // Inherited `Request` plus any local declared `HttpRequest` / `HttpRequestBase`
1568
+ // (nullable `HttpRequest?` included). `HttpRequestMessage` does not match:
1569
+ // the type token must be followed by whitespace, not more identifier text.
1570
+ const httpRequestReceivers = new Set(['Request']);
1571
+ const httpRequestDeclRe = /\b(?:HttpRequest|HttpRequestBase)\??\s+([A-Za-z_]\w*)/g;
1565
1572
  for (const line of lines) {
1566
- const re = new RegExp(formFileDeclRe.source, 'g');
1573
+ const formRe = new RegExp(formFileDeclRe.source, 'g');
1567
1574
  let d;
1568
- while ((d = re.exec(line)) !== null)
1575
+ while ((d = formRe.exec(line)) !== null)
1569
1576
  formFileParams.add(d[1]);
1577
+ const reqRe = new RegExp(httpRequestDeclRe.source, 'g');
1578
+ while ((d = reqRe.exec(line)) !== null)
1579
+ httpRequestReceivers.add(d[1]);
1570
1580
  }
1571
1581
  const formFileReadRe = formFileParams.size > 0
1572
1582
  ? new RegExp(`\\b(?:${[...formFileParams].join('|')})\\s*\\.\\s*(?:FileName|ContentType)\\b`)
1573
1583
  : null;
1584
+ const requestReadRe = new RegExp(`\\b(?:${[...httpRequestReceivers].join('|')})\\s*\\.\\s*(?:Query|Form|Headers|Cookies|QueryString|RouteValues|Body|Files|Params)\\b`);
1574
1585
  const classify = (text) => requestReadRe.test(text)
1575
1586
  ? 'http_param'
1576
1587
  : formFileReadRe?.test(text)
@@ -1620,10 +1631,44 @@ function findCSharpRequestSources(sourceCode, language) {
1620
1631
  };
1621
1632
  const exprStmtCallRe = /^\s*(?:await\s+)?[A-Za-z_][\w.]*(?:<[^>]*>)?\s*\(.*\)\s*;\s*$/;
1622
1633
  const nonCallKeywordRe = /^\s*(?:await\s+)?(?:if|while|for|foreach|switch|using|lock|return|throw|catch|when|nameof|typeof|sizeof|default)\b/;
1634
+ // cognium-dev#504 — an assignment nested in a same-line block is not the
1635
+ // whole line, so `assignRe` misses it:
1636
+ // try { data = Console.ReadLine(); } catch (Exception) { } // was NOT
1637
+ // data = Console.ReadLine(); // bound
1638
+ // `(?!=)` keeps `==` from looking like an assignment. A full-line comment
1639
+ // is skipped; the rest of this scanner already ignores comment text only
1640
+ // when it is not the line's code.
1641
+ const embeddedAssignRe = /(?:^|[{;])\s*(?:await\s+)?(?:using\s*\(?\s*)?(?:var\s+|[A-Za-z_][\w.<>\[\]]*\s+)?([A-Za-z_]\w*)\s*=(?!=)\s*([^;{}]+)/;
1623
1642
  for (let i = 0; i < lines.length; i++) {
1624
1643
  const m = assignRe.exec(lines[i]);
1625
1644
  if (!m) {
1626
1645
  const stmt = lines[i];
1646
+ if (stmt.includes('{') && !/^\s*(?:\/\/|\/\*|\*)/.test(stmt)) {
1647
+ const emb = new RegExp(embeddedAssignRe.source, 'g');
1648
+ let em;
1649
+ let seeded = false;
1650
+ while ((em = emb.exec(stmt)) !== null) {
1651
+ const varName = em[1];
1652
+ const rhs = em[2];
1653
+ const type = classify(rhs);
1654
+ if (!type)
1655
+ continue;
1656
+ const lineNumber = i + 1;
1657
+ if (sources.some(s => s.line === lineNumber && s.variable === varName))
1658
+ continue;
1659
+ sources.push({
1660
+ type,
1661
+ location: `${varName} = ${rhs.trim().substring(0, 50)}${rhs.length > 50 ? '...' : ''}`,
1662
+ severity: 'high',
1663
+ line: lineNumber,
1664
+ confidence: 1.0,
1665
+ variable: varName,
1666
+ });
1667
+ seeded = true;
1668
+ }
1669
+ if (seeded)
1670
+ continue;
1671
+ }
1627
1672
  if (!exprStmtCallRe.test(stmt) || nonCallKeywordRe.test(stmt))
1628
1673
  continue;
1629
1674
  const hit = inlineRead(stmt);
@@ -5596,6 +5641,113 @@ function guardRejects(lines, i, terminatorRe, window) {
5596
5641
  * form `if (x.StartsWith(root)) { … }` still reports, and a test pins that so
5597
5642
  * the gap stays visible rather than silently assumed.
5598
5643
  */
5644
+ /**
5645
+ * cognium-dev#272 — a validating predicate followed by an EARLY RETURN is a
5646
+ * guard, and the value reaching the sink afterwards is already validated.
5647
+ *
5648
+ * if (Array.IndexOf(Allowed, u.Host) < 0) return; // host allow-list
5649
+ * await client.GetStringAsync(input); // reported ssrf
5650
+ *
5651
+ * if (!input.All(char.IsLetterOrDigit)) return; // char-class check
5652
+ * doc.SelectSingleNode("/a[@b='" + input + "']"); // reported xpath
5653
+ *
5654
+ * Both safe mirrors are clean of their *family* finding after #272's earlier
5655
+ * fixes, and both still emit a finding because the guard itself is not
5656
+ * credited. The shape is shared with #286-B and with the Java `Set.contains`
5657
+ * guard in #293, so the recogniser is written around the PREDICATE rather than
5658
+ * around any one sink family.
5659
+ *
5660
+ * Deliberately limited to predicates that are self-evidently validating:
5661
+ * allow-list membership and character-class validation. A user-defined
5662
+ * boolean helper (`if (!IsAllowed(input)) return;`, #286-B) is NOT credited
5663
+ * here — crediting an arbitrary predicate by name would silence a real flow
5664
+ * whenever the helper does not actually validate, which is a false negative no
5665
+ * test would catch. That half needs its own evidence.
5666
+ *
5667
+ * Only the REJECTING direction counts, via `guardRejects`: a guard whose body
5668
+ * falls through has not stopped anything.
5669
+ */
5670
+ function findCSharpEarlyReturnGuardSanitizers(code, language) {
5671
+ if (language !== 'csharp')
5672
+ return [];
5673
+ const sanitizers = [];
5674
+ const lines = code.split('\n');
5675
+ const terminatorRe = /\b(throw|return)\b/;
5676
+ // Allow-list membership, rejecting form:
5677
+ // if (Array.IndexOf(Allowed, x) < 0) return;
5678
+ // if (!Allowed.Contains(x)) return;
5679
+ const allowlistRes = [
5680
+ /\bif\s*\(\s*Array\s*\.\s*IndexOf\s*\([^,]+,\s*([A-Za-z_][\w.]*)\s*\)\s*<\s*0\s*\)/,
5681
+ /\bif\s*\(\s*!\s*[A-Za-z_][\w.]*\s*\.\s*Contains\s*\(\s*([A-Za-z_][\w.]*)\s*\)\s*\)/,
5682
+ ];
5683
+ // Character-class validation, rejecting form:
5684
+ // if (!input.All(char.IsLetterOrDigit)) return;
5685
+ const charClassRe = /\bif\s*\(\s*!\s*([A-Za-z_][\w.]*)\s*\.\s*All\s*\(\s*char\s*\.\s*Is[A-Za-z]+\s*\)\s*\)/;
5686
+ for (let i = 0; i < lines.length; i++) {
5687
+ let guardedExpr = null;
5688
+ for (const re of allowlistRes) {
5689
+ const m = re.exec(lines[i]);
5690
+ if (m) {
5691
+ guardedExpr = m[1];
5692
+ break;
5693
+ }
5694
+ }
5695
+ if (!guardedExpr) {
5696
+ const m = charClassRe.exec(lines[i]);
5697
+ if (m)
5698
+ guardedExpr = m[1];
5699
+ }
5700
+ if (!guardedExpr)
5701
+ continue;
5702
+ if (!guardRejects(lines, i, terminatorRe, 6))
5703
+ continue;
5704
+ // The guarded expression may be a derivation of the tainted value
5705
+ // (`u.Host` guards `u`, and `u` came from `input`), so credit the root
5706
+ // identifier as well as the full expression.
5707
+ const root = guardedExpr.split('.')[0];
5708
+ const guarded = new Set([guardedExpr, root]);
5709
+ const assignRe = /^\s*(?:(?:var|string)\s+)?([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/;
5710
+ // Walk BACKWARD first: the guard often validates a DERIVATION of the
5711
+ // tainted value while the sink uses the original.
5712
+ //
5713
+ // var u = new Uri(input);
5714
+ // if (Array.IndexOf(Allowed, u.Host) < 0) return; // guards u.Host
5715
+ // await client.GetStringAsync(input); // uses input
5716
+ //
5717
+ // Allow-listing the host of the URI built from `input` constrains where
5718
+ // `input` can point, so the original is guarded too. Bounded to the
5719
+ // identifiers that actually fed the guarded root, transitively.
5720
+ for (let b = i - 1; b >= 0; b--) {
5721
+ const a = assignRe.exec(lines[b]);
5722
+ if (!a || !guarded.has(a[1]))
5723
+ continue;
5724
+ for (const id of a[2].match(/[A-Za-z_]\w*/g) ?? [])
5725
+ guarded.add(id);
5726
+ }
5727
+ // Anything assigned FROM a guarded value downstream is guarded too.
5728
+ const mentions = (t) => {
5729
+ for (const n of guarded)
5730
+ if (new RegExp(`\\b${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(t))
5731
+ return true;
5732
+ return false;
5733
+ };
5734
+ for (let l = i + 1; l < lines.length; l++) {
5735
+ const a = assignRe.exec(lines[l]);
5736
+ if (a && mentions(a[2]))
5737
+ guarded.add(a[1]);
5738
+ if (mentions(lines[l])) {
5739
+ sanitizers.push({
5740
+ type: 'csharp_early_return_guard',
5741
+ method: 'guard',
5742
+ line: l + 1,
5743
+ sanitizes: ['ssrf', 'xpath_injection', 'ldap_injection', 'path_traversal',
5744
+ 'command_injection', 'sql_injection', 'external_taint_escape'],
5745
+ });
5746
+ }
5747
+ }
5748
+ }
5749
+ return sanitizers;
5750
+ }
5599
5751
  function findCSharpFullPathStartsWithGuardSanitizers(code) {
5600
5752
  const sanitizers = [];
5601
5753
  const lines = code.split('\n');