circle-ir 4.9.27 → 4.9.29

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 (31) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +58 -5
  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 +130 -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 +162 -8
  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 +214 -16
  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 +62 -1
  21. package/dist/analysis/taint-matcher.js.map +1 -1
  22. package/dist/browser/circle-ir.js +486 -33
  23. package/dist/core/circle-ir-core.cjs +173 -13
  24. package/dist/core/circle-ir-core.js +173 -13
  25. package/dist/core/extractors/calls.js +155 -1
  26. package/dist/core/extractors/calls.js.map +1 -1
  27. package/dist/core/extractors/types.js +50 -7
  28. package/dist/core/extractors/types.js.map +1 -1
  29. package/dist/types/config.d.ts +14 -0
  30. package/dist/types/config.d.ts.map +1 -1
  31. package/package.json +1 -1
@@ -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
@@ -1569,6 +1569,19 @@ function findCSharpRequestSources(sourceCode, language) {
1569
1569
  // the type token must be followed by whitespace, not more identifier text.
1570
1570
  const httpRequestReceivers = new Set(['Request']);
1571
1571
  const httpRequestDeclRe = /\b(?:HttpRequest|HttpRequestBase)\??\s+([A-Za-z_]\w*)/g;
1572
+ // cognium-dev#502 — I/O and database reads, mirroring Java's
1573
+ // `BufferedReader.readLine` (io_input) and `ResultSet.getString` (db_input).
1574
+ // Declared-type scoped for the same reason #501 needed it: the method names
1575
+ // are generic (`ReadLine`, `GetString`), so matching them on any receiver
1576
+ // would fire on unrelated types. A registry row cannot do this — the
1577
+ // receiver's type is not resolved for a `SqlDataReader rdr` parameter — and
1578
+ // binding here also gives the LHS a `variable`, which a bare
1579
+ // `return_tainted` row does not, so the flow can form when the read sits
1580
+ // inside a nested `using` / `try` block (the Juliet shape).
1581
+ const streamReaderVars = new Set();
1582
+ const streamReaderDeclRe = /\b(?:StreamReader|TextReader|BinaryReader)\??\s+([A-Za-z_]\w*)/g;
1583
+ const dbReaderVars = new Set();
1584
+ const dbReaderDeclRe = /\b(?:SqlDataReader|DbDataReader|IDataReader|OdbcDataReader|OleDbDataReader)\??\s+([A-Za-z_]\w*)/g;
1572
1585
  for (const line of lines) {
1573
1586
  const formRe = new RegExp(formFileDeclRe.source, 'g');
1574
1587
  let d;
@@ -1577,7 +1590,19 @@ function findCSharpRequestSources(sourceCode, language) {
1577
1590
  const reqRe = new RegExp(httpRequestDeclRe.source, 'g');
1578
1591
  while ((d = reqRe.exec(line)) !== null)
1579
1592
  httpRequestReceivers.add(d[1]);
1580
- }
1593
+ const srRe = new RegExp(streamReaderDeclRe.source, 'g');
1594
+ while ((d = srRe.exec(line)) !== null)
1595
+ streamReaderVars.add(d[1]);
1596
+ const dbRe = new RegExp(dbReaderDeclRe.source, 'g');
1597
+ while ((d = dbRe.exec(line)) !== null)
1598
+ dbReaderVars.add(d[1]);
1599
+ }
1600
+ const streamReadRe = streamReaderVars.size > 0
1601
+ ? new RegExp(`\\b(?:${[...streamReaderVars].join('|')})\\s*\\.\\s*(?:ReadLine|ReadToEnd|ReadLineAsync|ReadToEndAsync|ReadBlock|Read)\\s*\\(`)
1602
+ : null;
1603
+ const dbReadRe = dbReaderVars.size > 0
1604
+ ? new RegExp(`\\b(?:${[...dbReaderVars].join('|')})\\s*\\.\\s*(?:GetString|GetValue|GetInt32|GetInt64|GetDecimal|GetDateTime|GetFieldValue|\\[)`)
1605
+ : null;
1581
1606
  const formFileReadRe = formFileParams.size > 0
1582
1607
  ? new RegExp(`\\b(?:${[...formFileParams].join('|')})\\s*\\.\\s*(?:FileName|ContentType)\\b`)
1583
1608
  : null;
@@ -1590,7 +1615,11 @@ function findCSharpRequestSources(sourceCode, language) {
1590
1615
  ? 'io_input'
1591
1616
  : envReadRe.test(text)
1592
1617
  ? 'env_input'
1593
- : null;
1618
+ : streamReadRe?.test(text)
1619
+ ? 'io_input'
1620
+ : dbReadRe?.test(text)
1621
+ ? 'db_input'
1622
+ : null;
1594
1623
  // cognium-dev#339 — a bare expression statement reads the request INLINE in
1595
1624
  // the call, with no local to bind:
1596
1625
  //
@@ -1613,6 +1642,8 @@ function findCSharpRequestSources(sourceCode, language) {
1613
1642
  [formFileReadRe, 'http_param'],
1614
1643
  [consoleReadRe, 'io_input'],
1615
1644
  [envReadRe, 'env_input'],
1645
+ [streamReadRe, 'io_input'],
1646
+ [dbReadRe, 'db_input'],
1616
1647
  ];
1617
1648
  const inlineRead = (text) => {
1618
1649
  const argsStart = text.indexOf('(');
@@ -1641,7 +1672,20 @@ function findCSharpRequestSources(sourceCode, language) {
1641
1672
  const embeddedAssignRe = /(?:^|[{;])\s*(?:await\s+)?(?:using\s*\(?\s*)?(?:var\s+|[A-Za-z_][\w.<>\[\]]*\s+)?([A-Za-z_]\w*)\s*=(?!=)\s*([^;{}]+)/;
1642
1673
  for (let i = 0; i < lines.length; i++) {
1643
1674
  const m = assignRe.exec(lines[i]);
1644
- if (!m) {
1675
+ // cognium-dev#502 — `assignRe` is anchored and greedy, so on a one-line
1676
+ // block header that also contains the real assignment
1677
+ //
1678
+ // using (StreamReader sr = new StreamReader(tcp.GetStream())) { data = sr.ReadLine(); }
1679
+ //
1680
+ // it captures `sr` with EVERYTHING after `=` as the right-hand side. That
1681
+ // RHS contains `sr.ReadLine()`, so the line was classified as a read and
1682
+ // bound to `sr` — the reader, not `data`. The embedded-assignment path
1683
+ // handles exactly this shape and finds both `sr` and `data`, but it only
1684
+ // ran when `assignRe` failed to match at all. Prefer it whenever the
1685
+ // captured RHS crosses a block boundary. (#504 was the mirror of this:
1686
+ // there `assignRe` did not match and the inner assignment was lost.)
1687
+ const rhsCrossesBlock = m !== null && m[2].includes('{');
1688
+ if (!m || rhsCrossesBlock) {
1645
1689
  const stmt = lines[i];
1646
1690
  if (stmt.includes('{') && !/^\s*(?:\/\/|\/\*|\*)/.test(stmt)) {
1647
1691
  const emb = new RegExp(embeddedAssignRe.source, 'g');
@@ -1659,7 +1703,8 @@ function findCSharpRequestSources(sourceCode, language) {
1659
1703
  sources.push({
1660
1704
  type,
1661
1705
  location: `${varName} = ${rhs.trim().substring(0, 50)}${rhs.length > 50 ? '...' : ''}`,
1662
- severity: 'high',
1706
+ // #502 — Java parity: a database row read is medium, I/O is high.
1707
+ severity: type === 'db_input' ? 'medium' : 'high',
1663
1708
  line: lineNumber,
1664
1709
  confidence: 1.0,
1665
1710
  variable: varName,
@@ -1693,7 +1738,7 @@ function findCSharpRequestSources(sourceCode, language) {
1693
1738
  sources.push({
1694
1739
  type,
1695
1740
  location: `${read} in ${code.substring(0, 50)}${code.length > 50 ? '...' : ''}`,
1696
- severity: 'high',
1741
+ severity: type === 'db_input' ? 'medium' : 'high',
1697
1742
  line: lineNumber,
1698
1743
  confidence: 1.0,
1699
1744
  variable: '<inline>',
@@ -1711,7 +1756,8 @@ function findCSharpRequestSources(sourceCode, language) {
1711
1756
  sources.push({
1712
1757
  type,
1713
1758
  location: `${varName} = ${rhs.trim().substring(0, 50)}${rhs.length > 50 ? '...' : ''}`,
1714
- severity: 'high',
1759
+ // #502 — Java parity: a database row read is medium, I/O is high.
1760
+ severity: type === 'db_input' ? 'medium' : 'high',
1715
1761
  line: lineNumber,
1716
1762
  confidence: 1.0,
1717
1763
  variable: varName,
@@ -1980,7 +2026,8 @@ function findPythonAssignmentSources(sourceCode, language) {
1980
2026
  sources.push({
1981
2027
  type,
1982
2028
  location: `${varName} = ${rhs.trim().substring(0, 50)}${rhs.length > 50 ? '...' : ''}`,
1983
- severity: 'high',
2029
+ // #502 — Java parity: a database row read is medium, I/O is high.
2030
+ severity: type === 'db_input' ? 'medium' : 'high',
1984
2031
  line: lineNumber,
1985
2032
  confidence: 0.95,
1986
2033
  variable: varName,
@@ -5641,6 +5688,113 @@ function guardRejects(lines, i, terminatorRe, window) {
5641
5688
  * form `if (x.StartsWith(root)) { … }` still reports, and a test pins that so
5642
5689
  * the gap stays visible rather than silently assumed.
5643
5690
  */
5691
+ /**
5692
+ * cognium-dev#272 — a validating predicate followed by an EARLY RETURN is a
5693
+ * guard, and the value reaching the sink afterwards is already validated.
5694
+ *
5695
+ * if (Array.IndexOf(Allowed, u.Host) < 0) return; // host allow-list
5696
+ * await client.GetStringAsync(input); // reported ssrf
5697
+ *
5698
+ * if (!input.All(char.IsLetterOrDigit)) return; // char-class check
5699
+ * doc.SelectSingleNode("/a[@b='" + input + "']"); // reported xpath
5700
+ *
5701
+ * Both safe mirrors are clean of their *family* finding after #272's earlier
5702
+ * fixes, and both still emit a finding because the guard itself is not
5703
+ * credited. The shape is shared with #286-B and with the Java `Set.contains`
5704
+ * guard in #293, so the recogniser is written around the PREDICATE rather than
5705
+ * around any one sink family.
5706
+ *
5707
+ * Deliberately limited to predicates that are self-evidently validating:
5708
+ * allow-list membership and character-class validation. A user-defined
5709
+ * boolean helper (`if (!IsAllowed(input)) return;`, #286-B) is NOT credited
5710
+ * here — crediting an arbitrary predicate by name would silence a real flow
5711
+ * whenever the helper does not actually validate, which is a false negative no
5712
+ * test would catch. That half needs its own evidence.
5713
+ *
5714
+ * Only the REJECTING direction counts, via `guardRejects`: a guard whose body
5715
+ * falls through has not stopped anything.
5716
+ */
5717
+ function findCSharpEarlyReturnGuardSanitizers(code, language) {
5718
+ if (language !== 'csharp')
5719
+ return [];
5720
+ const sanitizers = [];
5721
+ const lines = code.split('\n');
5722
+ const terminatorRe = /\b(throw|return)\b/;
5723
+ // Allow-list membership, rejecting form:
5724
+ // if (Array.IndexOf(Allowed, x) < 0) return;
5725
+ // if (!Allowed.Contains(x)) return;
5726
+ const allowlistRes = [
5727
+ /\bif\s*\(\s*Array\s*\.\s*IndexOf\s*\([^,]+,\s*([A-Za-z_][\w.]*)\s*\)\s*<\s*0\s*\)/,
5728
+ /\bif\s*\(\s*!\s*[A-Za-z_][\w.]*\s*\.\s*Contains\s*\(\s*([A-Za-z_][\w.]*)\s*\)\s*\)/,
5729
+ ];
5730
+ // Character-class validation, rejecting form:
5731
+ // if (!input.All(char.IsLetterOrDigit)) return;
5732
+ const charClassRe = /\bif\s*\(\s*!\s*([A-Za-z_][\w.]*)\s*\.\s*All\s*\(\s*char\s*\.\s*Is[A-Za-z]+\s*\)\s*\)/;
5733
+ for (let i = 0; i < lines.length; i++) {
5734
+ let guardedExpr = null;
5735
+ for (const re of allowlistRes) {
5736
+ const m = re.exec(lines[i]);
5737
+ if (m) {
5738
+ guardedExpr = m[1];
5739
+ break;
5740
+ }
5741
+ }
5742
+ if (!guardedExpr) {
5743
+ const m = charClassRe.exec(lines[i]);
5744
+ if (m)
5745
+ guardedExpr = m[1];
5746
+ }
5747
+ if (!guardedExpr)
5748
+ continue;
5749
+ if (!guardRejects(lines, i, terminatorRe, 6))
5750
+ continue;
5751
+ // The guarded expression may be a derivation of the tainted value
5752
+ // (`u.Host` guards `u`, and `u` came from `input`), so credit the root
5753
+ // identifier as well as the full expression.
5754
+ const root = guardedExpr.split('.')[0];
5755
+ const guarded = new Set([guardedExpr, root]);
5756
+ const assignRe = /^\s*(?:(?:var|string)\s+)?([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/;
5757
+ // Walk BACKWARD first: the guard often validates a DERIVATION of the
5758
+ // tainted value while the sink uses the original.
5759
+ //
5760
+ // var u = new Uri(input);
5761
+ // if (Array.IndexOf(Allowed, u.Host) < 0) return; // guards u.Host
5762
+ // await client.GetStringAsync(input); // uses input
5763
+ //
5764
+ // Allow-listing the host of the URI built from `input` constrains where
5765
+ // `input` can point, so the original is guarded too. Bounded to the
5766
+ // identifiers that actually fed the guarded root, transitively.
5767
+ for (let b = i - 1; b >= 0; b--) {
5768
+ const a = assignRe.exec(lines[b]);
5769
+ if (!a || !guarded.has(a[1]))
5770
+ continue;
5771
+ for (const id of a[2].match(/[A-Za-z_]\w*/g) ?? [])
5772
+ guarded.add(id);
5773
+ }
5774
+ // Anything assigned FROM a guarded value downstream is guarded too.
5775
+ const mentions = (t) => {
5776
+ for (const n of guarded)
5777
+ if (new RegExp(`\\b${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(t))
5778
+ return true;
5779
+ return false;
5780
+ };
5781
+ for (let l = i + 1; l < lines.length; l++) {
5782
+ const a = assignRe.exec(lines[l]);
5783
+ if (a && mentions(a[2]))
5784
+ guarded.add(a[1]);
5785
+ if (mentions(lines[l])) {
5786
+ sanitizers.push({
5787
+ type: 'csharp_early_return_guard',
5788
+ method: 'guard',
5789
+ line: l + 1,
5790
+ sanitizes: ['ssrf', 'xpath_injection', 'ldap_injection', 'path_traversal',
5791
+ 'command_injection', 'sql_injection', 'external_taint_escape'],
5792
+ });
5793
+ }
5794
+ }
5795
+ }
5796
+ return sanitizers;
5797
+ }
5644
5798
  function findCSharpFullPathStartsWithGuardSanitizers(code) {
5645
5799
  const sanitizers = [];
5646
5800
  const lines = code.split('\n');