circle-ir 3.136.0 → 3.138.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.
@@ -228,6 +228,21 @@ 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
+ }
236
+ // Sprint 89 (#189): Go insecure_deserialization — encoding/gob
237
+ // NewDecoder(r.Body).Decode(&v).
238
+ for (const finding of findGoGobDeserializationFindings(code, graph.ir.meta.file)) {
239
+ ctx.addFinding(finding);
240
+ }
241
+ // Sprint 90 (#189): Go XXE — encoding/xml NewDecoder(r.Body) with
242
+ // Strict=false or custom Entity map.
243
+ for (const finding of findGoXmlDecoderXxeFindings(code, graph.ir.meta.file)) {
244
+ ctx.addFinding(finding);
245
+ }
231
246
  }
232
247
  // -- Python: safe-handler sanitizer detectors (cognium-dev #114 Sprint 31) --
233
248
  if (language === 'python') {
@@ -282,6 +297,10 @@ export class LanguageSourcesPass {
282
297
  for (const finding of findPythonHeaderCrlfInjectionFindings(code, graph.ir.meta.file)) {
283
298
  ctx.addFinding(finding);
284
299
  }
300
+ // Sprint 90 (#189): Python SSTI — jinja2.Template(<tainted>).render(...).
301
+ for (const finding of findPythonJinjaTemplateSstiFindings(code, graph.ir.meta.file)) {
302
+ ctx.addFinding(finding);
303
+ }
285
304
  }
286
305
  // -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
287
306
  if (language === 'rust') {
@@ -320,6 +339,16 @@ export class LanguageSourcesPass {
320
339
  for (const finding of findRustEvalCrateCodeInjectionFindings(code, graph.ir.meta.file)) {
321
340
  ctx.addFinding(finding);
322
341
  }
342
+ // Sprint 87 (#189): Rust ldap_injection — ldap3 LdapConn::search with
343
+ // tainted filter argument (slot 3).
344
+ for (const finding of findRustLdapInjectionFindings(code, graph.ir.meta.file)) {
345
+ ctx.addFinding(finding);
346
+ }
347
+ // Sprint 87 (#189): Rust log_injection — log crate macros
348
+ // (info!/warn!/error!/debug!/trace!) with tainted format args.
349
+ for (const finding of findRustLogInjectionFindings(code, graph.ir.meta.file)) {
350
+ ctx.addFinding(finding);
351
+ }
323
352
  }
324
353
  // -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
325
354
  // sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
@@ -363,6 +392,26 @@ export class LanguageSourcesPass {
363
392
  for (const finding of findJsUtilFormatFormatStringFindings(code, graph.ir.meta.file)) {
364
393
  ctx.addFinding(finding);
365
394
  }
395
+ // Sprint 87 (#189): JS/TS ldap_injection — ldapjs/ldapts
396
+ // client.search(base, { filter: <tainted>, ... }).
397
+ for (const finding of findJsLdapInjectionFindings(code, graph.ir.meta.file)) {
398
+ ctx.addFinding(finding);
399
+ }
400
+ // Sprint 89 (#189): JS insecure_deserialization —
401
+ // JSON.parse(req.body) on raw / text bodies.
402
+ for (const finding of findJsJsonParseBodyFindings(code, graph.ir.meta.file)) {
403
+ ctx.addFinding(finding);
404
+ }
405
+ // Sprint 89 (#189): JS xpath_injection — DOM
406
+ // document.evaluate(<tainted>, ...).
407
+ for (const finding of findJsDomXpathInjectionFindings(code, graph.ir.meta.file)) {
408
+ ctx.addFinding(finding);
409
+ }
410
+ // Sprint 90 (#189): JS template_injection — Handlebars.compile /
411
+ // ejs.render / ejs.compile with a tainted source.
412
+ for (const finding of findJsTemplateInjectionSstiFindings(code, graph.ir.meta.file)) {
413
+ ctx.addFinding(finding);
414
+ }
366
415
  }
367
416
  // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
368
417
  // fromJson recognized as ETE terminator (does not affect
@@ -6448,4 +6497,1218 @@ export function findPythonHeaderCrlfInjectionFindings(code, file) {
6448
6497
  }
6449
6498
  return findings;
6450
6499
  }
6500
+ /**
6501
+ * Sprint 87 detector A (#189) — JavaScript / TypeScript LDAP injection
6502
+ * via ldapjs / ldapts `client.search(base, { filter: <tainted>, ... })`.
6503
+ *
6504
+ * ldapjs and ldapts share the same call shape: `client.search(base, opts,
6505
+ * cb)` where `opts.filter` is the LDAP filter string. When an attacker
6506
+ * controls the filter content, they can break out of the intended filter
6507
+ * (e.g. `*)(uid=*` injection) to enumerate the directory. CWE-90.
6508
+ *
6509
+ * The detector tracks taint propagation from Express request extractors
6510
+ * (`req.query.X`, `req.body.X`, `req.params.X`, `req.headers.X`,
6511
+ * `req.cookies.X`) through both `let|const|var` assignments and
6512
+ * template-literal / `+`-concat construction of the filter string.
6513
+ */
6514
+ export function findJsLdapInjectionFindings(code, file) {
6515
+ const findings = [];
6516
+ if (typeof code !== 'string' || code.length === 0)
6517
+ return findings;
6518
+ // Gate: must reference an ldapjs/ldapts-shaped `.search(...)` call AND
6519
+ // an Express-shaped request extractor.
6520
+ if (!/\.\s*search\s*\(/.test(code))
6521
+ return findings;
6522
+ if (!/\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/.test(code)) {
6523
+ return findings;
6524
+ }
6525
+ // Soft library gate — only fire when ldapjs or ldapts is in the file.
6526
+ if (!/\b(?:ldapjs|ldapts|require\(['"]ldapjs['"]\)|from\s+['"]ldapts['"])\b/.test(code)) {
6527
+ return findings;
6528
+ }
6529
+ const lines = code.split('\n');
6530
+ const reqExtractRe = /\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/;
6531
+ const taintedVars = new Set();
6532
+ // 3-pass whole-file taint propagation across `(const|let|var) x = expr`.
6533
+ for (let pass = 0; pass < 3; pass++) {
6534
+ const before = taintedVars.size;
6535
+ for (let i = 0; i < lines.length; i++) {
6536
+ const t = lines[i].trim();
6537
+ if (t.startsWith('//'))
6538
+ continue;
6539
+ const m = t.match(/^(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(.+?);?$/);
6540
+ if (!m)
6541
+ continue;
6542
+ const lhs = m[1];
6543
+ const rhs = m[2];
6544
+ if (taintedVars.has(lhs))
6545
+ continue;
6546
+ if (reqExtractRe.test(rhs)) {
6547
+ taintedVars.add(lhs);
6548
+ continue;
6549
+ }
6550
+ for (const v of taintedVars) {
6551
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
6552
+ taintedVars.add(lhs);
6553
+ break;
6554
+ }
6555
+ }
6556
+ }
6557
+ if (taintedVars.size === before)
6558
+ break;
6559
+ }
6560
+ if (taintedVars.size === 0)
6561
+ return findings;
6562
+ // Find lines containing `filter: <expr>` (object property) where the
6563
+ // expression resolves to a tainted symbol, plus the shorthand
6564
+ // `{ filter, ... }` / `{ ..., filter }` form (ES6 property shorthand
6565
+ // means `{ filter }` is equivalent to `{ filter: filter }`).
6566
+ const filterPropRe = /\bfilter\s*:\s*([^,}\n]+)/;
6567
+ const filterShorthandRe = /(?:^|[\{,])\s*filter\s*[,}]/;
6568
+ const seen = new Set();
6569
+ for (let i = 0; i < lines.length; i++) {
6570
+ const line = lines[i];
6571
+ const fm = line.match(filterPropRe);
6572
+ let expr = null;
6573
+ if (fm) {
6574
+ expr = fm[1].trim();
6575
+ }
6576
+ else if (filterShorthandRe.test(line)) {
6577
+ // Shorthand: value is the local symbol named "filter".
6578
+ expr = 'filter';
6579
+ }
6580
+ else {
6581
+ continue;
6582
+ }
6583
+ // Tainted if any tainted var appears in the expression.
6584
+ let tainted = false;
6585
+ for (const v of taintedVars) {
6586
+ if (new RegExp(`\\b${v}\\b`).test(expr)) {
6587
+ tainted = true;
6588
+ break;
6589
+ }
6590
+ }
6591
+ if (!tainted)
6592
+ continue;
6593
+ if (seen.has(i + 1))
6594
+ continue;
6595
+ seen.add(i + 1);
6596
+ findings.push({
6597
+ id: `ldap_injection-${file}-${i + 1}-js-ldap-filter`,
6598
+ pass: 'language-sources',
6599
+ category: 'security',
6600
+ rule_id: 'ldap_injection',
6601
+ cwe: 'CWE-90',
6602
+ severity: 'critical',
6603
+ level: 'error',
6604
+ message: 'LDAP injection: ldapjs/ldapts `client.search(..., { filter: ' +
6605
+ '<tainted>, ... })` lets the attacker break out of the filter ' +
6606
+ 'expression (e.g. `*)(uid=*`) and enumerate the directory. ' +
6607
+ 'Escape the user input with a tight allowlist (`[A-Za-z0-9_-]+`) ' +
6608
+ 'or use a structured filter builder.',
6609
+ file,
6610
+ line: i + 1,
6611
+ snippet: line.trim(),
6612
+ });
6613
+ }
6614
+ return findings;
6615
+ }
6616
+ /**
6617
+ * Sprint 87 detector B (#189) — Go LDAP injection via go-ldap/ldap.v3
6618
+ * `ldap.NewSearchRequest(base, scope, deref, sizeLimit, timeLimit,
6619
+ * typesOnly, <tainted-filter>, attributes, controls)`.
6620
+ *
6621
+ * The 7th positional argument is the LDAP filter. The detector tracks
6622
+ * tainted strings derived from `r.URL.Query().Get(...)` / `r.Form.Get`
6623
+ * / `r.PostForm.Get` / `r.FormValue` / `r.PostFormValue` through
6624
+ * `:=`/`=` assignments and `fmt.Sprintf` calls, then fires when the
6625
+ * filter slot resolves to a tainted symbol. CWE-90.
6626
+ */
6627
+ export function findGoLdapInjectionFindings(code, file) {
6628
+ const findings = [];
6629
+ if (typeof code !== 'string' || code.length === 0)
6630
+ return findings;
6631
+ if (!/\bldap\s*\.\s*NewSearchRequest\s*\(/.test(code))
6632
+ return findings;
6633
+ if (!/\br\s*\.\s*(?:URL\s*\.\s*Query\s*\(\s*\)|Form|PostForm|FormValue|PostFormValue|Header)/.test(code)) {
6634
+ return findings;
6635
+ }
6636
+ const lines = code.split('\n');
6637
+ 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*\(/;
6638
+ const taintedVars = new Set();
6639
+ for (let pass = 0; pass < 3; pass++) {
6640
+ const before = taintedVars.size;
6641
+ for (let i = 0; i < lines.length; i++) {
6642
+ const t = lines[i].trim();
6643
+ if (t.startsWith('//'))
6644
+ continue;
6645
+ // `name := expr` or `name = expr`
6646
+ const m = t.match(/^(\w+)\s*(?::=|=)\s*(.+?)$/);
6647
+ if (!m)
6648
+ continue;
6649
+ const lhs = m[1];
6650
+ const rhs = m[2];
6651
+ if (taintedVars.has(lhs))
6652
+ continue;
6653
+ if (reqExtractRe.test(rhs)) {
6654
+ taintedVars.add(lhs);
6655
+ continue;
6656
+ }
6657
+ for (const v of taintedVars) {
6658
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
6659
+ taintedVars.add(lhs);
6660
+ break;
6661
+ }
6662
+ }
6663
+ }
6664
+ if (taintedVars.size === before)
6665
+ break;
6666
+ }
6667
+ if (taintedVars.size === 0)
6668
+ return findings;
6669
+ // Multiline-tolerant scan: NewSearchRequest call may span several
6670
+ // lines. Find each opener and collect its full argument span.
6671
+ const seen = new Set();
6672
+ for (let i = 0; i < lines.length; i++) {
6673
+ const openIdx = lines[i].indexOf('NewSearchRequest(');
6674
+ if (openIdx === -1)
6675
+ continue;
6676
+ // Aggregate text from `i` until we balance the parens.
6677
+ let depth = 0;
6678
+ let buf = '';
6679
+ let endLine = i;
6680
+ let started = false;
6681
+ for (let j = i; j < Math.min(i + 25, lines.length); j++) {
6682
+ for (const ch of lines[j]) {
6683
+ if (ch === '(') {
6684
+ depth++;
6685
+ started = true;
6686
+ }
6687
+ else if (ch === ')') {
6688
+ depth--;
6689
+ }
6690
+ buf += ch;
6691
+ if (started && depth === 0) {
6692
+ endLine = j;
6693
+ break;
6694
+ }
6695
+ }
6696
+ if (started && depth === 0)
6697
+ break;
6698
+ buf += '\n';
6699
+ }
6700
+ // buf now contains "NewSearchRequest(...)". Strip prefix.
6701
+ const argsStart = buf.indexOf('(');
6702
+ if (argsStart === -1)
6703
+ continue;
6704
+ const args = buf.substring(argsStart + 1, buf.length - 1);
6705
+ // Top-level split on commas (string-literal aware).
6706
+ const parts = [];
6707
+ {
6708
+ let d = 0;
6709
+ let b = '';
6710
+ let inStr = null;
6711
+ for (let k = 0; k < args.length; k++) {
6712
+ const ch = args[k];
6713
+ if (inStr) {
6714
+ if (ch === '\\') {
6715
+ b += ch + (args[k + 1] ?? '');
6716
+ k++;
6717
+ continue;
6718
+ }
6719
+ if (ch === inStr)
6720
+ inStr = null;
6721
+ b += ch;
6722
+ continue;
6723
+ }
6724
+ if (ch === '"' || ch === '`') {
6725
+ inStr = ch;
6726
+ b += ch;
6727
+ continue;
6728
+ }
6729
+ if (ch === '(' || ch === '[' || ch === '{')
6730
+ d++;
6731
+ else if (ch === ')' || ch === ']' || ch === '}')
6732
+ d--;
6733
+ if (ch === ',' && d === 0) {
6734
+ parts.push(b);
6735
+ b = '';
6736
+ continue;
6737
+ }
6738
+ b += ch;
6739
+ }
6740
+ if (b.trim().length > 0)
6741
+ parts.push(b);
6742
+ }
6743
+ if (parts.length < 7)
6744
+ continue;
6745
+ const filterExpr = parts[6].trim();
6746
+ let tainted = false;
6747
+ for (const v of taintedVars) {
6748
+ if (new RegExp(`\\b${v}\\b`).test(filterExpr)) {
6749
+ tainted = true;
6750
+ break;
6751
+ }
6752
+ }
6753
+ if (!tainted)
6754
+ continue;
6755
+ if (seen.has(i + 1))
6756
+ continue;
6757
+ seen.add(i + 1);
6758
+ findings.push({
6759
+ id: `ldap_injection-${file}-${i + 1}-go-newsearchrequest`,
6760
+ pass: 'language-sources',
6761
+ category: 'security',
6762
+ rule_id: 'ldap_injection',
6763
+ cwe: 'CWE-90',
6764
+ severity: 'critical',
6765
+ level: 'error',
6766
+ message: 'LDAP injection: go-ldap `ldap.NewSearchRequest(..., <tainted ' +
6767
+ 'filter>, ...)` lets the attacker break out of the filter ' +
6768
+ 'expression and enumerate the directory. Escape the user input ' +
6769
+ 'with `ldap.EscapeFilter(...)` or use a structured filter ' +
6770
+ 'builder.',
6771
+ file,
6772
+ line: i + 1,
6773
+ snippet: (lines[i] + (endLine > i ? ' …' : '')).trim(),
6774
+ });
6775
+ }
6776
+ return findings;
6777
+ }
6778
+ /**
6779
+ * Sprint 87 detector C (#189) — Rust LDAP injection via ldap3
6780
+ * `LdapConn::search(base, scope, &<tainted-filter>, attrs)` (and the
6781
+ * async `Ldap::search(...)` mirror).
6782
+ *
6783
+ * The 3rd positional argument is the LDAP filter. Tainted strings come
6784
+ * from actix-web `web::Query<HashMap<String, String>>` / `web::Path` /
6785
+ * `web::Form` / `web::Json` parameter types (extractor handlers) and
6786
+ * propagate through `let` bindings and `format!(...)` macros. CWE-90.
6787
+ */
6788
+ export function findRustLdapInjectionFindings(code, file) {
6789
+ const findings = [];
6790
+ if (typeof code !== 'string' || code.length === 0)
6791
+ return findings;
6792
+ if (!/\.\s*search\s*\(/.test(code))
6793
+ return findings;
6794
+ if (!/\b(?:ldap3|LdapConn|Ldap)\b/.test(code))
6795
+ return findings;
6796
+ const lines = code.split('\n');
6797
+ 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)/;
6798
+ const fns = [];
6799
+ let cur = null;
6800
+ for (let i = 0; i < lines.length; i++) {
6801
+ const t = lines[i];
6802
+ if (/^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+\s*\(/.test(t)) {
6803
+ if (cur) {
6804
+ cur.end = i - 1;
6805
+ fns.push(cur);
6806
+ }
6807
+ cur = { start: i, end: lines.length - 1, tainted: new Set() };
6808
+ // Assemble multi-line header.
6809
+ let headerJoined = '';
6810
+ for (let j = i; j < Math.min(i + 12, lines.length); j++) {
6811
+ headerJoined += lines[j];
6812
+ if (/\{\s*$/.test(lines[j]))
6813
+ break;
6814
+ }
6815
+ const open = headerJoined.indexOf('(');
6816
+ const close = headerJoined.lastIndexOf(')');
6817
+ if (open !== -1 && close > open) {
6818
+ const params = headerJoined.substring(open + 1, close);
6819
+ let depth = 0;
6820
+ let buf = '';
6821
+ const parts = [];
6822
+ for (const ch of params) {
6823
+ if (ch === '<' || ch === '(')
6824
+ depth++;
6825
+ else if (ch === '>' || ch === ')')
6826
+ depth--;
6827
+ if (ch === ',' && depth === 0) {
6828
+ parts.push(buf);
6829
+ buf = '';
6830
+ continue;
6831
+ }
6832
+ buf += ch;
6833
+ }
6834
+ if (buf.trim().length > 0)
6835
+ parts.push(buf);
6836
+ for (const p of parts) {
6837
+ const pm = p.match(/(?:mut\s+)?(\w+)\s*:/);
6838
+ if (!pm)
6839
+ continue;
6840
+ if (extractorTypeRe.test(p))
6841
+ cur.tainted.add(pm[1]);
6842
+ }
6843
+ }
6844
+ }
6845
+ }
6846
+ if (cur)
6847
+ fns.push(cur);
6848
+ // Propagate taint through `let` bindings.
6849
+ for (const fn of fns) {
6850
+ for (let pass = 0; pass < 3; pass++) {
6851
+ const before = fn.tainted.size;
6852
+ for (let i = fn.start; i <= fn.end; i++) {
6853
+ const t = lines[i].trim();
6854
+ const m = t.match(/^let\s+(?:mut\s+)?(\w+)\s*(?::\s*[^=]+)?=\s*(.+?);?$/);
6855
+ if (!m)
6856
+ continue;
6857
+ const lhs = m[1];
6858
+ const rhs = m[2];
6859
+ if (fn.tainted.has(lhs))
6860
+ continue;
6861
+ for (const v of fn.tainted) {
6862
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
6863
+ fn.tainted.add(lhs);
6864
+ break;
6865
+ }
6866
+ }
6867
+ }
6868
+ if (fn.tainted.size === before)
6869
+ break;
6870
+ }
6871
+ }
6872
+ // Multiline-tolerant `.search(` call walker.
6873
+ const seen = new Set();
6874
+ for (const fn of fns) {
6875
+ if (fn.tainted.size === 0)
6876
+ continue;
6877
+ for (let i = fn.start; i <= fn.end; i++) {
6878
+ const searchIdx = lines[i].indexOf('.search(');
6879
+ if (searchIdx === -1)
6880
+ continue;
6881
+ // Assemble args span.
6882
+ let depth = 0;
6883
+ let buf = '';
6884
+ let started = false;
6885
+ for (let j = i; j < Math.min(i + 15, lines.length); j++) {
6886
+ const startCol = j === i ? searchIdx : 0;
6887
+ for (let k = startCol; k < lines[j].length; k++) {
6888
+ const ch = lines[j][k];
6889
+ if (ch === '(') {
6890
+ depth++;
6891
+ started = true;
6892
+ }
6893
+ else if (ch === ')') {
6894
+ depth--;
6895
+ }
6896
+ buf += ch;
6897
+ if (started && depth === 0)
6898
+ break;
6899
+ }
6900
+ if (started && depth === 0)
6901
+ break;
6902
+ buf += '\n';
6903
+ }
6904
+ const argsStart = buf.indexOf('(');
6905
+ if (argsStart === -1)
6906
+ continue;
6907
+ const args = buf.substring(argsStart + 1, buf.length - 1);
6908
+ // String-literal aware top-level comma split.
6909
+ const parts = [];
6910
+ {
6911
+ let d = 0;
6912
+ let b = '';
6913
+ let inStr = null;
6914
+ for (let k = 0; k < args.length; k++) {
6915
+ const ch = args[k];
6916
+ if (inStr) {
6917
+ if (ch === '\\') {
6918
+ b += ch + (args[k + 1] ?? '');
6919
+ k++;
6920
+ continue;
6921
+ }
6922
+ if (ch === inStr)
6923
+ inStr = null;
6924
+ b += ch;
6925
+ continue;
6926
+ }
6927
+ if (ch === '"') {
6928
+ inStr = ch;
6929
+ b += ch;
6930
+ continue;
6931
+ }
6932
+ if (ch === '(' || ch === '[' || ch === '{')
6933
+ d++;
6934
+ else if (ch === ')' || ch === ']' || ch === '}')
6935
+ d--;
6936
+ if (ch === ',' && d === 0) {
6937
+ parts.push(b);
6938
+ b = '';
6939
+ continue;
6940
+ }
6941
+ b += ch;
6942
+ }
6943
+ if (b.trim().length > 0)
6944
+ parts.push(b);
6945
+ }
6946
+ // ldap3 search signature: search(base, scope, filter, attrs)
6947
+ if (parts.length < 3)
6948
+ continue;
6949
+ const filterExpr = parts[2].trim();
6950
+ let tainted = false;
6951
+ for (const v of fn.tainted) {
6952
+ if (new RegExp(`\\b${v}\\b`).test(filterExpr)) {
6953
+ tainted = true;
6954
+ break;
6955
+ }
6956
+ }
6957
+ if (!tainted)
6958
+ continue;
6959
+ if (seen.has(i + 1))
6960
+ continue;
6961
+ seen.add(i + 1);
6962
+ findings.push({
6963
+ id: `ldap_injection-${file}-${i + 1}-rust-ldap3-search`,
6964
+ pass: 'language-sources',
6965
+ category: 'security',
6966
+ rule_id: 'ldap_injection',
6967
+ cwe: 'CWE-90',
6968
+ severity: 'critical',
6969
+ level: 'error',
6970
+ message: 'LDAP injection: ldap3 `LdapConn::search(..., &<tainted ' +
6971
+ 'filter>, ...)` lets the attacker break out of the filter ' +
6972
+ 'expression and enumerate the directory. Escape the user input ' +
6973
+ 'or use a structured filter builder.',
6974
+ file,
6975
+ line: i + 1,
6976
+ snippet: lines[i].trim(),
6977
+ });
6978
+ }
6979
+ }
6980
+ return findings;
6981
+ }
6982
+ /**
6983
+ * Sprint 87 detector D (#189) — Rust log injection via the `log` crate
6984
+ * macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) where a
6985
+ * tainted value is interpolated into the format args.
6986
+ *
6987
+ * Unsanitized CRLF in log lines can split log entries, forge
6988
+ * authentication events, or escape into log-aggregation pipelines that
6989
+ * parse newlines as record boundaries. CWE-117.
6990
+ */
6991
+ export function findRustLogInjectionFindings(code, file) {
6992
+ const findings = [];
6993
+ if (typeof code !== 'string' || code.length === 0)
6994
+ return findings;
6995
+ if (!/\b(?:info|warn|error|debug|trace)\s*!\s*\(/.test(code))
6996
+ return findings;
6997
+ if (!/\b(?:log|tracing)\b/.test(code))
6998
+ return findings;
6999
+ const lines = code.split('\n');
7000
+ 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)/;
7001
+ const fns = [];
7002
+ let cur = null;
7003
+ for (let i = 0; i < lines.length; i++) {
7004
+ const t = lines[i];
7005
+ if (/^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+\s*\(/.test(t)) {
7006
+ if (cur) {
7007
+ cur.end = i - 1;
7008
+ fns.push(cur);
7009
+ }
7010
+ cur = { start: i, end: lines.length - 1, tainted: new Set() };
7011
+ let headerJoined = '';
7012
+ for (let j = i; j < Math.min(i + 12, lines.length); j++) {
7013
+ headerJoined += lines[j];
7014
+ if (/\{\s*$/.test(lines[j]))
7015
+ break;
7016
+ }
7017
+ const open = headerJoined.indexOf('(');
7018
+ const close = headerJoined.lastIndexOf(')');
7019
+ if (open !== -1 && close > open) {
7020
+ const params = headerJoined.substring(open + 1, close);
7021
+ let depth = 0;
7022
+ let buf = '';
7023
+ const parts = [];
7024
+ for (const ch of params) {
7025
+ if (ch === '<' || ch === '(')
7026
+ depth++;
7027
+ else if (ch === '>' || ch === ')')
7028
+ depth--;
7029
+ if (ch === ',' && depth === 0) {
7030
+ parts.push(buf);
7031
+ buf = '';
7032
+ continue;
7033
+ }
7034
+ buf += ch;
7035
+ }
7036
+ if (buf.trim().length > 0)
7037
+ parts.push(buf);
7038
+ for (const p of parts) {
7039
+ const pm = p.match(/(?:mut\s+)?(\w+)\s*:/);
7040
+ if (!pm)
7041
+ continue;
7042
+ if (extractorTypeRe.test(p))
7043
+ cur.tainted.add(pm[1]);
7044
+ }
7045
+ }
7046
+ }
7047
+ }
7048
+ if (cur)
7049
+ fns.push(cur);
7050
+ for (const fn of fns) {
7051
+ for (let pass = 0; pass < 3; pass++) {
7052
+ const before = fn.tainted.size;
7053
+ for (let i = fn.start; i <= fn.end; i++) {
7054
+ const t = lines[i].trim();
7055
+ const m = t.match(/^let\s+(?:mut\s+)?(\w+)\s*(?::\s*[^=]+)?=\s*(.+?);?$/);
7056
+ if (!m)
7057
+ continue;
7058
+ const lhs = m[1];
7059
+ const rhs = m[2];
7060
+ if (fn.tainted.has(lhs))
7061
+ continue;
7062
+ for (const v of fn.tainted) {
7063
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
7064
+ fn.tainted.add(lhs);
7065
+ break;
7066
+ }
7067
+ }
7068
+ }
7069
+ if (fn.tainted.size === before)
7070
+ break;
7071
+ }
7072
+ }
7073
+ const macroRe = /\b(info|warn|error|debug|trace)\s*!\s*\(\s*([^;]+?)\)\s*;?\s*$/;
7074
+ const seen = new Set();
7075
+ for (const fn of fns) {
7076
+ if (fn.tainted.size === 0)
7077
+ continue;
7078
+ for (let i = fn.start; i <= fn.end; i++) {
7079
+ const line = lines[i];
7080
+ const m = line.match(macroRe);
7081
+ if (!m)
7082
+ continue;
7083
+ const macroName = m[1];
7084
+ const argSpan = m[2];
7085
+ // Split on top-level commas to separate the fmt-string from args.
7086
+ const parts = [];
7087
+ {
7088
+ let d = 0;
7089
+ let b = '';
7090
+ let inStr = null;
7091
+ for (let k = 0; k < argSpan.length; k++) {
7092
+ const ch = argSpan[k];
7093
+ if (inStr) {
7094
+ if (ch === '\\') {
7095
+ b += ch + (argSpan[k + 1] ?? '');
7096
+ k++;
7097
+ continue;
7098
+ }
7099
+ if (ch === inStr)
7100
+ inStr = null;
7101
+ b += ch;
7102
+ continue;
7103
+ }
7104
+ if (ch === '"') {
7105
+ inStr = ch;
7106
+ b += ch;
7107
+ continue;
7108
+ }
7109
+ if (ch === '(' || ch === '[' || ch === '{')
7110
+ d++;
7111
+ else if (ch === ')' || ch === ']' || ch === '}')
7112
+ d--;
7113
+ if (ch === ',' && d === 0) {
7114
+ parts.push(b);
7115
+ b = '';
7116
+ continue;
7117
+ }
7118
+ b += ch;
7119
+ }
7120
+ if (b.trim().length > 0)
7121
+ parts.push(b);
7122
+ }
7123
+ if (parts.length < 2)
7124
+ continue;
7125
+ // Any of the format args (parts[1..]) being tainted = fire.
7126
+ let tainted = false;
7127
+ for (let p = 1; p < parts.length; p++) {
7128
+ for (const v of fn.tainted) {
7129
+ if (new RegExp(`\\b${v}\\b`).test(parts[p])) {
7130
+ tainted = true;
7131
+ break;
7132
+ }
7133
+ }
7134
+ if (tainted)
7135
+ break;
7136
+ }
7137
+ if (!tainted)
7138
+ continue;
7139
+ const key = `${i + 1}:${macroName}`;
7140
+ if (seen.has(key))
7141
+ continue;
7142
+ seen.add(key);
7143
+ findings.push({
7144
+ id: `log_injection-${file}-${i + 1}-rust-${macroName}`,
7145
+ pass: 'language-sources',
7146
+ category: 'security',
7147
+ rule_id: 'log_injection',
7148
+ cwe: 'CWE-117',
7149
+ severity: 'medium',
7150
+ level: 'warning',
7151
+ message: `Log injection: Rust \`${macroName}!(...)\` interpolates a ` +
7152
+ 'tainted value into the log line. Unsanitized CRLF lets an ' +
7153
+ 'attacker forge log entries or split records. Strip control ' +
7154
+ 'characters or use a structured logging API.',
7155
+ file,
7156
+ line: i + 1,
7157
+ snippet: line.trim(),
7158
+ });
7159
+ }
7160
+ }
7161
+ return findings;
7162
+ }
7163
+ /**
7164
+ * Sprint 89 detector A (#189) — Go insecure deserialization via
7165
+ * `encoding/gob` `gob.NewDecoder(<req-body>).Decode(&v)`.
7166
+ *
7167
+ * The `gob` package will reconstruct arbitrary Go values, and any
7168
+ * registered concrete type can be instantiated by the attacker through
7169
+ * `gob.Register(...)` side effects. Decoding directly from
7170
+ * `r.Body` (`*http.Request`) without authenticated framing is unsafe.
7171
+ * CWE-502.
7172
+ */
7173
+ export function findGoGobDeserializationFindings(code, file) {
7174
+ const findings = [];
7175
+ if (typeof code !== 'string' || code.length === 0)
7176
+ return findings;
7177
+ if (!/\bgob\s*\.\s*NewDecoder\s*\(/.test(code))
7178
+ return findings;
7179
+ if (!/\*http\.Request\b/.test(code) && !/\bhttp\.HandlerFunc\b/.test(code)) {
7180
+ return findings;
7181
+ }
7182
+ const lines = code.split('\n');
7183
+ // Track variables holding a *gob.Decoder constructed from req body.
7184
+ const decoderFromBody = new Set();
7185
+ const newDecoderAssignRe = /^(\w+)\s*(?::=|=)\s*gob\s*\.\s*NewDecoder\s*\(\s*([^)]+)\)/;
7186
+ for (const raw of lines) {
7187
+ const t = raw.trim();
7188
+ if (t.startsWith('//'))
7189
+ continue;
7190
+ const m = t.match(newDecoderAssignRe);
7191
+ if (!m)
7192
+ continue;
7193
+ const lhs = m[1];
7194
+ const arg = m[2];
7195
+ if (/\b\w+\s*\.\s*Body\b/.test(arg)) {
7196
+ decoderFromBody.add(lhs);
7197
+ }
7198
+ }
7199
+ const seen = new Set();
7200
+ if (decoderFromBody.size > 0) {
7201
+ for (let i = 0; i < lines.length; i++) {
7202
+ const line = lines[i];
7203
+ if (line.trim().startsWith('//'))
7204
+ continue;
7205
+ const m = line.match(/\b(\w+)\s*\.\s*Decode\s*\(/);
7206
+ if (!m)
7207
+ continue;
7208
+ if (!decoderFromBody.has(m[1]))
7209
+ continue;
7210
+ if (seen.has(i + 1))
7211
+ continue;
7212
+ seen.add(i + 1);
7213
+ findings.push({
7214
+ id: `insecure_deserialization-${file}-${i + 1}-go-gob`,
7215
+ pass: 'language-sources',
7216
+ category: 'security',
7217
+ rule_id: 'insecure_deserialization',
7218
+ cwe: 'CWE-502',
7219
+ severity: 'critical',
7220
+ level: 'error',
7221
+ message: 'Insecure deserialization: `gob.NewDecoder(req.Body).Decode(...)` ' +
7222
+ 'reconstructs arbitrary registered Go types from attacker-controlled ' +
7223
+ 'bytes. Use an authenticated framing (signed/MAC payloads), avoid ' +
7224
+ 'decoding interface{} values, or switch to a schema-bound format ' +
7225
+ '(JSON with explicit types, Protocol Buffers).',
7226
+ file,
7227
+ line: i + 1,
7228
+ snippet: line.trim(),
7229
+ });
7230
+ }
7231
+ }
7232
+ // Inline form — `gob.NewDecoder(req.Body).Decode(&v)`.
7233
+ for (let i = 0; i < lines.length; i++) {
7234
+ const line = lines[i];
7235
+ if (line.trim().startsWith('//'))
7236
+ continue;
7237
+ if (!/\bgob\s*\.\s*NewDecoder\s*\(\s*\w+\s*\.\s*Body\s*\)\s*\.\s*Decode\s*\(/.test(line)) {
7238
+ continue;
7239
+ }
7240
+ if (seen.has(i + 1))
7241
+ continue;
7242
+ seen.add(i + 1);
7243
+ findings.push({
7244
+ id: `insecure_deserialization-${file}-${i + 1}-go-gob-inline`,
7245
+ pass: 'language-sources',
7246
+ category: 'security',
7247
+ rule_id: 'insecure_deserialization',
7248
+ cwe: 'CWE-502',
7249
+ severity: 'critical',
7250
+ level: 'error',
7251
+ message: 'Insecure deserialization: `gob.NewDecoder(req.Body).Decode(...)` ' +
7252
+ 'reconstructs arbitrary registered Go types from attacker-controlled ' +
7253
+ 'bytes. Use an authenticated framing (signed/MAC payloads), avoid ' +
7254
+ 'decoding interface{} values, or switch to a schema-bound format ' +
7255
+ '(JSON with explicit types, Protocol Buffers).',
7256
+ file,
7257
+ line: i + 1,
7258
+ snippet: line.trim(),
7259
+ });
7260
+ }
7261
+ return findings;
7262
+ }
7263
+ /**
7264
+ * Sprint 89 detector B (#189) — JS insecure deserialization via
7265
+ * `JSON.parse(req.body)`.
7266
+ *
7267
+ * Express applications that register `express.text(...)` or
7268
+ * `bodyParser.raw(...)` middleware leave `req.body` as an
7269
+ * attacker-controlled string. Passing it through `JSON.parse(...)`
7270
+ * exposes prototype-pollution paths if the parsed object is then
7271
+ * merged into trusted state or used as a property bag. CWE-502 /
7272
+ * CWE-1321 (prototype-pollution adjacent).
7273
+ *
7274
+ * Conservative gate: only fire on `JSON.parse(<expr>)` where
7275
+ * `<expr>` resolves to `req.body` (the only express extractor that
7276
+ * is genuinely a raw string when text/raw bodyparsing is used).
7277
+ */
7278
+ export function findJsJsonParseBodyFindings(code, file) {
7279
+ const findings = [];
7280
+ if (typeof code !== 'string' || code.length === 0)
7281
+ return findings;
7282
+ if (!/\bJSON\s*\.\s*parse\s*\(/.test(code))
7283
+ return findings;
7284
+ if (!/\breq\s*\.\s*body\b/.test(code))
7285
+ return findings;
7286
+ const lines = code.split('\n');
7287
+ const reqBodyRe = /\breq\s*\.\s*body\b/;
7288
+ const taintedVars = new Set();
7289
+ for (let pass = 0; pass < 3; pass++) {
7290
+ const before = taintedVars.size;
7291
+ for (let i = 0; i < lines.length; i++) {
7292
+ const t = lines[i].trim();
7293
+ if (t.startsWith('//'))
7294
+ continue;
7295
+ const m = t.match(/^(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(.+?);?$/);
7296
+ if (!m)
7297
+ continue;
7298
+ const lhs = m[1];
7299
+ const rhs = m[2];
7300
+ if (taintedVars.has(lhs))
7301
+ continue;
7302
+ if (reqBodyRe.test(rhs)) {
7303
+ taintedVars.add(lhs);
7304
+ continue;
7305
+ }
7306
+ for (const v of taintedVars) {
7307
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
7308
+ taintedVars.add(lhs);
7309
+ break;
7310
+ }
7311
+ }
7312
+ }
7313
+ if (taintedVars.size === before)
7314
+ break;
7315
+ }
7316
+ const callRe = /\bJSON\s*\.\s*parse\s*\(\s*([^)]+?)\s*\)/g;
7317
+ const seen = new Set();
7318
+ for (let i = 0; i < lines.length; i++) {
7319
+ const line = lines[i];
7320
+ if (line.trim().startsWith('//'))
7321
+ continue;
7322
+ callRe.lastIndex = 0;
7323
+ let m;
7324
+ while ((m = callRe.exec(line)) !== null) {
7325
+ const arg = m[1];
7326
+ let tainted = reqBodyRe.test(arg);
7327
+ if (!tainted) {
7328
+ for (const v of taintedVars) {
7329
+ if (new RegExp(`\\b${v}\\b`).test(arg)) {
7330
+ tainted = true;
7331
+ break;
7332
+ }
7333
+ }
7334
+ }
7335
+ if (!tainted)
7336
+ continue;
7337
+ if (seen.has(i + 1))
7338
+ continue;
7339
+ seen.add(i + 1);
7340
+ findings.push({
7341
+ id: `insecure_deserialization-${file}-${i + 1}-js-jsonparse-body`,
7342
+ pass: 'language-sources',
7343
+ category: 'security',
7344
+ rule_id: 'insecure_deserialization',
7345
+ cwe: 'CWE-502',
7346
+ severity: 'high',
7347
+ level: 'warning',
7348
+ message: '`JSON.parse(req.body)` deserializes attacker-controlled bytes ' +
7349
+ 'directly. With `express.text()` / `bodyParser.raw()` middleware ' +
7350
+ '`req.body` is an unvetted string; the parsed object can carry a ' +
7351
+ '`__proto__` payload that pollutes downstream property lookups. ' +
7352
+ 'Validate against a schema (zod/ajv) before consuming the value.',
7353
+ file,
7354
+ line: i + 1,
7355
+ snippet: line.trim(),
7356
+ });
7357
+ }
7358
+ }
7359
+ return findings;
7360
+ }
7361
+ /**
7362
+ * Sprint 89 detector C (#189) — JS DOM XPath injection via
7363
+ * `document.evaluate(<tainted>, ...)`.
7364
+ *
7365
+ * The DOM `XPathEvaluator.evaluate()` API takes a string XPath
7366
+ * expression as its first argument. When that string is built from
7367
+ * `location.search` / `location.hash` / URLSearchParams without
7368
+ * escaping, the attacker can rewrite the query (e.g. injecting
7369
+ * `' or '1'='1`-style predicates) and exfiltrate other nodes. CWE-643.
7370
+ *
7371
+ * Gate: file must reference `XPathResult` (strong DOM-XPath signal)
7372
+ * AND call `.evaluate(`, AND contain a browser taint source
7373
+ * (`location.search`, `location.hash`, `URLSearchParams`).
7374
+ */
7375
+ export function findJsDomXpathInjectionFindings(code, file) {
7376
+ const findings = [];
7377
+ if (typeof code !== 'string' || code.length === 0)
7378
+ return findings;
7379
+ if (!/\.\s*evaluate\s*\(/.test(code))
7380
+ return findings;
7381
+ if (!/\bXPathResult\b/.test(code))
7382
+ return findings;
7383
+ const browserSourceRe = /\b(?:location\s*\.\s*(?:search|hash|href)|URLSearchParams|window\s*\.\s*name|document\s*\.\s*cookie)\b/;
7384
+ if (!browserSourceRe.test(code))
7385
+ return findings;
7386
+ const lines = code.split('\n');
7387
+ const taintedVars = new Set();
7388
+ for (let pass = 0; pass < 3; pass++) {
7389
+ const before = taintedVars.size;
7390
+ for (let i = 0; i < lines.length; i++) {
7391
+ const t = lines[i].trim();
7392
+ if (t.startsWith('//'))
7393
+ continue;
7394
+ const m = t.match(/^(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(.+?);?$/);
7395
+ if (!m)
7396
+ continue;
7397
+ const lhs = m[1];
7398
+ const rhs = m[2];
7399
+ if (taintedVars.has(lhs))
7400
+ continue;
7401
+ if (browserSourceRe.test(rhs)) {
7402
+ taintedVars.add(lhs);
7403
+ continue;
7404
+ }
7405
+ for (const v of taintedVars) {
7406
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
7407
+ taintedVars.add(lhs);
7408
+ break;
7409
+ }
7410
+ }
7411
+ }
7412
+ if (taintedVars.size === before)
7413
+ break;
7414
+ }
7415
+ if (taintedVars.size === 0)
7416
+ return findings;
7417
+ const evalRe = /\.\s*evaluate\s*\(\s*([^,)]+)/;
7418
+ const seen = new Set();
7419
+ for (let i = 0; i < lines.length; i++) {
7420
+ const line = lines[i];
7421
+ if (line.trim().startsWith('//'))
7422
+ continue;
7423
+ const m = line.match(evalRe);
7424
+ if (!m)
7425
+ continue;
7426
+ const arg = m[1].trim();
7427
+ let tainted = false;
7428
+ for (const v of taintedVars) {
7429
+ if (new RegExp(`\\b${v}\\b`).test(arg)) {
7430
+ tainted = true;
7431
+ break;
7432
+ }
7433
+ }
7434
+ if (!tainted)
7435
+ continue;
7436
+ if (seen.has(i + 1))
7437
+ continue;
7438
+ seen.add(i + 1);
7439
+ findings.push({
7440
+ id: `xpath_injection-${file}-${i + 1}-js-dom-evaluate`,
7441
+ pass: 'language-sources',
7442
+ category: 'security',
7443
+ rule_id: 'xpath_injection',
7444
+ cwe: 'CWE-643',
7445
+ severity: 'high',
7446
+ level: 'warning',
7447
+ message: 'XPath injection: `document.evaluate(<tainted>, ...)` lets the ' +
7448
+ 'attacker break out of the XPath expression and read sibling ' +
7449
+ 'nodes. Bind user input through XPath variables / parameterized ' +
7450
+ 'expressions, or escape with an allowlist before concatenation.',
7451
+ file,
7452
+ line: i + 1,
7453
+ snippet: line.trim(),
7454
+ });
7455
+ }
7456
+ return findings;
7457
+ }
7458
+ /**
7459
+ * Sprint 90 detector A (#189) — Go XXE via `encoding/xml` decoder with
7460
+ * `d.Strict = false` (allows DTD-like constructs and custom Entity
7461
+ * resolution to be configured on the decoder). When the source stream
7462
+ * is `*http.Request.Body`, the attacker can submit a payload that
7463
+ * triggers entity-expansion / external-entity reads through the
7464
+ * `Entity` map. CWE-611 / CWE-776.
7465
+ */
7466
+ export function findGoXmlDecoderXxeFindings(code, file) {
7467
+ const findings = [];
7468
+ if (typeof code !== 'string' || code.length === 0)
7469
+ return findings;
7470
+ if (!/\bxml\s*\.\s*NewDecoder\s*\(/.test(code))
7471
+ return findings;
7472
+ if (!/\*http\.Request\b/.test(code) && !/\bhttp\.HandlerFunc\b/.test(code)) {
7473
+ return findings;
7474
+ }
7475
+ const lines = code.split('\n');
7476
+ // Track decoder vars constructed from req.Body.
7477
+ const decoderFromBody = new Map(); // var -> line idx
7478
+ for (let i = 0; i < lines.length; i++) {
7479
+ const t = lines[i].trim();
7480
+ if (t.startsWith('//'))
7481
+ continue;
7482
+ const m = t.match(/^(\w+)\s*(?::=|=)\s*xml\s*\.\s*NewDecoder\s*\(\s*([^)]+)\)/);
7483
+ if (!m)
7484
+ continue;
7485
+ if (/\b\w+\s*\.\s*Body\b/.test(m[2]))
7486
+ decoderFromBody.set(m[1], i);
7487
+ }
7488
+ if (decoderFromBody.size === 0)
7489
+ return findings;
7490
+ // Only fire if downstream we see `<dec>.Strict = false` or
7491
+ // `<dec>.Entity = ...` (entity-map override).
7492
+ const seen = new Set();
7493
+ for (let i = 0; i < lines.length; i++) {
7494
+ const line = lines[i];
7495
+ if (line.trim().startsWith('//'))
7496
+ continue;
7497
+ const m = line.match(/\b(\w+)\s*\.\s*(?:Strict\s*=\s*false|Entity\s*=)\b/);
7498
+ if (!m)
7499
+ continue;
7500
+ if (!decoderFromBody.has(m[1]))
7501
+ continue;
7502
+ if (seen.has(i + 1))
7503
+ continue;
7504
+ seen.add(i + 1);
7505
+ findings.push({
7506
+ id: `xml_entity_expansion-${file}-${i + 1}-go-xml-decoder`,
7507
+ pass: 'language-sources',
7508
+ category: 'security',
7509
+ rule_id: 'xml_entity_expansion',
7510
+ cwe: 'CWE-611',
7511
+ severity: 'high',
7512
+ level: 'warning',
7513
+ message: 'XXE: Go `xml.NewDecoder(req.Body)` with `Strict = false` (or a ' +
7514
+ 'custom `Entity` map) allows entity references that the standard ' +
7515
+ 'library normally rejects. Keep `Strict = true` and avoid setting ' +
7516
+ '`Entity` from attacker-controlled inputs.',
7517
+ file,
7518
+ line: i + 1,
7519
+ snippet: line.trim(),
7520
+ });
7521
+ }
7522
+ return findings;
7523
+ }
7524
+ /**
7525
+ * Sprint 90 detector B (#189) — Python SSTI via Jinja2
7526
+ * `Template(<tainted>).render(...)`.
7527
+ *
7528
+ * Constructing a `jinja2.Template` from attacker-controlled source
7529
+ * gives the attacker the entire Jinja sandbox-escape surface
7530
+ * (`{{ ''.__class__.__mro__[1].__subclasses__() ... }}`). CWE-1336.
7531
+ *
7532
+ * Gate: file must import `jinja2.Template` AND construct it from a
7533
+ * Flask/FastAPI request extractor (request.args/form/values/json/data).
7534
+ */
7535
+ export function findPythonJinjaTemplateSstiFindings(code, file) {
7536
+ const findings = [];
7537
+ if (typeof code !== 'string' || code.length === 0)
7538
+ return findings;
7539
+ if (!/\bfrom\s+jinja2\s+import\s+[^#\n]*\bTemplate\b/.test(code) &&
7540
+ !/\bjinja2\s*\.\s*Template\b/.test(code)) {
7541
+ return findings;
7542
+ }
7543
+ const reqExtractRe = /\brequest\s*\.\s*(?:args|form|values|json|data|cookies|headers)\b/;
7544
+ if (!reqExtractRe.test(code))
7545
+ return findings;
7546
+ const lines = code.split('\n');
7547
+ const taintedVars = new Set();
7548
+ for (let pass = 0; pass < 3; pass++) {
7549
+ const before = taintedVars.size;
7550
+ for (let i = 0; i < lines.length; i++) {
7551
+ const t = lines[i].trim();
7552
+ if (t.startsWith('#'))
7553
+ continue;
7554
+ const m = t.match(/^(\w+)\s*=\s*(.+?)$/);
7555
+ if (!m)
7556
+ continue;
7557
+ const lhs = m[1];
7558
+ const rhs = m[2];
7559
+ if (taintedVars.has(lhs))
7560
+ continue;
7561
+ if (reqExtractRe.test(rhs)) {
7562
+ taintedVars.add(lhs);
7563
+ continue;
7564
+ }
7565
+ for (const v of taintedVars) {
7566
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
7567
+ taintedVars.add(lhs);
7568
+ break;
7569
+ }
7570
+ }
7571
+ }
7572
+ if (taintedVars.size === before)
7573
+ break;
7574
+ }
7575
+ if (taintedVars.size === 0)
7576
+ return findings;
7577
+ const ctorRe = /\b(?:jinja2\s*\.\s*)?Template\s*\(\s*([^)]+?)\s*\)/;
7578
+ const seen = new Set();
7579
+ for (let i = 0; i < lines.length; i++) {
7580
+ const line = lines[i];
7581
+ if (line.trim().startsWith('#'))
7582
+ continue;
7583
+ const m = line.match(ctorRe);
7584
+ if (!m)
7585
+ continue;
7586
+ const arg = m[1].trim();
7587
+ let tainted = reqExtractRe.test(arg);
7588
+ if (!tainted) {
7589
+ for (const v of taintedVars) {
7590
+ if (new RegExp(`\\b${v}\\b`).test(arg)) {
7591
+ tainted = true;
7592
+ break;
7593
+ }
7594
+ }
7595
+ }
7596
+ if (!tainted)
7597
+ continue;
7598
+ if (seen.has(i + 1))
7599
+ continue;
7600
+ seen.add(i + 1);
7601
+ findings.push({
7602
+ id: `template_injection-${file}-${i + 1}-py-jinja-template`,
7603
+ pass: 'language-sources',
7604
+ category: 'security',
7605
+ rule_id: 'template_injection',
7606
+ cwe: 'CWE-1336',
7607
+ severity: 'critical',
7608
+ level: 'error',
7609
+ message: 'Server-side template injection: `jinja2.Template(<tainted>).render()` ' +
7610
+ 'compiles attacker-controlled source. Sandbox-escape gadgets such as ' +
7611
+ '`{{ ().__class__.__bases__[0].__subclasses__() }}` lead to RCE. ' +
7612
+ 'Render fixed templates with user data passed as context variables.',
7613
+ file,
7614
+ line: i + 1,
7615
+ snippet: line.trim(),
7616
+ });
7617
+ }
7618
+ return findings;
7619
+ }
7620
+ /**
7621
+ * Sprint 90 detector C (#189) — JS SSTI via `Handlebars.compile(<tainted>)`
7622
+ * or `ejs.render(<tainted>, ...)` / `ejs.compile(<tainted>)`.
7623
+ *
7624
+ * Compiling an attacker-controlled template lets the attacker execute
7625
+ * arbitrary code through helper-shadowing / prototype gadgets
7626
+ * (Handlebars CVE-2019-19919 chains, EJS render-options escape). CWE-1336.
7627
+ */
7628
+ export function findJsTemplateInjectionSstiFindings(code, file) {
7629
+ const findings = [];
7630
+ if (typeof code !== 'string' || code.length === 0)
7631
+ return findings;
7632
+ if (!/\bHandlebars\s*\.\s*compile\s*\(/.test(code) &&
7633
+ !/\bejs\s*\.\s*(?:render|compile)\s*\(/.test(code)) {
7634
+ return findings;
7635
+ }
7636
+ if (!/\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/.test(code)) {
7637
+ return findings;
7638
+ }
7639
+ const lines = code.split('\n');
7640
+ const reqExtractRe = /\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/;
7641
+ const taintedVars = new Set();
7642
+ for (let pass = 0; pass < 3; pass++) {
7643
+ const before = taintedVars.size;
7644
+ for (let i = 0; i < lines.length; i++) {
7645
+ const t = lines[i].trim();
7646
+ if (t.startsWith('//'))
7647
+ continue;
7648
+ const m = t.match(/^(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(.+?);?$/);
7649
+ if (!m)
7650
+ continue;
7651
+ const lhs = m[1];
7652
+ const rhs = m[2];
7653
+ if (taintedVars.has(lhs))
7654
+ continue;
7655
+ if (reqExtractRe.test(rhs)) {
7656
+ taintedVars.add(lhs);
7657
+ continue;
7658
+ }
7659
+ for (const v of taintedVars) {
7660
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
7661
+ taintedVars.add(lhs);
7662
+ break;
7663
+ }
7664
+ }
7665
+ }
7666
+ if (taintedVars.size === before)
7667
+ break;
7668
+ }
7669
+ if (taintedVars.size === 0)
7670
+ return findings;
7671
+ const callRe = /\b(Handlebars\s*\.\s*compile|ejs\s*\.\s*(?:render|compile))\s*\(\s*([^,)]+)/;
7672
+ const seen = new Set();
7673
+ for (let i = 0; i < lines.length; i++) {
7674
+ const line = lines[i];
7675
+ if (line.trim().startsWith('//'))
7676
+ continue;
7677
+ const m = line.match(callRe);
7678
+ if (!m)
7679
+ continue;
7680
+ const arg = m[2].trim();
7681
+ let tainted = reqExtractRe.test(arg);
7682
+ if (!tainted) {
7683
+ for (const v of taintedVars) {
7684
+ if (new RegExp(`\\b${v}\\b`).test(arg)) {
7685
+ tainted = true;
7686
+ break;
7687
+ }
7688
+ }
7689
+ }
7690
+ if (!tainted)
7691
+ continue;
7692
+ if (seen.has(i + 1))
7693
+ continue;
7694
+ seen.add(i + 1);
7695
+ findings.push({
7696
+ id: `template_injection-${file}-${i + 1}-js-${m[1].replace(/[\s.]/g, '').toLowerCase()}`,
7697
+ pass: 'language-sources',
7698
+ category: 'security',
7699
+ rule_id: 'template_injection',
7700
+ cwe: 'CWE-1336',
7701
+ severity: 'critical',
7702
+ level: 'error',
7703
+ message: 'Server-side template injection: compiling/rendering a template ' +
7704
+ 'whose source is attacker-controlled (`Handlebars.compile(...)` / ' +
7705
+ '`ejs.render(...)`) opens helper-shadowing and prototype-gadget RCE ' +
7706
+ 'paths. Use a fixed template and pass user data as context.',
7707
+ file,
7708
+ line: i + 1,
7709
+ snippet: line.trim(),
7710
+ });
7711
+ }
7712
+ return findings;
7713
+ }
6451
7714
  //# sourceMappingURL=language-sources-pass.js.map