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.
- package/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +23 -3
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/findings.d.ts.map +1 -1
- package/dist/analysis/findings.js +154 -0
- package/dist/analysis/findings.js.map +1 -1
- package/dist/analysis/interprocedural.d.ts.map +1 -1
- package/dist/analysis/interprocedural.js +63 -0
- package/dist/analysis/interprocedural.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +156 -4
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.js +97 -4
- package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
- package/dist/analysis/passes/variable-shadowing-pass.d.ts.map +1 -1
- package/dist/analysis/passes/variable-shadowing-pass.js +15 -0
- package/dist/analysis/passes/variable-shadowing-pass.js.map +1 -1
- package/dist/analysis/taint-matcher.d.ts.map +1 -1
- package/dist/analysis/taint-matcher.js +21 -1
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/analyzer.d.ts +36 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +30 -0
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +360 -13
- package/dist/core/circle-ir-core.cjs +94 -5
- package/dist/core/circle-ir-core.js +94 -5
- package/dist/core/extractors/calls.js +127 -1
- package/dist/core/extractors/calls.js.map +1 -1
- package/dist/types/index.d.ts +17 -0
- package/dist/types/index.d.ts.map +1 -1
- 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
|
-
|
|
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
|
|
1573
|
+
const formRe = new RegExp(formFileDeclRe.source, 'g');
|
|
1567
1574
|
let d;
|
|
1568
|
-
while ((d =
|
|
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');
|