circle-ir 3.136.0 → 3.137.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.
- package/dist/analysis/passes/language-sources-pass.d.ts +48 -0
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +683 -0
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/browser/circle-ir.js +531 -0
- package/package.json +1 -1
|
@@ -228,6 +228,11 @@ export class LanguageSourcesPass {
|
|
|
228
228
|
for (const finding of findGoMongoNosqlInjectionFindings(code, graph.ir.meta.file)) {
|
|
229
229
|
ctx.addFinding(finding);
|
|
230
230
|
}
|
|
231
|
+
// Sprint 87 (#189): Go ldap_injection — go-ldap NewSearchRequest with
|
|
232
|
+
// a tainted filter argument (slot 7).
|
|
233
|
+
for (const finding of findGoLdapInjectionFindings(code, graph.ir.meta.file)) {
|
|
234
|
+
ctx.addFinding(finding);
|
|
235
|
+
}
|
|
231
236
|
}
|
|
232
237
|
// -- Python: safe-handler sanitizer detectors (cognium-dev #114 Sprint 31) --
|
|
233
238
|
if (language === 'python') {
|
|
@@ -320,6 +325,16 @@ export class LanguageSourcesPass {
|
|
|
320
325
|
for (const finding of findRustEvalCrateCodeInjectionFindings(code, graph.ir.meta.file)) {
|
|
321
326
|
ctx.addFinding(finding);
|
|
322
327
|
}
|
|
328
|
+
// Sprint 87 (#189): Rust ldap_injection — ldap3 LdapConn::search with
|
|
329
|
+
// tainted filter argument (slot 3).
|
|
330
|
+
for (const finding of findRustLdapInjectionFindings(code, graph.ir.meta.file)) {
|
|
331
|
+
ctx.addFinding(finding);
|
|
332
|
+
}
|
|
333
|
+
// Sprint 87 (#189): Rust log_injection — log crate macros
|
|
334
|
+
// (info!/warn!/error!/debug!/trace!) with tainted format args.
|
|
335
|
+
for (const finding of findRustLogInjectionFindings(code, graph.ir.meta.file)) {
|
|
336
|
+
ctx.addFinding(finding);
|
|
337
|
+
}
|
|
323
338
|
}
|
|
324
339
|
// -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
|
|
325
340
|
// sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
|
|
@@ -363,6 +378,11 @@ export class LanguageSourcesPass {
|
|
|
363
378
|
for (const finding of findJsUtilFormatFormatStringFindings(code, graph.ir.meta.file)) {
|
|
364
379
|
ctx.addFinding(finding);
|
|
365
380
|
}
|
|
381
|
+
// Sprint 87 (#189): JS/TS ldap_injection — ldapjs/ldapts
|
|
382
|
+
// client.search(base, { filter: <tainted>, ... }).
|
|
383
|
+
for (const finding of findJsLdapInjectionFindings(code, graph.ir.meta.file)) {
|
|
384
|
+
ctx.addFinding(finding);
|
|
385
|
+
}
|
|
366
386
|
}
|
|
367
387
|
// -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
|
|
368
388
|
// fromJson recognized as ETE terminator (does not affect
|
|
@@ -6448,4 +6468,667 @@ export function findPythonHeaderCrlfInjectionFindings(code, file) {
|
|
|
6448
6468
|
}
|
|
6449
6469
|
return findings;
|
|
6450
6470
|
}
|
|
6471
|
+
/**
|
|
6472
|
+
* Sprint 87 detector A (#189) — JavaScript / TypeScript LDAP injection
|
|
6473
|
+
* via ldapjs / ldapts `client.search(base, { filter: <tainted>, ... })`.
|
|
6474
|
+
*
|
|
6475
|
+
* ldapjs and ldapts share the same call shape: `client.search(base, opts,
|
|
6476
|
+
* cb)` where `opts.filter` is the LDAP filter string. When an attacker
|
|
6477
|
+
* controls the filter content, they can break out of the intended filter
|
|
6478
|
+
* (e.g. `*)(uid=*` injection) to enumerate the directory. CWE-90.
|
|
6479
|
+
*
|
|
6480
|
+
* The detector tracks taint propagation from Express request extractors
|
|
6481
|
+
* (`req.query.X`, `req.body.X`, `req.params.X`, `req.headers.X`,
|
|
6482
|
+
* `req.cookies.X`) through both `let|const|var` assignments and
|
|
6483
|
+
* template-literal / `+`-concat construction of the filter string.
|
|
6484
|
+
*/
|
|
6485
|
+
export function findJsLdapInjectionFindings(code, file) {
|
|
6486
|
+
const findings = [];
|
|
6487
|
+
if (typeof code !== 'string' || code.length === 0)
|
|
6488
|
+
return findings;
|
|
6489
|
+
// Gate: must reference an ldapjs/ldapts-shaped `.search(...)` call AND
|
|
6490
|
+
// an Express-shaped request extractor.
|
|
6491
|
+
if (!/\.\s*search\s*\(/.test(code))
|
|
6492
|
+
return findings;
|
|
6493
|
+
if (!/\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/.test(code)) {
|
|
6494
|
+
return findings;
|
|
6495
|
+
}
|
|
6496
|
+
// Soft library gate — only fire when ldapjs or ldapts is in the file.
|
|
6497
|
+
if (!/\b(?:ldapjs|ldapts|require\(['"]ldapjs['"]\)|from\s+['"]ldapts['"])\b/.test(code)) {
|
|
6498
|
+
return findings;
|
|
6499
|
+
}
|
|
6500
|
+
const lines = code.split('\n');
|
|
6501
|
+
const reqExtractRe = /\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/;
|
|
6502
|
+
const taintedVars = new Set();
|
|
6503
|
+
// 3-pass whole-file taint propagation across `(const|let|var) x = expr`.
|
|
6504
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
6505
|
+
const before = taintedVars.size;
|
|
6506
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6507
|
+
const t = lines[i].trim();
|
|
6508
|
+
if (t.startsWith('//'))
|
|
6509
|
+
continue;
|
|
6510
|
+
const m = t.match(/^(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(.+?);?$/);
|
|
6511
|
+
if (!m)
|
|
6512
|
+
continue;
|
|
6513
|
+
const lhs = m[1];
|
|
6514
|
+
const rhs = m[2];
|
|
6515
|
+
if (taintedVars.has(lhs))
|
|
6516
|
+
continue;
|
|
6517
|
+
if (reqExtractRe.test(rhs)) {
|
|
6518
|
+
taintedVars.add(lhs);
|
|
6519
|
+
continue;
|
|
6520
|
+
}
|
|
6521
|
+
for (const v of taintedVars) {
|
|
6522
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
6523
|
+
taintedVars.add(lhs);
|
|
6524
|
+
break;
|
|
6525
|
+
}
|
|
6526
|
+
}
|
|
6527
|
+
}
|
|
6528
|
+
if (taintedVars.size === before)
|
|
6529
|
+
break;
|
|
6530
|
+
}
|
|
6531
|
+
if (taintedVars.size === 0)
|
|
6532
|
+
return findings;
|
|
6533
|
+
// Find lines containing `filter: <expr>` (object property) where the
|
|
6534
|
+
// expression resolves to a tainted symbol, plus the shorthand
|
|
6535
|
+
// `{ filter, ... }` / `{ ..., filter }` form (ES6 property shorthand
|
|
6536
|
+
// means `{ filter }` is equivalent to `{ filter: filter }`).
|
|
6537
|
+
const filterPropRe = /\bfilter\s*:\s*([^,}\n]+)/;
|
|
6538
|
+
const filterShorthandRe = /(?:^|[\{,])\s*filter\s*[,}]/;
|
|
6539
|
+
const seen = new Set();
|
|
6540
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6541
|
+
const line = lines[i];
|
|
6542
|
+
const fm = line.match(filterPropRe);
|
|
6543
|
+
let expr = null;
|
|
6544
|
+
if (fm) {
|
|
6545
|
+
expr = fm[1].trim();
|
|
6546
|
+
}
|
|
6547
|
+
else if (filterShorthandRe.test(line)) {
|
|
6548
|
+
// Shorthand: value is the local symbol named "filter".
|
|
6549
|
+
expr = 'filter';
|
|
6550
|
+
}
|
|
6551
|
+
else {
|
|
6552
|
+
continue;
|
|
6553
|
+
}
|
|
6554
|
+
// Tainted if any tainted var appears in the expression.
|
|
6555
|
+
let tainted = false;
|
|
6556
|
+
for (const v of taintedVars) {
|
|
6557
|
+
if (new RegExp(`\\b${v}\\b`).test(expr)) {
|
|
6558
|
+
tainted = true;
|
|
6559
|
+
break;
|
|
6560
|
+
}
|
|
6561
|
+
}
|
|
6562
|
+
if (!tainted)
|
|
6563
|
+
continue;
|
|
6564
|
+
if (seen.has(i + 1))
|
|
6565
|
+
continue;
|
|
6566
|
+
seen.add(i + 1);
|
|
6567
|
+
findings.push({
|
|
6568
|
+
id: `ldap_injection-${file}-${i + 1}-js-ldap-filter`,
|
|
6569
|
+
pass: 'language-sources',
|
|
6570
|
+
category: 'security',
|
|
6571
|
+
rule_id: 'ldap_injection',
|
|
6572
|
+
cwe: 'CWE-90',
|
|
6573
|
+
severity: 'critical',
|
|
6574
|
+
level: 'error',
|
|
6575
|
+
message: 'LDAP injection: ldapjs/ldapts `client.search(..., { filter: ' +
|
|
6576
|
+
'<tainted>, ... })` lets the attacker break out of the filter ' +
|
|
6577
|
+
'expression (e.g. `*)(uid=*`) and enumerate the directory. ' +
|
|
6578
|
+
'Escape the user input with a tight allowlist (`[A-Za-z0-9_-]+`) ' +
|
|
6579
|
+
'or use a structured filter builder.',
|
|
6580
|
+
file,
|
|
6581
|
+
line: i + 1,
|
|
6582
|
+
snippet: line.trim(),
|
|
6583
|
+
});
|
|
6584
|
+
}
|
|
6585
|
+
return findings;
|
|
6586
|
+
}
|
|
6587
|
+
/**
|
|
6588
|
+
* Sprint 87 detector B (#189) — Go LDAP injection via go-ldap/ldap.v3
|
|
6589
|
+
* `ldap.NewSearchRequest(base, scope, deref, sizeLimit, timeLimit,
|
|
6590
|
+
* typesOnly, <tainted-filter>, attributes, controls)`.
|
|
6591
|
+
*
|
|
6592
|
+
* The 7th positional argument is the LDAP filter. The detector tracks
|
|
6593
|
+
* tainted strings derived from `r.URL.Query().Get(...)` / `r.Form.Get`
|
|
6594
|
+
* / `r.PostForm.Get` / `r.FormValue` / `r.PostFormValue` through
|
|
6595
|
+
* `:=`/`=` assignments and `fmt.Sprintf` calls, then fires when the
|
|
6596
|
+
* filter slot resolves to a tainted symbol. CWE-90.
|
|
6597
|
+
*/
|
|
6598
|
+
export function findGoLdapInjectionFindings(code, file) {
|
|
6599
|
+
const findings = [];
|
|
6600
|
+
if (typeof code !== 'string' || code.length === 0)
|
|
6601
|
+
return findings;
|
|
6602
|
+
if (!/\bldap\s*\.\s*NewSearchRequest\s*\(/.test(code))
|
|
6603
|
+
return findings;
|
|
6604
|
+
if (!/\br\s*\.\s*(?:URL\s*\.\s*Query\s*\(\s*\)|Form|PostForm|FormValue|PostFormValue|Header)/.test(code)) {
|
|
6605
|
+
return findings;
|
|
6606
|
+
}
|
|
6607
|
+
const lines = code.split('\n');
|
|
6608
|
+
const reqExtractRe = /\br\s*\.\s*(?:URL\s*\.\s*Query\s*\(\s*\)\s*\.\s*Get|Form\s*\.\s*Get|PostForm\s*\.\s*Get|FormValue|PostFormValue|Header\s*\.\s*Get)\s*\(/;
|
|
6609
|
+
const taintedVars = new Set();
|
|
6610
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
6611
|
+
const before = taintedVars.size;
|
|
6612
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6613
|
+
const t = lines[i].trim();
|
|
6614
|
+
if (t.startsWith('//'))
|
|
6615
|
+
continue;
|
|
6616
|
+
// `name := expr` or `name = expr`
|
|
6617
|
+
const m = t.match(/^(\w+)\s*(?::=|=)\s*(.+?)$/);
|
|
6618
|
+
if (!m)
|
|
6619
|
+
continue;
|
|
6620
|
+
const lhs = m[1];
|
|
6621
|
+
const rhs = m[2];
|
|
6622
|
+
if (taintedVars.has(lhs))
|
|
6623
|
+
continue;
|
|
6624
|
+
if (reqExtractRe.test(rhs)) {
|
|
6625
|
+
taintedVars.add(lhs);
|
|
6626
|
+
continue;
|
|
6627
|
+
}
|
|
6628
|
+
for (const v of taintedVars) {
|
|
6629
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
6630
|
+
taintedVars.add(lhs);
|
|
6631
|
+
break;
|
|
6632
|
+
}
|
|
6633
|
+
}
|
|
6634
|
+
}
|
|
6635
|
+
if (taintedVars.size === before)
|
|
6636
|
+
break;
|
|
6637
|
+
}
|
|
6638
|
+
if (taintedVars.size === 0)
|
|
6639
|
+
return findings;
|
|
6640
|
+
// Multiline-tolerant scan: NewSearchRequest call may span several
|
|
6641
|
+
// lines. Find each opener and collect its full argument span.
|
|
6642
|
+
const seen = new Set();
|
|
6643
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6644
|
+
const openIdx = lines[i].indexOf('NewSearchRequest(');
|
|
6645
|
+
if (openIdx === -1)
|
|
6646
|
+
continue;
|
|
6647
|
+
// Aggregate text from `i` until we balance the parens.
|
|
6648
|
+
let depth = 0;
|
|
6649
|
+
let buf = '';
|
|
6650
|
+
let endLine = i;
|
|
6651
|
+
let started = false;
|
|
6652
|
+
for (let j = i; j < Math.min(i + 25, lines.length); j++) {
|
|
6653
|
+
for (const ch of lines[j]) {
|
|
6654
|
+
if (ch === '(') {
|
|
6655
|
+
depth++;
|
|
6656
|
+
started = true;
|
|
6657
|
+
}
|
|
6658
|
+
else if (ch === ')') {
|
|
6659
|
+
depth--;
|
|
6660
|
+
}
|
|
6661
|
+
buf += ch;
|
|
6662
|
+
if (started && depth === 0) {
|
|
6663
|
+
endLine = j;
|
|
6664
|
+
break;
|
|
6665
|
+
}
|
|
6666
|
+
}
|
|
6667
|
+
if (started && depth === 0)
|
|
6668
|
+
break;
|
|
6669
|
+
buf += '\n';
|
|
6670
|
+
}
|
|
6671
|
+
// buf now contains "NewSearchRequest(...)". Strip prefix.
|
|
6672
|
+
const argsStart = buf.indexOf('(');
|
|
6673
|
+
if (argsStart === -1)
|
|
6674
|
+
continue;
|
|
6675
|
+
const args = buf.substring(argsStart + 1, buf.length - 1);
|
|
6676
|
+
// Top-level split on commas (string-literal aware).
|
|
6677
|
+
const parts = [];
|
|
6678
|
+
{
|
|
6679
|
+
let d = 0;
|
|
6680
|
+
let b = '';
|
|
6681
|
+
let inStr = null;
|
|
6682
|
+
for (let k = 0; k < args.length; k++) {
|
|
6683
|
+
const ch = args[k];
|
|
6684
|
+
if (inStr) {
|
|
6685
|
+
if (ch === '\\') {
|
|
6686
|
+
b += ch + (args[k + 1] ?? '');
|
|
6687
|
+
k++;
|
|
6688
|
+
continue;
|
|
6689
|
+
}
|
|
6690
|
+
if (ch === inStr)
|
|
6691
|
+
inStr = null;
|
|
6692
|
+
b += ch;
|
|
6693
|
+
continue;
|
|
6694
|
+
}
|
|
6695
|
+
if (ch === '"' || ch === '`') {
|
|
6696
|
+
inStr = ch;
|
|
6697
|
+
b += ch;
|
|
6698
|
+
continue;
|
|
6699
|
+
}
|
|
6700
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
6701
|
+
d++;
|
|
6702
|
+
else if (ch === ')' || ch === ']' || ch === '}')
|
|
6703
|
+
d--;
|
|
6704
|
+
if (ch === ',' && d === 0) {
|
|
6705
|
+
parts.push(b);
|
|
6706
|
+
b = '';
|
|
6707
|
+
continue;
|
|
6708
|
+
}
|
|
6709
|
+
b += ch;
|
|
6710
|
+
}
|
|
6711
|
+
if (b.trim().length > 0)
|
|
6712
|
+
parts.push(b);
|
|
6713
|
+
}
|
|
6714
|
+
if (parts.length < 7)
|
|
6715
|
+
continue;
|
|
6716
|
+
const filterExpr = parts[6].trim();
|
|
6717
|
+
let tainted = false;
|
|
6718
|
+
for (const v of taintedVars) {
|
|
6719
|
+
if (new RegExp(`\\b${v}\\b`).test(filterExpr)) {
|
|
6720
|
+
tainted = true;
|
|
6721
|
+
break;
|
|
6722
|
+
}
|
|
6723
|
+
}
|
|
6724
|
+
if (!tainted)
|
|
6725
|
+
continue;
|
|
6726
|
+
if (seen.has(i + 1))
|
|
6727
|
+
continue;
|
|
6728
|
+
seen.add(i + 1);
|
|
6729
|
+
findings.push({
|
|
6730
|
+
id: `ldap_injection-${file}-${i + 1}-go-newsearchrequest`,
|
|
6731
|
+
pass: 'language-sources',
|
|
6732
|
+
category: 'security',
|
|
6733
|
+
rule_id: 'ldap_injection',
|
|
6734
|
+
cwe: 'CWE-90',
|
|
6735
|
+
severity: 'critical',
|
|
6736
|
+
level: 'error',
|
|
6737
|
+
message: 'LDAP injection: go-ldap `ldap.NewSearchRequest(..., <tainted ' +
|
|
6738
|
+
'filter>, ...)` lets the attacker break out of the filter ' +
|
|
6739
|
+
'expression and enumerate the directory. Escape the user input ' +
|
|
6740
|
+
'with `ldap.EscapeFilter(...)` or use a structured filter ' +
|
|
6741
|
+
'builder.',
|
|
6742
|
+
file,
|
|
6743
|
+
line: i + 1,
|
|
6744
|
+
snippet: (lines[i] + (endLine > i ? ' …' : '')).trim(),
|
|
6745
|
+
});
|
|
6746
|
+
}
|
|
6747
|
+
return findings;
|
|
6748
|
+
}
|
|
6749
|
+
/**
|
|
6750
|
+
* Sprint 87 detector C (#189) — Rust LDAP injection via ldap3
|
|
6751
|
+
* `LdapConn::search(base, scope, &<tainted-filter>, attrs)` (and the
|
|
6752
|
+
* async `Ldap::search(...)` mirror).
|
|
6753
|
+
*
|
|
6754
|
+
* The 3rd positional argument is the LDAP filter. Tainted strings come
|
|
6755
|
+
* from actix-web `web::Query<HashMap<String, String>>` / `web::Path` /
|
|
6756
|
+
* `web::Form` / `web::Json` parameter types (extractor handlers) and
|
|
6757
|
+
* propagate through `let` bindings and `format!(...)` macros. CWE-90.
|
|
6758
|
+
*/
|
|
6759
|
+
export function findRustLdapInjectionFindings(code, file) {
|
|
6760
|
+
const findings = [];
|
|
6761
|
+
if (typeof code !== 'string' || code.length === 0)
|
|
6762
|
+
return findings;
|
|
6763
|
+
if (!/\.\s*search\s*\(/.test(code))
|
|
6764
|
+
return findings;
|
|
6765
|
+
if (!/\b(?:ldap3|LdapConn|Ldap)\b/.test(code))
|
|
6766
|
+
return findings;
|
|
6767
|
+
const lines = code.split('\n');
|
|
6768
|
+
const extractorTypeRe = /:\s*(?:String|Bytes|bytes::Bytes|axum::body::Bytes|web::Query\b|web::Path\b|web::Form\b|web::Json\b|HttpRequest\b|actix_web::HttpRequest\b)/;
|
|
6769
|
+
const fns = [];
|
|
6770
|
+
let cur = null;
|
|
6771
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6772
|
+
const t = lines[i];
|
|
6773
|
+
if (/^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+\s*\(/.test(t)) {
|
|
6774
|
+
if (cur) {
|
|
6775
|
+
cur.end = i - 1;
|
|
6776
|
+
fns.push(cur);
|
|
6777
|
+
}
|
|
6778
|
+
cur = { start: i, end: lines.length - 1, tainted: new Set() };
|
|
6779
|
+
// Assemble multi-line header.
|
|
6780
|
+
let headerJoined = '';
|
|
6781
|
+
for (let j = i; j < Math.min(i + 12, lines.length); j++) {
|
|
6782
|
+
headerJoined += lines[j];
|
|
6783
|
+
if (/\{\s*$/.test(lines[j]))
|
|
6784
|
+
break;
|
|
6785
|
+
}
|
|
6786
|
+
const open = headerJoined.indexOf('(');
|
|
6787
|
+
const close = headerJoined.lastIndexOf(')');
|
|
6788
|
+
if (open !== -1 && close > open) {
|
|
6789
|
+
const params = headerJoined.substring(open + 1, close);
|
|
6790
|
+
let depth = 0;
|
|
6791
|
+
let buf = '';
|
|
6792
|
+
const parts = [];
|
|
6793
|
+
for (const ch of params) {
|
|
6794
|
+
if (ch === '<' || ch === '(')
|
|
6795
|
+
depth++;
|
|
6796
|
+
else if (ch === '>' || ch === ')')
|
|
6797
|
+
depth--;
|
|
6798
|
+
if (ch === ',' && depth === 0) {
|
|
6799
|
+
parts.push(buf);
|
|
6800
|
+
buf = '';
|
|
6801
|
+
continue;
|
|
6802
|
+
}
|
|
6803
|
+
buf += ch;
|
|
6804
|
+
}
|
|
6805
|
+
if (buf.trim().length > 0)
|
|
6806
|
+
parts.push(buf);
|
|
6807
|
+
for (const p of parts) {
|
|
6808
|
+
const pm = p.match(/(?:mut\s+)?(\w+)\s*:/);
|
|
6809
|
+
if (!pm)
|
|
6810
|
+
continue;
|
|
6811
|
+
if (extractorTypeRe.test(p))
|
|
6812
|
+
cur.tainted.add(pm[1]);
|
|
6813
|
+
}
|
|
6814
|
+
}
|
|
6815
|
+
}
|
|
6816
|
+
}
|
|
6817
|
+
if (cur)
|
|
6818
|
+
fns.push(cur);
|
|
6819
|
+
// Propagate taint through `let` bindings.
|
|
6820
|
+
for (const fn of fns) {
|
|
6821
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
6822
|
+
const before = fn.tainted.size;
|
|
6823
|
+
for (let i = fn.start; i <= fn.end; i++) {
|
|
6824
|
+
const t = lines[i].trim();
|
|
6825
|
+
const m = t.match(/^let\s+(?:mut\s+)?(\w+)\s*(?::\s*[^=]+)?=\s*(.+?);?$/);
|
|
6826
|
+
if (!m)
|
|
6827
|
+
continue;
|
|
6828
|
+
const lhs = m[1];
|
|
6829
|
+
const rhs = m[2];
|
|
6830
|
+
if (fn.tainted.has(lhs))
|
|
6831
|
+
continue;
|
|
6832
|
+
for (const v of fn.tainted) {
|
|
6833
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
6834
|
+
fn.tainted.add(lhs);
|
|
6835
|
+
break;
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6838
|
+
}
|
|
6839
|
+
if (fn.tainted.size === before)
|
|
6840
|
+
break;
|
|
6841
|
+
}
|
|
6842
|
+
}
|
|
6843
|
+
// Multiline-tolerant `.search(` call walker.
|
|
6844
|
+
const seen = new Set();
|
|
6845
|
+
for (const fn of fns) {
|
|
6846
|
+
if (fn.tainted.size === 0)
|
|
6847
|
+
continue;
|
|
6848
|
+
for (let i = fn.start; i <= fn.end; i++) {
|
|
6849
|
+
const searchIdx = lines[i].indexOf('.search(');
|
|
6850
|
+
if (searchIdx === -1)
|
|
6851
|
+
continue;
|
|
6852
|
+
// Assemble args span.
|
|
6853
|
+
let depth = 0;
|
|
6854
|
+
let buf = '';
|
|
6855
|
+
let started = false;
|
|
6856
|
+
for (let j = i; j < Math.min(i + 15, lines.length); j++) {
|
|
6857
|
+
const startCol = j === i ? searchIdx : 0;
|
|
6858
|
+
for (let k = startCol; k < lines[j].length; k++) {
|
|
6859
|
+
const ch = lines[j][k];
|
|
6860
|
+
if (ch === '(') {
|
|
6861
|
+
depth++;
|
|
6862
|
+
started = true;
|
|
6863
|
+
}
|
|
6864
|
+
else if (ch === ')') {
|
|
6865
|
+
depth--;
|
|
6866
|
+
}
|
|
6867
|
+
buf += ch;
|
|
6868
|
+
if (started && depth === 0)
|
|
6869
|
+
break;
|
|
6870
|
+
}
|
|
6871
|
+
if (started && depth === 0)
|
|
6872
|
+
break;
|
|
6873
|
+
buf += '\n';
|
|
6874
|
+
}
|
|
6875
|
+
const argsStart = buf.indexOf('(');
|
|
6876
|
+
if (argsStart === -1)
|
|
6877
|
+
continue;
|
|
6878
|
+
const args = buf.substring(argsStart + 1, buf.length - 1);
|
|
6879
|
+
// String-literal aware top-level comma split.
|
|
6880
|
+
const parts = [];
|
|
6881
|
+
{
|
|
6882
|
+
let d = 0;
|
|
6883
|
+
let b = '';
|
|
6884
|
+
let inStr = null;
|
|
6885
|
+
for (let k = 0; k < args.length; k++) {
|
|
6886
|
+
const ch = args[k];
|
|
6887
|
+
if (inStr) {
|
|
6888
|
+
if (ch === '\\') {
|
|
6889
|
+
b += ch + (args[k + 1] ?? '');
|
|
6890
|
+
k++;
|
|
6891
|
+
continue;
|
|
6892
|
+
}
|
|
6893
|
+
if (ch === inStr)
|
|
6894
|
+
inStr = null;
|
|
6895
|
+
b += ch;
|
|
6896
|
+
continue;
|
|
6897
|
+
}
|
|
6898
|
+
if (ch === '"') {
|
|
6899
|
+
inStr = ch;
|
|
6900
|
+
b += ch;
|
|
6901
|
+
continue;
|
|
6902
|
+
}
|
|
6903
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
6904
|
+
d++;
|
|
6905
|
+
else if (ch === ')' || ch === ']' || ch === '}')
|
|
6906
|
+
d--;
|
|
6907
|
+
if (ch === ',' && d === 0) {
|
|
6908
|
+
parts.push(b);
|
|
6909
|
+
b = '';
|
|
6910
|
+
continue;
|
|
6911
|
+
}
|
|
6912
|
+
b += ch;
|
|
6913
|
+
}
|
|
6914
|
+
if (b.trim().length > 0)
|
|
6915
|
+
parts.push(b);
|
|
6916
|
+
}
|
|
6917
|
+
// ldap3 search signature: search(base, scope, filter, attrs)
|
|
6918
|
+
if (parts.length < 3)
|
|
6919
|
+
continue;
|
|
6920
|
+
const filterExpr = parts[2].trim();
|
|
6921
|
+
let tainted = false;
|
|
6922
|
+
for (const v of fn.tainted) {
|
|
6923
|
+
if (new RegExp(`\\b${v}\\b`).test(filterExpr)) {
|
|
6924
|
+
tainted = true;
|
|
6925
|
+
break;
|
|
6926
|
+
}
|
|
6927
|
+
}
|
|
6928
|
+
if (!tainted)
|
|
6929
|
+
continue;
|
|
6930
|
+
if (seen.has(i + 1))
|
|
6931
|
+
continue;
|
|
6932
|
+
seen.add(i + 1);
|
|
6933
|
+
findings.push({
|
|
6934
|
+
id: `ldap_injection-${file}-${i + 1}-rust-ldap3-search`,
|
|
6935
|
+
pass: 'language-sources',
|
|
6936
|
+
category: 'security',
|
|
6937
|
+
rule_id: 'ldap_injection',
|
|
6938
|
+
cwe: 'CWE-90',
|
|
6939
|
+
severity: 'critical',
|
|
6940
|
+
level: 'error',
|
|
6941
|
+
message: 'LDAP injection: ldap3 `LdapConn::search(..., &<tainted ' +
|
|
6942
|
+
'filter>, ...)` lets the attacker break out of the filter ' +
|
|
6943
|
+
'expression and enumerate the directory. Escape the user input ' +
|
|
6944
|
+
'or use a structured filter builder.',
|
|
6945
|
+
file,
|
|
6946
|
+
line: i + 1,
|
|
6947
|
+
snippet: lines[i].trim(),
|
|
6948
|
+
});
|
|
6949
|
+
}
|
|
6950
|
+
}
|
|
6951
|
+
return findings;
|
|
6952
|
+
}
|
|
6953
|
+
/**
|
|
6954
|
+
* Sprint 87 detector D (#189) — Rust log injection via the `log` crate
|
|
6955
|
+
* macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) where a
|
|
6956
|
+
* tainted value is interpolated into the format args.
|
|
6957
|
+
*
|
|
6958
|
+
* Unsanitized CRLF in log lines can split log entries, forge
|
|
6959
|
+
* authentication events, or escape into log-aggregation pipelines that
|
|
6960
|
+
* parse newlines as record boundaries. CWE-117.
|
|
6961
|
+
*/
|
|
6962
|
+
export function findRustLogInjectionFindings(code, file) {
|
|
6963
|
+
const findings = [];
|
|
6964
|
+
if (typeof code !== 'string' || code.length === 0)
|
|
6965
|
+
return findings;
|
|
6966
|
+
if (!/\b(?:info|warn|error|debug|trace)\s*!\s*\(/.test(code))
|
|
6967
|
+
return findings;
|
|
6968
|
+
if (!/\b(?:log|tracing)\b/.test(code))
|
|
6969
|
+
return findings;
|
|
6970
|
+
const lines = code.split('\n');
|
|
6971
|
+
const extractorTypeRe = /:\s*(?:String|Bytes|bytes::Bytes|axum::body::Bytes|web::Query\b|web::Path\b|web::Form\b|web::Json\b|HttpRequest\b|actix_web::HttpRequest\b)/;
|
|
6972
|
+
const fns = [];
|
|
6973
|
+
let cur = null;
|
|
6974
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6975
|
+
const t = lines[i];
|
|
6976
|
+
if (/^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+\s*\(/.test(t)) {
|
|
6977
|
+
if (cur) {
|
|
6978
|
+
cur.end = i - 1;
|
|
6979
|
+
fns.push(cur);
|
|
6980
|
+
}
|
|
6981
|
+
cur = { start: i, end: lines.length - 1, tainted: new Set() };
|
|
6982
|
+
let headerJoined = '';
|
|
6983
|
+
for (let j = i; j < Math.min(i + 12, lines.length); j++) {
|
|
6984
|
+
headerJoined += lines[j];
|
|
6985
|
+
if (/\{\s*$/.test(lines[j]))
|
|
6986
|
+
break;
|
|
6987
|
+
}
|
|
6988
|
+
const open = headerJoined.indexOf('(');
|
|
6989
|
+
const close = headerJoined.lastIndexOf(')');
|
|
6990
|
+
if (open !== -1 && close > open) {
|
|
6991
|
+
const params = headerJoined.substring(open + 1, close);
|
|
6992
|
+
let depth = 0;
|
|
6993
|
+
let buf = '';
|
|
6994
|
+
const parts = [];
|
|
6995
|
+
for (const ch of params) {
|
|
6996
|
+
if (ch === '<' || ch === '(')
|
|
6997
|
+
depth++;
|
|
6998
|
+
else if (ch === '>' || ch === ')')
|
|
6999
|
+
depth--;
|
|
7000
|
+
if (ch === ',' && depth === 0) {
|
|
7001
|
+
parts.push(buf);
|
|
7002
|
+
buf = '';
|
|
7003
|
+
continue;
|
|
7004
|
+
}
|
|
7005
|
+
buf += ch;
|
|
7006
|
+
}
|
|
7007
|
+
if (buf.trim().length > 0)
|
|
7008
|
+
parts.push(buf);
|
|
7009
|
+
for (const p of parts) {
|
|
7010
|
+
const pm = p.match(/(?:mut\s+)?(\w+)\s*:/);
|
|
7011
|
+
if (!pm)
|
|
7012
|
+
continue;
|
|
7013
|
+
if (extractorTypeRe.test(p))
|
|
7014
|
+
cur.tainted.add(pm[1]);
|
|
7015
|
+
}
|
|
7016
|
+
}
|
|
7017
|
+
}
|
|
7018
|
+
}
|
|
7019
|
+
if (cur)
|
|
7020
|
+
fns.push(cur);
|
|
7021
|
+
for (const fn of fns) {
|
|
7022
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
7023
|
+
const before = fn.tainted.size;
|
|
7024
|
+
for (let i = fn.start; i <= fn.end; i++) {
|
|
7025
|
+
const t = lines[i].trim();
|
|
7026
|
+
const m = t.match(/^let\s+(?:mut\s+)?(\w+)\s*(?::\s*[^=]+)?=\s*(.+?);?$/);
|
|
7027
|
+
if (!m)
|
|
7028
|
+
continue;
|
|
7029
|
+
const lhs = m[1];
|
|
7030
|
+
const rhs = m[2];
|
|
7031
|
+
if (fn.tainted.has(lhs))
|
|
7032
|
+
continue;
|
|
7033
|
+
for (const v of fn.tainted) {
|
|
7034
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
7035
|
+
fn.tainted.add(lhs);
|
|
7036
|
+
break;
|
|
7037
|
+
}
|
|
7038
|
+
}
|
|
7039
|
+
}
|
|
7040
|
+
if (fn.tainted.size === before)
|
|
7041
|
+
break;
|
|
7042
|
+
}
|
|
7043
|
+
}
|
|
7044
|
+
const macroRe = /\b(info|warn|error|debug|trace)\s*!\s*\(\s*([^;]+?)\)\s*;?\s*$/;
|
|
7045
|
+
const seen = new Set();
|
|
7046
|
+
for (const fn of fns) {
|
|
7047
|
+
if (fn.tainted.size === 0)
|
|
7048
|
+
continue;
|
|
7049
|
+
for (let i = fn.start; i <= fn.end; i++) {
|
|
7050
|
+
const line = lines[i];
|
|
7051
|
+
const m = line.match(macroRe);
|
|
7052
|
+
if (!m)
|
|
7053
|
+
continue;
|
|
7054
|
+
const macroName = m[1];
|
|
7055
|
+
const argSpan = m[2];
|
|
7056
|
+
// Split on top-level commas to separate the fmt-string from args.
|
|
7057
|
+
const parts = [];
|
|
7058
|
+
{
|
|
7059
|
+
let d = 0;
|
|
7060
|
+
let b = '';
|
|
7061
|
+
let inStr = null;
|
|
7062
|
+
for (let k = 0; k < argSpan.length; k++) {
|
|
7063
|
+
const ch = argSpan[k];
|
|
7064
|
+
if (inStr) {
|
|
7065
|
+
if (ch === '\\') {
|
|
7066
|
+
b += ch + (argSpan[k + 1] ?? '');
|
|
7067
|
+
k++;
|
|
7068
|
+
continue;
|
|
7069
|
+
}
|
|
7070
|
+
if (ch === inStr)
|
|
7071
|
+
inStr = null;
|
|
7072
|
+
b += ch;
|
|
7073
|
+
continue;
|
|
7074
|
+
}
|
|
7075
|
+
if (ch === '"') {
|
|
7076
|
+
inStr = ch;
|
|
7077
|
+
b += ch;
|
|
7078
|
+
continue;
|
|
7079
|
+
}
|
|
7080
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
7081
|
+
d++;
|
|
7082
|
+
else if (ch === ')' || ch === ']' || ch === '}')
|
|
7083
|
+
d--;
|
|
7084
|
+
if (ch === ',' && d === 0) {
|
|
7085
|
+
parts.push(b);
|
|
7086
|
+
b = '';
|
|
7087
|
+
continue;
|
|
7088
|
+
}
|
|
7089
|
+
b += ch;
|
|
7090
|
+
}
|
|
7091
|
+
if (b.trim().length > 0)
|
|
7092
|
+
parts.push(b);
|
|
7093
|
+
}
|
|
7094
|
+
if (parts.length < 2)
|
|
7095
|
+
continue;
|
|
7096
|
+
// Any of the format args (parts[1..]) being tainted = fire.
|
|
7097
|
+
let tainted = false;
|
|
7098
|
+
for (let p = 1; p < parts.length; p++) {
|
|
7099
|
+
for (const v of fn.tainted) {
|
|
7100
|
+
if (new RegExp(`\\b${v}\\b`).test(parts[p])) {
|
|
7101
|
+
tainted = true;
|
|
7102
|
+
break;
|
|
7103
|
+
}
|
|
7104
|
+
}
|
|
7105
|
+
if (tainted)
|
|
7106
|
+
break;
|
|
7107
|
+
}
|
|
7108
|
+
if (!tainted)
|
|
7109
|
+
continue;
|
|
7110
|
+
const key = `${i + 1}:${macroName}`;
|
|
7111
|
+
if (seen.has(key))
|
|
7112
|
+
continue;
|
|
7113
|
+
seen.add(key);
|
|
7114
|
+
findings.push({
|
|
7115
|
+
id: `log_injection-${file}-${i + 1}-rust-${macroName}`,
|
|
7116
|
+
pass: 'language-sources',
|
|
7117
|
+
category: 'security',
|
|
7118
|
+
rule_id: 'log_injection',
|
|
7119
|
+
cwe: 'CWE-117',
|
|
7120
|
+
severity: 'medium',
|
|
7121
|
+
level: 'warning',
|
|
7122
|
+
message: `Log injection: Rust \`${macroName}!(...)\` interpolates a ` +
|
|
7123
|
+
'tainted value into the log line. Unsanitized CRLF lets an ' +
|
|
7124
|
+
'attacker forge log entries or split records. Strip control ' +
|
|
7125
|
+
'characters or use a structured logging API.',
|
|
7126
|
+
file,
|
|
7127
|
+
line: i + 1,
|
|
7128
|
+
snippet: line.trim(),
|
|
7129
|
+
});
|
|
7130
|
+
}
|
|
7131
|
+
}
|
|
7132
|
+
return findings;
|
|
7133
|
+
}
|
|
6451
7134
|
//# sourceMappingURL=language-sources-pass.js.map
|