circle-ir 3.132.0 → 3.134.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.
@@ -219,6 +219,15 @@ export class LanguageSourcesPass {
219
219
  for (const finding of findGoLocationHeaderOpenRedirectFindings(code, graph.ir.meta.file)) {
220
220
  ctx.addFinding(finding);
221
221
  }
222
+ // Sprint 83 (#189): Go code_injection — plugin.Open / plugin.Lookup.
223
+ for (const finding of findGoPluginOpenCodeInjectionFindings(code, graph.ir.meta.file)) {
224
+ ctx.addFinding(finding);
225
+ }
226
+ // Sprint 84 (#189): Go nosql_injection — *mongo.Collection FindOne / Find
227
+ // / Insert / Update / Delete / Aggregate with bson.M{...tainted}.
228
+ for (const finding of findGoMongoNosqlInjectionFindings(code, graph.ir.meta.file)) {
229
+ ctx.addFinding(finding);
230
+ }
222
231
  }
223
232
  // -- Python: safe-handler sanitizer detectors (cognium-dev #114 Sprint 31) --
224
233
  if (language === 'python') {
@@ -252,6 +261,16 @@ export class LanguageSourcesPass {
252
261
  for (const finding of findPythonHeadersSubscriptOpenRedirectFindings(code, graph.ir.meta.file)) {
253
262
  ctx.addFinding(finding);
254
263
  }
264
+ // Sprint 83 (#189): Python code_injection — code.InteractiveInterpreter
265
+ // / InteractiveConsole / compile_command on Flask request input.
266
+ for (const finding of findPythonInteractiveInterpreterCodeInjectionFindings(code, graph.ir.meta.file)) {
267
+ ctx.addFinding(finding);
268
+ }
269
+ // Sprint 84 (#189): Python nosql_injection — mongoengine `__raw__={"$where":
270
+ // <tainted-string-concat>}` JS-string injection through MongoDB $where.
271
+ for (const finding of findPythonMongoengineWhereNosqlInjectionFindings(code, graph.ir.meta.file)) {
272
+ ctx.addFinding(finding);
273
+ }
255
274
  }
256
275
  // -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
257
276
  if (language === 'rust') {
@@ -285,6 +304,11 @@ export class LanguageSourcesPass {
285
304
  for (const finding of findRustAppendHeaderTupleOpenRedirectFindings(code, graph.ir.meta.file)) {
286
305
  ctx.addFinding(finding);
287
306
  }
307
+ // Sprint 83 (#189): Rust code_injection — evalexpr crate, libloading,
308
+ // mlua/rlua dynamic-load shapes on Actix/Axum request bodies.
309
+ for (const finding of findRustEvalCrateCodeInjectionFindings(code, graph.ir.meta.file)) {
310
+ ctx.addFinding(finding);
311
+ }
288
312
  }
289
313
  // -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
290
314
  // sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
@@ -318,6 +342,11 @@ export class LanguageSourcesPass {
318
342
  for (const finding of findJsDomOpenRedirectFindings(code, graph.ir.meta.file)) {
319
343
  ctx.addFinding(finding);
320
344
  }
345
+ // Sprint 83 (#189): JS code_injection — indirect eval forms
346
+ // ((0, eval)(x), globalThis.eval(x), aliased eval).
347
+ for (const finding of findJsIndirectEvalCodeInjectionFindings(code, graph.ir.meta.file)) {
348
+ ctx.addFinding(finding);
349
+ }
321
350
  }
322
351
  // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
323
352
  // fromJson recognized as ETE terminator (does not affect
@@ -341,6 +370,11 @@ export class LanguageSourcesPass {
341
370
  for (const finding of findJavaResponseWriterXssFindings(code, graph.ir.meta.file)) {
342
371
  ctx.addFinding(finding);
343
372
  }
373
+ // Sprint 84 (#189): Java nosql_injection — MongoCollection find / insert /
374
+ // update / delete / aggregate with Filters.eq("k", <tainted servlet input>).
375
+ for (const finding of findJavaMongoNosqlInjectionFindings(code, graph.ir.meta.file)) {
376
+ ctx.addFinding(finding);
377
+ }
344
378
  }
345
379
  // Sprint 70 (#151): cross-language env-secret → external-network exfiltration.
346
380
  // Pattern findings only — no taint flow required (composed-flow shape that
@@ -5135,4 +5169,852 @@ export function findJsDomOpenRedirectFindings(code, file) {
5135
5169
  }
5136
5170
  return out;
5137
5171
  }
5172
+ // ---------------------------------------------------------------------------
5173
+ // Sprint 83 (issue #189 — code_injection cluster, 8 FN cells)
5174
+ // ---------------------------------------------------------------------------
5175
+ // Per-language pattern detectors that close 4 remaining code_injection FN
5176
+ // shapes that the configured-sink path does not cover:
5177
+ // - Go plugin.Open / plugin.Lookup with *http.Request-derived path
5178
+ // - JS indirect eval forms: (0, eval)(x), globalThis.eval(x), aliased eval
5179
+ // - Python code.InteractiveInterpreter / InteractiveConsole / compile_command
5180
+ // - Rust evalexpr crate, libloading dynamic load, mlua/rlua .load().exec
5181
+ // ---------------------------------------------------------------------------
5182
+ /**
5183
+ * Sprint 83 detector A — Go `plugin.Open(<tainted>)` / `plugin.Lookup(...)`.
5184
+ * Loading a Go plugin executes the loaded module's init() functions and
5185
+ * makes its exported symbols callable, which is a code-injection sink
5186
+ * equivalent to dynamic library loading. Fires when the path argument
5187
+ * traces back to an `*http.Request` extractor (FormValue / URL.Query /
5188
+ * PostFormValue / Header.Get / Cookie) within the same function.
5189
+ */
5190
+ export function findGoPluginOpenCodeInjectionFindings(code, file) {
5191
+ const findings = [];
5192
+ if (typeof code !== 'string' || code.length === 0)
5193
+ return findings;
5194
+ if (!/\bplugin\s*\.\s*(?:Open|Lookup)\s*\(/.test(code))
5195
+ return findings;
5196
+ const lines = code.split('\n');
5197
+ const reqExtractRe = /\b\w+\s*\.\s*(?:FormValue|PostFormValue|URL\.Query\(\)\.Get|Header\.Get|Cookie)\s*\(/;
5198
+ const httpReqParamRe = /\*\s*http\.Request\b/;
5199
+ const callRe = /\bplugin\s*\.\s*(?:Open|Lookup)\s*\(\s*([^)]*)\s*\)/;
5200
+ const sinkLabel = (op) => op === 'Lookup'
5201
+ ? 'Go plugin.Lookup'
5202
+ : 'Go plugin.Open';
5203
+ const funcs = [];
5204
+ let cur = null;
5205
+ for (let i = 0; i < lines.length; i++) {
5206
+ const t = lines[i].trim();
5207
+ if (/^func\b/.test(t)) {
5208
+ if (cur) {
5209
+ cur.end = i - 1;
5210
+ funcs.push(cur);
5211
+ }
5212
+ cur = { start: i, end: lines.length - 1 };
5213
+ }
5214
+ }
5215
+ if (cur)
5216
+ funcs.push(cur);
5217
+ for (const fn of funcs) {
5218
+ const header = lines[fn.start];
5219
+ if (!httpReqParamRe.test(header))
5220
+ continue;
5221
+ const taintedVars = new Set();
5222
+ // Up to 3 passes: discover transitively-tainted vars from request extractors.
5223
+ for (let pass = 0; pass < 3; pass++) {
5224
+ const before = taintedVars.size;
5225
+ for (let i = fn.start; i <= fn.end; i++) {
5226
+ const line = lines[i];
5227
+ const trimmed = line.trim();
5228
+ const assignMatch = trimmed.match(/^(\w+)\s*(?::=|=)\s*(.+?)(?:\s*\/\/.*)?$/);
5229
+ if (!assignMatch)
5230
+ continue;
5231
+ const lhs = assignMatch[1];
5232
+ const rhs = assignMatch[2];
5233
+ if (taintedVars.has(lhs))
5234
+ continue;
5235
+ if (reqExtractRe.test(rhs)) {
5236
+ taintedVars.add(lhs);
5237
+ continue;
5238
+ }
5239
+ // Aliasing existing taint
5240
+ for (const v of taintedVars) {
5241
+ const re = new RegExp(`\\b${v}\\b`);
5242
+ if (re.test(rhs)) {
5243
+ taintedVars.add(lhs);
5244
+ break;
5245
+ }
5246
+ }
5247
+ }
5248
+ if (taintedVars.size === before)
5249
+ break;
5250
+ }
5251
+ if (taintedVars.size === 0)
5252
+ continue;
5253
+ for (let i = fn.start; i <= fn.end; i++) {
5254
+ const line = lines[i];
5255
+ const m = line.match(callRe);
5256
+ if (!m)
5257
+ continue;
5258
+ const arg = m[1].trim();
5259
+ if (arg.length === 0)
5260
+ continue;
5261
+ // skip clear literal-only constants
5262
+ if (/^"[^"]*"$/.test(arg))
5263
+ continue;
5264
+ let tainted = false;
5265
+ // direct extractor call as argument
5266
+ if (reqExtractRe.test(arg))
5267
+ tainted = true;
5268
+ else {
5269
+ for (const v of taintedVars) {
5270
+ const re = new RegExp(`\\b${v}\\b`);
5271
+ if (re.test(arg)) {
5272
+ tainted = true;
5273
+ break;
5274
+ }
5275
+ }
5276
+ }
5277
+ if (!tainted)
5278
+ continue;
5279
+ const op = /\bplugin\s*\.\s*Lookup\b/.test(line) ? 'Lookup' : 'Open';
5280
+ findings.push({
5281
+ id: `code_injection-${file}-${i + 1}-go-plugin-${op.toLowerCase()}`,
5282
+ pass: 'language-sources',
5283
+ category: 'security',
5284
+ rule_id: 'code_injection',
5285
+ cwe: 'CWE-94',
5286
+ severity: 'critical',
5287
+ level: 'error',
5288
+ message: `Code injection: ${sinkLabel(op)} called with a path/symbol derived ` +
5289
+ 'from an *http.Request without an allow-list. Loading a plugin ' +
5290
+ 'runs its init() and exposes arbitrary exported symbols. Restrict ' +
5291
+ 'the path to a trusted directory or use a fixed allow-list.',
5292
+ file,
5293
+ line: i + 1,
5294
+ snippet: line.trim(),
5295
+ });
5296
+ }
5297
+ }
5298
+ return findings;
5299
+ }
5300
+ /**
5301
+ * Sprint 83 detector B — JS indirect eval forms.
5302
+ * Configured `eval` sink matches direct `eval(x)` but misses:
5303
+ * - `(0, eval)(x)` comma-operator indirect call
5304
+ * - `globalThis.eval(x)` / `global.eval(x)` / `window.eval(x)` / `self.eval(x)`
5305
+ * - aliased eval: `const f = eval; f(x)` then `f(taint)`
5306
+ * Fires when the argument traces back to req.body / req.query / req.params /
5307
+ * req.headers / req.cookies (Express/Koa-style request extractor).
5308
+ */
5309
+ export function findJsIndirectEvalCodeInjectionFindings(code, file) {
5310
+ const findings = [];
5311
+ if (typeof code !== 'string' || code.length === 0)
5312
+ return findings;
5313
+ if (!/\beval\b/.test(code))
5314
+ return findings;
5315
+ const lines = code.split('\n');
5316
+ // request extractor (assignment rhs)
5317
+ const reqExtractRe = /\breq(?:uest)?\s*\.\s*(?:body|query|params|headers|cookies)\b/;
5318
+ // First: discover indirect-eval aliases: `const f = eval;`, `let f = eval`
5319
+ const aliasRe = /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:globalThis\s*\.\s*eval|global\s*\.\s*eval|window\s*\.\s*eval|self\s*\.\s*eval|eval)\s*;?\s*$/;
5320
+ const aliases = new Set();
5321
+ for (const line of lines) {
5322
+ const m = line.match(aliasRe);
5323
+ if (m)
5324
+ aliases.add(m[1]);
5325
+ }
5326
+ // Discover transitively-tainted vars
5327
+ const taintedVars = new Set();
5328
+ const assignRe = /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(.+?);?\s*$/;
5329
+ const reassignRe = /^\s*(\w+)\s*=\s*(.+?);?\s*$/;
5330
+ for (let pass = 0; pass < 3; pass++) {
5331
+ const before = taintedVars.size;
5332
+ for (const line of lines) {
5333
+ const m = line.match(assignRe) || line.match(reassignRe);
5334
+ if (!m)
5335
+ continue;
5336
+ const lhs = m[1];
5337
+ const rhs = m[2];
5338
+ if (taintedVars.has(lhs))
5339
+ continue;
5340
+ if (lhs === 'const' || lhs === 'let' || lhs === 'var')
5341
+ continue;
5342
+ if (reqExtractRe.test(rhs)) {
5343
+ taintedVars.add(lhs);
5344
+ continue;
5345
+ }
5346
+ for (const v of taintedVars) {
5347
+ const re = new RegExp(`\\b${v}\\b`);
5348
+ if (re.test(rhs)) {
5349
+ taintedVars.add(lhs);
5350
+ break;
5351
+ }
5352
+ }
5353
+ }
5354
+ if (taintedVars.size === before)
5355
+ break;
5356
+ }
5357
+ // Patterns: (0, eval)(x); globalThis.eval(x); aliased f(x)
5358
+ const indirectCommaRe = /\(\s*0\s*,\s*eval\s*\)\s*\(\s*([^)]*)\s*\)/;
5359
+ const indirectMemberRe = /\b(?:globalThis|global|window|self)\s*\.\s*eval\s*\(\s*([^)]*)\s*\)/;
5360
+ for (let i = 0; i < lines.length; i++) {
5361
+ const line = lines[i];
5362
+ const trimmed = line.trim();
5363
+ // skip comment lines and alias-declaration lines themselves
5364
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
5365
+ continue;
5366
+ if (aliasRe.test(line))
5367
+ continue;
5368
+ let arg = null;
5369
+ let formLabel = '';
5370
+ let m = trimmed.match(indirectCommaRe);
5371
+ if (m) {
5372
+ arg = m[1].trim();
5373
+ formLabel = '(0, eval)(...) indirect eval';
5374
+ }
5375
+ if (!arg) {
5376
+ m = trimmed.match(indirectMemberRe);
5377
+ if (m) {
5378
+ arg = m[1].trim();
5379
+ formLabel = 'globalThis.eval / window.eval / self.eval indirect eval';
5380
+ }
5381
+ }
5382
+ if (!arg && aliases.size > 0) {
5383
+ for (const a of aliases) {
5384
+ const aliasCallRe = new RegExp(`\\b${a}\\s*\\(\\s*([^)]*)\\s*\\)`);
5385
+ const mm = trimmed.match(aliasCallRe);
5386
+ if (mm) {
5387
+ arg = mm[1].trim();
5388
+ formLabel = `aliased eval reference \`${a}(...)\``;
5389
+ break;
5390
+ }
5391
+ }
5392
+ }
5393
+ if (arg === null)
5394
+ continue;
5395
+ if (arg.length === 0)
5396
+ continue;
5397
+ // skip literal-only string args
5398
+ if (/^['"`][^'"`]*['"`]$/.test(arg))
5399
+ continue;
5400
+ let tainted = false;
5401
+ if (reqExtractRe.test(arg))
5402
+ tainted = true;
5403
+ else {
5404
+ for (const v of taintedVars) {
5405
+ const re = new RegExp(`\\b${v}\\b`);
5406
+ if (re.test(arg)) {
5407
+ tainted = true;
5408
+ break;
5409
+ }
5410
+ }
5411
+ }
5412
+ if (!tainted)
5413
+ continue;
5414
+ findings.push({
5415
+ id: `code_injection-${file}-${i + 1}-js-indirect-eval`,
5416
+ pass: 'language-sources',
5417
+ category: 'security',
5418
+ rule_id: 'code_injection',
5419
+ cwe: 'CWE-94',
5420
+ severity: 'critical',
5421
+ level: 'error',
5422
+ message: `Code injection: ${formLabel} called with a value derived from ` +
5423
+ 'an HTTP request body/query/headers. Indirect eval forms still ' +
5424
+ 'execute arbitrary code in the global scope. Remove the eval and ' +
5425
+ 'parse the input with a safe data parser instead.',
5426
+ file,
5427
+ line: i + 1,
5428
+ snippet: trimmed,
5429
+ });
5430
+ }
5431
+ return findings;
5432
+ }
5433
+ /**
5434
+ * Sprint 83 detector C — Python `code` stdlib REPL / compile_command.
5435
+ * `code.InteractiveInterpreter().runsource(s)`, `runcode(c)`, `push(line)` and
5436
+ * `code.InteractiveConsole().push(line)`, plus `code.compile_command(s)` —
5437
+ * all execute or compile arbitrary Python source. Fires when the argument
5438
+ * traces back to a Flask `request.*` extractor; gated on `import code`
5439
+ * to avoid colliding with user-defined `code` variables.
5440
+ */
5441
+ export function findPythonInteractiveInterpreterCodeInjectionFindings(code, file) {
5442
+ const findings = [];
5443
+ if (typeof code !== 'string' || code.length === 0)
5444
+ return findings;
5445
+ // Require `import code` namespace gate to avoid clashing with user-defined
5446
+ // identifiers named `code`.
5447
+ if (!/^\s*import\s+code\b/m.test(code))
5448
+ return findings;
5449
+ if (!/\bcode\s*\.\s*(?:InteractiveInterpreter|InteractiveConsole|compile_command)\b/.test(code)) {
5450
+ return findings;
5451
+ }
5452
+ const lines = code.split('\n');
5453
+ const reqExtractRe = /\brequest\s*\.\s*(?:args|form|values|files|json|cookies|headers|get_data|get_json)\b/;
5454
+ // Discover transitively-tainted vars
5455
+ const taintedVars = new Set();
5456
+ const assignRe = /^\s*(\w+)\s*=\s*(.+?)\s*(?:#.*)?$/;
5457
+ for (let pass = 0; pass < 3; pass++) {
5458
+ const before = taintedVars.size;
5459
+ for (const line of lines) {
5460
+ const m = line.match(assignRe);
5461
+ if (!m)
5462
+ continue;
5463
+ const lhs = m[1];
5464
+ const rhs = m[2];
5465
+ if (taintedVars.has(lhs))
5466
+ continue;
5467
+ if (reqExtractRe.test(rhs)) {
5468
+ taintedVars.add(lhs);
5469
+ continue;
5470
+ }
5471
+ for (const v of taintedVars) {
5472
+ const re = new RegExp(`\\b${v}\\b`);
5473
+ if (re.test(rhs)) {
5474
+ taintedVars.add(lhs);
5475
+ break;
5476
+ }
5477
+ }
5478
+ }
5479
+ if (taintedVars.size === before)
5480
+ break;
5481
+ }
5482
+ // Sink call patterns
5483
+ // a) code.InteractiveInterpreter(...).{runsource,runcode,push}(arg)
5484
+ // b) code.InteractiveConsole(...).{runsource,runcode,push,interact}(arg)
5485
+ // c) code.compile_command(arg, ...)
5486
+ const callRe = /\bcode\s*\.\s*(?:InteractiveInterpreter|InteractiveConsole)\s*\([^)]*\)\s*\.\s*(runsource|runcode|push|interact)\s*\(\s*([^),]+)/;
5487
+ const compileRe = /\bcode\s*\.\s*compile_command\s*\(\s*([^),]+)/;
5488
+ for (let i = 0; i < lines.length; i++) {
5489
+ const line = lines[i];
5490
+ const trimmed = line.trim();
5491
+ let arg = null;
5492
+ let formLabel = '';
5493
+ const m1 = trimmed.match(callRe);
5494
+ if (m1) {
5495
+ arg = m1[2].trim();
5496
+ formLabel = `code.${/Interpreter/.test(trimmed) ? 'InteractiveInterpreter' : 'InteractiveConsole'}().${m1[1]}`;
5497
+ }
5498
+ if (!arg) {
5499
+ const m2 = trimmed.match(compileRe);
5500
+ if (m2) {
5501
+ arg = m2[1].trim();
5502
+ formLabel = 'code.compile_command';
5503
+ }
5504
+ }
5505
+ if (arg === null)
5506
+ continue;
5507
+ if (arg.length === 0)
5508
+ continue;
5509
+ if (/^['"][^'"]*['"]$/.test(arg))
5510
+ continue;
5511
+ let tainted = false;
5512
+ if (reqExtractRe.test(arg))
5513
+ tainted = true;
5514
+ else {
5515
+ for (const v of taintedVars) {
5516
+ const re = new RegExp(`\\b${v}\\b`);
5517
+ if (re.test(arg)) {
5518
+ tainted = true;
5519
+ break;
5520
+ }
5521
+ }
5522
+ }
5523
+ if (!tainted)
5524
+ continue;
5525
+ findings.push({
5526
+ id: `code_injection-${file}-${i + 1}-py-interactive-interpreter`,
5527
+ pass: 'language-sources',
5528
+ category: 'security',
5529
+ rule_id: 'code_injection',
5530
+ cwe: 'CWE-94',
5531
+ severity: 'critical',
5532
+ level: 'error',
5533
+ message: `Code injection: ${formLabel}(...) called with a value derived from ` +
5534
+ 'a Flask request extractor. The Python `code` module compiles and ' +
5535
+ 'executes arbitrary source. Remove the call and validate input ' +
5536
+ 'against a fixed allow-list instead.',
5537
+ file,
5538
+ line: i + 1,
5539
+ snippet: trimmed,
5540
+ });
5541
+ }
5542
+ return findings;
5543
+ }
5544
+ /**
5545
+ * Sprint 83 detector D — Rust eval-crate / dynamic-load sinks.
5546
+ * Rust has no language-level eval; the canonical sinks are:
5547
+ * - `evalexpr::eval(...)` (and `_with_context|_boolean|_int|_float|_string|_tuple|_empty`)
5548
+ * - `libloading::Library::new(...)` (dynamic library load)
5549
+ * - `mlua::Lua::new().load(<src>).{exec,eval,call}(...)` / rlua equivalent
5550
+ * Fires when the argument traces back to an Actix-web extractor: a plain
5551
+ * `body: String|Bytes`, a `web::Query<T>` / `web::Path<T>` / `web::Form<T>` /
5552
+ * `web::Json<T>` / `HttpRequest` param, or `axum::body::Bytes`/`String` etc.
5553
+ */
5554
+ export function findRustEvalCrateCodeInjectionFindings(code, file) {
5555
+ const findings = [];
5556
+ if (typeof code !== 'string' || code.length === 0)
5557
+ return findings;
5558
+ if (!/\b(?:evalexpr\s*::\s*eval|libloading\s*::\s*Library\s*::\s*new|\.\s*load\s*\([^)]*\)\s*\.\s*(?:exec|eval|call))/.test(code)) {
5559
+ return findings;
5560
+ }
5561
+ const lines = code.split('\n');
5562
+ // Discover per-function tainted params: scan `fn ...(params)` headers and
5563
+ // mark params with extractor types as tainted.
5564
+ 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)/;
5565
+ const fns = [];
5566
+ let cur = null;
5567
+ for (let i = 0; i < lines.length; i++) {
5568
+ const t = lines[i];
5569
+ if (/^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+\s*\(/.test(t)) {
5570
+ if (cur) {
5571
+ cur.end = i - 1;
5572
+ fns.push(cur);
5573
+ }
5574
+ cur = { start: i, end: lines.length - 1, tainted: new Set() };
5575
+ // collect param idents whose type matches extractor
5576
+ const headerJoined = (() => {
5577
+ let j = i;
5578
+ let s = '';
5579
+ while (j < lines.length && !/\{\s*$/.test(s)) {
5580
+ s += lines[j];
5581
+ if (/\{\s*$/.test(lines[j]))
5582
+ break;
5583
+ j++;
5584
+ if (j - i > 12)
5585
+ break;
5586
+ }
5587
+ return s;
5588
+ })();
5589
+ // params are between first '(' and matching ')'
5590
+ const open = headerJoined.indexOf('(');
5591
+ const close = headerJoined.lastIndexOf(')');
5592
+ if (open !== -1 && close > open) {
5593
+ const params = headerJoined.substring(open + 1, close);
5594
+ // Split by top-level commas
5595
+ let depth = 0;
5596
+ let buf = '';
5597
+ const parts = [];
5598
+ for (const ch of params) {
5599
+ if (ch === '<' || ch === '(')
5600
+ depth++;
5601
+ else if (ch === '>' || ch === ')')
5602
+ depth--;
5603
+ if (ch === ',' && depth === 0) {
5604
+ parts.push(buf);
5605
+ buf = '';
5606
+ continue;
5607
+ }
5608
+ buf += ch;
5609
+ }
5610
+ if (buf.trim().length > 0)
5611
+ parts.push(buf);
5612
+ for (const p of parts) {
5613
+ const pm = p.match(/(?:mut\s+)?(\w+)\s*:/);
5614
+ if (!pm)
5615
+ continue;
5616
+ if (extractorTypeRe.test(p))
5617
+ cur.tainted.add(pm[1]);
5618
+ }
5619
+ }
5620
+ }
5621
+ }
5622
+ if (cur)
5623
+ fns.push(cur);
5624
+ // Inside each function, scan assignments (let / let mut) to propagate
5625
+ // taint from existing tainted vars into new bindings.
5626
+ for (const fn of fns) {
5627
+ for (let pass = 0; pass < 3; pass++) {
5628
+ const before = fn.tainted.size;
5629
+ for (let i = fn.start; i <= fn.end; i++) {
5630
+ const t = lines[i].trim();
5631
+ const m = t.match(/^let\s+(?:mut\s+)?(\w+)\s*(?::\s*[^=]+)?=\s*(.+?);?$/);
5632
+ if (!m)
5633
+ continue;
5634
+ const lhs = m[1];
5635
+ const rhs = m[2];
5636
+ if (fn.tainted.has(lhs))
5637
+ continue;
5638
+ for (const v of fn.tainted) {
5639
+ const re = new RegExp(`\\b${v}\\b`);
5640
+ if (re.test(rhs)) {
5641
+ fn.tainted.add(lhs);
5642
+ break;
5643
+ }
5644
+ }
5645
+ }
5646
+ if (fn.tainted.size === before)
5647
+ break;
5648
+ }
5649
+ }
5650
+ const evalExprRe = /\bevalexpr\s*::\s*eval(?:_with_context|_boolean|_int|_float|_string|_tuple|_empty)?\s*\(\s*([^)]+)\s*\)/;
5651
+ const libloadingRe = /\blibloading\s*::\s*Library\s*::\s*new\s*\(\s*([^)]+)\s*\)/;
5652
+ const luaLoadRe = /\.\s*load\s*\(\s*([^)]+)\s*\)\s*\.\s*(?:exec|eval|call)\b/;
5653
+ for (const fn of fns) {
5654
+ if (fn.tainted.size === 0)
5655
+ continue;
5656
+ for (let i = fn.start; i <= fn.end; i++) {
5657
+ const line = lines[i];
5658
+ const trimmed = line.trim();
5659
+ let arg = null;
5660
+ let formLabel = '';
5661
+ let m = trimmed.match(evalExprRe);
5662
+ if (m) {
5663
+ arg = m[1].trim();
5664
+ formLabel = 'evalexpr::eval';
5665
+ }
5666
+ if (!arg) {
5667
+ m = trimmed.match(libloadingRe);
5668
+ if (m) {
5669
+ arg = m[1].trim();
5670
+ formLabel = 'libloading::Library::new';
5671
+ }
5672
+ }
5673
+ if (!arg) {
5674
+ m = trimmed.match(luaLoadRe);
5675
+ if (m) {
5676
+ arg = m[1].trim();
5677
+ formLabel = 'mlua/rlua Lua::load().{exec|eval|call}';
5678
+ }
5679
+ }
5680
+ if (arg === null)
5681
+ continue;
5682
+ if (arg.length === 0)
5683
+ continue;
5684
+ // unwrap leading '&' borrow
5685
+ let unwrapped = arg.replace(/^&\s*/, '').trim();
5686
+ if (/^"[^"]*"$/.test(unwrapped))
5687
+ continue;
5688
+ let tainted = false;
5689
+ for (const v of fn.tainted) {
5690
+ const re = new RegExp(`\\b${v}\\b`);
5691
+ if (re.test(unwrapped)) {
5692
+ tainted = true;
5693
+ break;
5694
+ }
5695
+ }
5696
+ if (!tainted)
5697
+ continue;
5698
+ findings.push({
5699
+ id: `code_injection-${file}-${i + 1}-rust-eval-crate`,
5700
+ pass: 'language-sources',
5701
+ category: 'security',
5702
+ rule_id: 'code_injection',
5703
+ cwe: 'CWE-94',
5704
+ severity: 'critical',
5705
+ level: 'error',
5706
+ message: `Code injection: ${formLabel}(...) called with a value derived ` +
5707
+ 'from an HTTP request extractor (body / Query / Path / Form / ' +
5708
+ 'Json / HttpRequest). The expression / library / Lua chunk is ' +
5709
+ 'executed as code. Remove the dynamic-eval path or restrict ' +
5710
+ 'input to a fixed allow-list.',
5711
+ file,
5712
+ line: i + 1,
5713
+ snippet: trimmed,
5714
+ });
5715
+ }
5716
+ }
5717
+ return findings;
5718
+ }
5719
+ /**
5720
+ * Sprint 84 detector A (#189) — Go MongoDB driver nosql_injection.
5721
+ * The Go MongoDB driver call shape `coll.FindOne(ctx, bson.M{"k": <taint>})`
5722
+ * (and siblings: Find / InsertOne / InsertMany / UpdateOne / UpdateMany /
5723
+ * DeleteOne / DeleteMany / FindOneAndUpdate / FindOneAndDelete /
5724
+ * FindOneAndReplace / Aggregate) is not modeled by configured sinks (those
5725
+ * cover Node.js Mongo only). Fires when the filter argument (after `ctx`)
5726
+ * references a value transitively derived from `*http.Request` extractors
5727
+ * (URL.Query().Get / FormValue / PostFormValue / Header.Get / Cookie).
5728
+ */
5729
+ export function findGoMongoNosqlInjectionFindings(code, file) {
5730
+ const findings = [];
5731
+ if (typeof code !== 'string' || code.length === 0)
5732
+ return findings;
5733
+ // Gate: require some hint of the mongo-driver / bson namespace usage.
5734
+ if (!/(\bbson\s*\.\s*[MDAE]\b|mongo-driver|\*\s*mongo\.Collection)/.test(code)) {
5735
+ return findings;
5736
+ }
5737
+ const lines = code.split('\n');
5738
+ const reqExtractRe = /\b\w+\s*\.\s*(?:FormValue|PostFormValue|URL\s*\.\s*Query\s*\(\s*\)\s*\.\s*Get|Header\s*\.\s*Get|Cookie)\s*\(/;
5739
+ const httpReqParamRe = /\*\s*http\.Request\b/;
5740
+ const opsAlt = '(?:FindOne|Find|InsertOne|InsertMany|UpdateOne|UpdateMany|DeleteOne|DeleteMany|FindOneAndUpdate|FindOneAndDelete|FindOneAndReplace|Aggregate)';
5741
+ // Match call-site head only; balanced parens for args are extracted below.
5742
+ const callHeadRe = new RegExp(`\\.\\s*(${opsAlt})\\s*\\(`);
5743
+ function extractBalanced(line, openIdx) {
5744
+ let depth = 0;
5745
+ for (let k = openIdx; k < line.length; k++) {
5746
+ const ch = line[k];
5747
+ if (ch === '(')
5748
+ depth++;
5749
+ else if (ch === ')') {
5750
+ depth--;
5751
+ if (depth === 0)
5752
+ return line.substring(openIdx + 1, k);
5753
+ }
5754
+ }
5755
+ return null;
5756
+ }
5757
+ const funcs = [];
5758
+ let cur = null;
5759
+ for (let i = 0; i < lines.length; i++) {
5760
+ const t = lines[i].trim();
5761
+ if (/^func\b/.test(t)) {
5762
+ if (cur) {
5763
+ cur.end = i - 1;
5764
+ funcs.push(cur);
5765
+ }
5766
+ cur = { start: i, end: lines.length - 1 };
5767
+ }
5768
+ }
5769
+ if (cur)
5770
+ funcs.push(cur);
5771
+ for (const fn of funcs) {
5772
+ const header = lines[fn.start];
5773
+ if (!httpReqParamRe.test(header))
5774
+ continue;
5775
+ const taintedVars = new Set();
5776
+ for (let pass = 0; pass < 3; pass++) {
5777
+ const before = taintedVars.size;
5778
+ for (let i = fn.start; i <= fn.end; i++) {
5779
+ const trimmed = lines[i].trim();
5780
+ const assignMatch = trimmed.match(/^(\w+)\s*(?::=|=)\s*(.+?)(?:\s*\/\/.*)?$/);
5781
+ if (!assignMatch)
5782
+ continue;
5783
+ const lhs = assignMatch[1];
5784
+ const rhs = assignMatch[2];
5785
+ if (taintedVars.has(lhs))
5786
+ continue;
5787
+ if (reqExtractRe.test(rhs)) {
5788
+ taintedVars.add(lhs);
5789
+ continue;
5790
+ }
5791
+ for (const v of taintedVars) {
5792
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
5793
+ taintedVars.add(lhs);
5794
+ break;
5795
+ }
5796
+ }
5797
+ }
5798
+ if (taintedVars.size === before)
5799
+ break;
5800
+ }
5801
+ if (taintedVars.size === 0)
5802
+ continue;
5803
+ for (let i = fn.start; i <= fn.end; i++) {
5804
+ const line = lines[i];
5805
+ const m = callHeadRe.exec(line);
5806
+ if (!m)
5807
+ continue;
5808
+ const op = m[1];
5809
+ const openIdx = m.index + m[0].length - 1;
5810
+ const args = extractBalanced(line, openIdx);
5811
+ if (args === null || args.length === 0)
5812
+ continue;
5813
+ let tainted = reqExtractRe.test(args);
5814
+ if (!tainted) {
5815
+ for (const v of taintedVars) {
5816
+ if (new RegExp(`\\b${v}\\b`).test(args)) {
5817
+ tainted = true;
5818
+ break;
5819
+ }
5820
+ }
5821
+ }
5822
+ if (!tainted)
5823
+ continue;
5824
+ findings.push({
5825
+ id: `nosql_injection-${file}-${i + 1}-go-mongo-${op.toLowerCase()}`,
5826
+ pass: 'language-sources',
5827
+ category: 'security',
5828
+ rule_id: 'nosql_injection',
5829
+ cwe: 'CWE-943',
5830
+ severity: 'critical',
5831
+ level: 'error',
5832
+ message: `NoSQL injection: Go MongoDB driver \`${op}(...)\` called with a ` +
5833
+ 'filter derived from *http.Request input. Untrusted values inside ' +
5834
+ 'a `bson.M` / `bson.D` filter can be operator objects (e.g. ' +
5835
+ '`{"$ne": null}`) and bypass authentication/intent. Validate or ' +
5836
+ 'coerce the value to a primitive before building the filter.',
5837
+ file,
5838
+ line: i + 1,
5839
+ snippet: line.trim(),
5840
+ });
5841
+ }
5842
+ }
5843
+ return findings;
5844
+ }
5845
+ /**
5846
+ * Sprint 84 detector B (#189) — Java Mongo driver nosql_injection.
5847
+ * Mongo Java driver shape `users.find(eq("k", <taint>))` (and siblings)
5848
+ * is not modeled by configured sinks (those cover Node.js Mongo only).
5849
+ * Fires when the receiver call payload references a value transitively
5850
+ * derived from servlet request extractors (request.getParameter /
5851
+ * getHeader / getCookies / getReader / getQueryString / getPart).
5852
+ */
5853
+ export function findJavaMongoNosqlInjectionFindings(code, file) {
5854
+ const findings = [];
5855
+ if (typeof code !== 'string' || code.length === 0)
5856
+ return findings;
5857
+ // Gate: require some hint of MongoCollection / Filters / Document use.
5858
+ if (!/(MongoCollection\b|com\.mongodb\b|\bFilters\b|\bnew\s+Document\s*\()/.test(code)) {
5859
+ return findings;
5860
+ }
5861
+ const lines = code.split('\n');
5862
+ const reqExtractRe = /\b\w+\s*\.\s*(?:getParameter|getParameterValues|getHeader|getHeaders|getCookies|getReader|getQueryString|getRequestURI|getInputStream|getPart|getParts)\s*\(/;
5863
+ const opsAlt = '(?:find|findOne|findOneAndUpdate|findOneAndDelete|findOneAndReplace|insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|replaceOne|aggregate|countDocuments|distinct)';
5864
+ const callRe = new RegExp(`\\.\\s*(${opsAlt})\\s*\\(([\\s\\S]*?)\\)`);
5865
+ // Whole-file 3-pass taint propagation across simple `Type name = expr;`
5866
+ // and `name = expr;` assignments. Lightweight; suitable for the fixture
5867
+ // set without paying for full Java scope tracking.
5868
+ const taintedVars = new Set();
5869
+ for (let pass = 0; pass < 3; pass++) {
5870
+ const before = taintedVars.size;
5871
+ for (let i = 0; i < lines.length; i++) {
5872
+ const t = lines[i].trim();
5873
+ const a = t.match(/^(?:final\s+)?(?:[\w<>?,\[\]]+\s+)?(\w+)\s*=\s*(.+?);?$/);
5874
+ if (!a)
5875
+ continue;
5876
+ const lhs = a[1];
5877
+ const rhs = a[2];
5878
+ if (taintedVars.has(lhs))
5879
+ continue;
5880
+ if (reqExtractRe.test(rhs)) {
5881
+ taintedVars.add(lhs);
5882
+ continue;
5883
+ }
5884
+ for (const v of taintedVars) {
5885
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
5886
+ taintedVars.add(lhs);
5887
+ break;
5888
+ }
5889
+ }
5890
+ }
5891
+ if (taintedVars.size === before)
5892
+ break;
5893
+ }
5894
+ if (taintedVars.size === 0)
5895
+ return findings;
5896
+ for (let i = 0; i < lines.length; i++) {
5897
+ const line = lines[i];
5898
+ const m = line.match(callRe);
5899
+ if (!m)
5900
+ continue;
5901
+ const op = m[1];
5902
+ const args = m[2];
5903
+ if (args.trim().length === 0)
5904
+ continue;
5905
+ let tainted = reqExtractRe.test(args);
5906
+ if (!tainted) {
5907
+ for (const v of taintedVars) {
5908
+ if (new RegExp(`\\b${v}\\b`).test(args)) {
5909
+ tainted = true;
5910
+ break;
5911
+ }
5912
+ }
5913
+ }
5914
+ if (!tainted)
5915
+ continue;
5916
+ findings.push({
5917
+ id: `nosql_injection-${file}-${i + 1}-java-mongo-${op.toLowerCase()}`,
5918
+ pass: 'language-sources',
5919
+ category: 'security',
5920
+ rule_id: 'nosql_injection',
5921
+ cwe: 'CWE-943',
5922
+ severity: 'critical',
5923
+ level: 'error',
5924
+ message: `NoSQL injection: Java Mongo driver \`${op}(...)\` called with a ` +
5925
+ 'filter derived from servlet request input. Untrusted values can ' +
5926
+ 'reach BSON operator positions and bypass intent. Validate the ' +
5927
+ 'input type before constructing the filter (e.g. require String).',
5928
+ file,
5929
+ line: i + 1,
5930
+ snippet: line.trim(),
5931
+ });
5932
+ }
5933
+ return findings;
5934
+ }
5935
+ /**
5936
+ * Sprint 84 detector C (#189) — Python mongoengine `$where` JS-string injection.
5937
+ * The shape `User.objects(__raw__={'$where': "this.x == '" + n + "'"})`
5938
+ * (and aliases via `$where` key with string concat / f-string) bypasses
5939
+ * the configured nosql sinks. Fires when the `$where` value references
5940
+ * a value transitively derived from Flask/Django/FastAPI request input.
5941
+ */
5942
+ export function findPythonMongoengineWhereNosqlInjectionFindings(code, file) {
5943
+ const findings = [];
5944
+ if (typeof code !== 'string' || code.length === 0)
5945
+ return findings;
5946
+ // Gate: require literal '$where' to appear in the file.
5947
+ if (!/['"]\$where['"]/.test(code))
5948
+ return findings;
5949
+ const lines = code.split('\n');
5950
+ const reqExtractRe = /\b(?:request\.(?:args|form|values|json|files|cookies|headers|data)\b|flask\.request\b)/;
5951
+ const taintedVars = new Set();
5952
+ for (let pass = 0; pass < 3; pass++) {
5953
+ const before = taintedVars.size;
5954
+ for (let i = 0; i < lines.length; i++) {
5955
+ const t = lines[i].trim();
5956
+ if (t.startsWith('#'))
5957
+ continue;
5958
+ const a = t.match(/^(\w+)\s*=\s*(.+?)$/);
5959
+ if (!a)
5960
+ continue;
5961
+ const lhs = a[1];
5962
+ const rhs = a[2];
5963
+ if (taintedVars.has(lhs))
5964
+ continue;
5965
+ if (reqExtractRe.test(rhs)) {
5966
+ taintedVars.add(lhs);
5967
+ continue;
5968
+ }
5969
+ for (const v of taintedVars) {
5970
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
5971
+ taintedVars.add(lhs);
5972
+ break;
5973
+ }
5974
+ }
5975
+ }
5976
+ if (taintedVars.size === before)
5977
+ break;
5978
+ }
5979
+ const whereRe = /['"]\$where['"]\s*:\s*([^,}\n]+)/;
5980
+ for (let i = 0; i < lines.length; i++) {
5981
+ const line = lines[i];
5982
+ const m = line.match(whereRe);
5983
+ if (!m)
5984
+ continue;
5985
+ const valueExpr = m[1].trim();
5986
+ // Pure string literal (no concat, no f-string interp): safe.
5987
+ if (/^"[^"]*"$/.test(valueExpr) || /^'[^']*'$/.test(valueExpr))
5988
+ continue;
5989
+ let tainted = reqExtractRe.test(valueExpr);
5990
+ if (!tainted) {
5991
+ for (const v of taintedVars) {
5992
+ if (new RegExp(`\\b${v}\\b`).test(valueExpr)) {
5993
+ tainted = true;
5994
+ break;
5995
+ }
5996
+ }
5997
+ }
5998
+ if (!tainted)
5999
+ continue;
6000
+ findings.push({
6001
+ id: `nosql_injection-${file}-${i + 1}-py-mongoengine-where`,
6002
+ pass: 'language-sources',
6003
+ category: 'security',
6004
+ rule_id: 'nosql_injection',
6005
+ cwe: 'CWE-943',
6006
+ severity: 'critical',
6007
+ level: 'error',
6008
+ message: 'NoSQL injection: mongoengine `__raw__={"$where": ...}` payload ' +
6009
+ 'derived from HTTP request input. The `$where` operator evaluates ' +
6010
+ 'JavaScript on the server; tainted string concatenation lets an ' +
6011
+ 'attacker inject arbitrary JS. Replace `$where` with field-based ' +
6012
+ 'operators or validate the input.',
6013
+ file,
6014
+ line: i + 1,
6015
+ snippet: line.trim(),
6016
+ });
6017
+ }
6018
+ return findings;
6019
+ }
5138
6020
  //# sourceMappingURL=language-sources-pass.js.map