circle-ir 3.135.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 +74 -0
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +1000 -0
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/browser/circle-ir.js +768 -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') {
|
|
@@ -271,6 +276,17 @@ export class LanguageSourcesPass {
|
|
|
271
276
|
for (const finding of findPythonMongoengineWhereNosqlInjectionFindings(code, graph.ir.meta.file)) {
|
|
272
277
|
ctx.addFinding(finding);
|
|
273
278
|
}
|
|
279
|
+
// Sprint 86 (#189): Python format_string — `<tainted> % args` and
|
|
280
|
+
// `<tainted>.format(args)` where the format template comes from
|
|
281
|
+
// an HTTP request extractor.
|
|
282
|
+
for (const finding of findPythonTaintedFormatStringFindings(code, graph.ir.meta.file)) {
|
|
283
|
+
ctx.addFinding(finding);
|
|
284
|
+
}
|
|
285
|
+
// Sprint 86 (#189): Python crlf / header injection —
|
|
286
|
+
// `response.headers['X-Custom'] = <tainted>` (Flask/Werkzeug).
|
|
287
|
+
for (const finding of findPythonHeaderCrlfInjectionFindings(code, graph.ir.meta.file)) {
|
|
288
|
+
ctx.addFinding(finding);
|
|
289
|
+
}
|
|
274
290
|
}
|
|
275
291
|
// -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
|
|
276
292
|
if (language === 'rust') {
|
|
@@ -309,6 +325,16 @@ export class LanguageSourcesPass {
|
|
|
309
325
|
for (const finding of findRustEvalCrateCodeInjectionFindings(code, graph.ir.meta.file)) {
|
|
310
326
|
ctx.addFinding(finding);
|
|
311
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
|
+
}
|
|
312
338
|
}
|
|
313
339
|
// -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
|
|
314
340
|
// sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
|
|
@@ -347,6 +373,16 @@ export class LanguageSourcesPass {
|
|
|
347
373
|
for (const finding of findJsIndirectEvalCodeInjectionFindings(code, graph.ir.meta.file)) {
|
|
348
374
|
ctx.addFinding(finding);
|
|
349
375
|
}
|
|
376
|
+
// Sprint 86 (#189): JS format_string — `util.format(<tainted>, ...)`
|
|
377
|
+
// where the format template comes from an HTTP request extractor.
|
|
378
|
+
for (const finding of findJsUtilFormatFormatStringFindings(code, graph.ir.meta.file)) {
|
|
379
|
+
ctx.addFinding(finding);
|
|
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
|
+
}
|
|
350
386
|
}
|
|
351
387
|
// -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
|
|
352
388
|
// fromJson recognized as ETE terminator (does not affect
|
|
@@ -6131,4 +6167,968 @@ export function findJavaUrlOpenStreamSsrfFindings(code, file) {
|
|
|
6131
6167
|
}
|
|
6132
6168
|
return findings;
|
|
6133
6169
|
}
|
|
6170
|
+
/**
|
|
6171
|
+
* Sprint 86 detector A (#189) — Python uncontrolled format-string.
|
|
6172
|
+
*
|
|
6173
|
+
* Two shapes:
|
|
6174
|
+
* 1. `<tainted> % args` — old-style percent formatting
|
|
6175
|
+
* 2. `<tainted>.format(args)` — `str.format` API
|
|
6176
|
+
*
|
|
6177
|
+
* When the format string itself comes from HTTP request input, an
|
|
6178
|
+
* attacker can probe arbitrary attributes/items via `str.format`
|
|
6179
|
+
* (`{0.__class__.__init__.__globals__}`) or crash the process via
|
|
6180
|
+
* malformed `%` specifiers. CWE-134.
|
|
6181
|
+
*/
|
|
6182
|
+
export function findPythonTaintedFormatStringFindings(code, file) {
|
|
6183
|
+
const findings = [];
|
|
6184
|
+
if (typeof code !== 'string' || code.length === 0)
|
|
6185
|
+
return findings;
|
|
6186
|
+
// Gate: the file must use a Flask/Django/FastAPI request extractor.
|
|
6187
|
+
if (!/\b(?:request\.(?:args|form|values|json|files|cookies|headers|data)|flask\.request)\b/.test(code)) {
|
|
6188
|
+
return findings;
|
|
6189
|
+
}
|
|
6190
|
+
const lines = code.split('\n');
|
|
6191
|
+
const reqExtractRe = /\b(?:request\.(?:args|form|values|json|files|cookies|headers|data)\b|flask\.request\b)/;
|
|
6192
|
+
// 3-pass whole-file taint propagation across simple `name = expr`
|
|
6193
|
+
// assignments.
|
|
6194
|
+
const taintedVars = new Set();
|
|
6195
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
6196
|
+
const before = taintedVars.size;
|
|
6197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6198
|
+
const t = lines[i].trim();
|
|
6199
|
+
if (t.startsWith('#'))
|
|
6200
|
+
continue;
|
|
6201
|
+
const a = t.match(/^(\w+)\s*=\s*(.+?)$/);
|
|
6202
|
+
if (!a)
|
|
6203
|
+
continue;
|
|
6204
|
+
const lhs = a[1];
|
|
6205
|
+
const rhs = a[2];
|
|
6206
|
+
if (taintedVars.has(lhs))
|
|
6207
|
+
continue;
|
|
6208
|
+
if (reqExtractRe.test(rhs)) {
|
|
6209
|
+
taintedVars.add(lhs);
|
|
6210
|
+
continue;
|
|
6211
|
+
}
|
|
6212
|
+
for (const v of taintedVars) {
|
|
6213
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
6214
|
+
taintedVars.add(lhs);
|
|
6215
|
+
break;
|
|
6216
|
+
}
|
|
6217
|
+
}
|
|
6218
|
+
}
|
|
6219
|
+
if (taintedVars.size === before)
|
|
6220
|
+
break;
|
|
6221
|
+
}
|
|
6222
|
+
if (taintedVars.size === 0)
|
|
6223
|
+
return findings;
|
|
6224
|
+
// Percent format: `<name> % <rhs>` where `<name>` is tainted and not a
|
|
6225
|
+
// string literal. Avoid false-fire on `2 % x` etc. by gating on
|
|
6226
|
+
// taintedVar appearing as the LHS of `%`.
|
|
6227
|
+
const percentRe = /\b(\w+)\s*%\s*[\(\[\{"'\w]/;
|
|
6228
|
+
// str.format(...): `<name>.format(`
|
|
6229
|
+
const dotFormatRe = /\b(\w+)\s*\.\s*format\s*\(/;
|
|
6230
|
+
const seen = new Set();
|
|
6231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6232
|
+
const line = lines[i];
|
|
6233
|
+
const t = line.trim();
|
|
6234
|
+
if (t.startsWith('#'))
|
|
6235
|
+
continue;
|
|
6236
|
+
// Percent format
|
|
6237
|
+
const pm = line.match(percentRe);
|
|
6238
|
+
if (pm && taintedVars.has(pm[1])) {
|
|
6239
|
+
// Avoid string literal LHS (already a literal would have been
|
|
6240
|
+
// assigned as a literal, but we double-check the var was tainted —
|
|
6241
|
+
// which the check above asserts).
|
|
6242
|
+
const key = `${i + 1}:percent`;
|
|
6243
|
+
if (!seen.has(key)) {
|
|
6244
|
+
seen.add(key);
|
|
6245
|
+
findings.push({
|
|
6246
|
+
id: `format_string-${file}-${i + 1}-py-percent`,
|
|
6247
|
+
pass: 'language-sources',
|
|
6248
|
+
category: 'security',
|
|
6249
|
+
rule_id: 'format_string',
|
|
6250
|
+
cwe: 'CWE-134',
|
|
6251
|
+
severity: 'high',
|
|
6252
|
+
level: 'error',
|
|
6253
|
+
message: 'Format-string injection: Python `<tainted> % args` uses an ' +
|
|
6254
|
+
'HTTP-request-controlled format string. Malformed specifiers can ' +
|
|
6255
|
+
'crash the handler (TypeError) and `%(name)s` access can reveal ' +
|
|
6256
|
+
'arbitrary mapping keys. Use a literal format string and pass ' +
|
|
6257
|
+
'untrusted values as arguments only.',
|
|
6258
|
+
file,
|
|
6259
|
+
line: i + 1,
|
|
6260
|
+
snippet: line.trim(),
|
|
6261
|
+
});
|
|
6262
|
+
}
|
|
6263
|
+
}
|
|
6264
|
+
// str.format
|
|
6265
|
+
const dm = line.match(dotFormatRe);
|
|
6266
|
+
if (dm && taintedVars.has(dm[1])) {
|
|
6267
|
+
const key = `${i + 1}:dotformat`;
|
|
6268
|
+
if (!seen.has(key)) {
|
|
6269
|
+
seen.add(key);
|
|
6270
|
+
findings.push({
|
|
6271
|
+
id: `format_string-${file}-${i + 1}-py-strformat`,
|
|
6272
|
+
pass: 'language-sources',
|
|
6273
|
+
category: 'security',
|
|
6274
|
+
rule_id: 'format_string',
|
|
6275
|
+
cwe: 'CWE-134',
|
|
6276
|
+
severity: 'high',
|
|
6277
|
+
level: 'error',
|
|
6278
|
+
message: 'Format-string injection: Python `<tainted>.format(...)` lets ' +
|
|
6279
|
+
'the attacker control the format template. Field-access shapes ' +
|
|
6280
|
+
'such as `{0.__class__.__init__.__globals__}` can leak module ' +
|
|
6281
|
+
'globals (e.g. secret keys). Use a literal template and pass ' +
|
|
6282
|
+
'untrusted values as positional/keyword arguments only.',
|
|
6283
|
+
file,
|
|
6284
|
+
line: i + 1,
|
|
6285
|
+
snippet: line.trim(),
|
|
6286
|
+
});
|
|
6287
|
+
}
|
|
6288
|
+
}
|
|
6289
|
+
}
|
|
6290
|
+
return findings;
|
|
6291
|
+
}
|
|
6292
|
+
/**
|
|
6293
|
+
* Sprint 86 detector B (#189) — JavaScript uncontrolled format-string
|
|
6294
|
+
* via Node `util.format(<tainted>, ...args)`. `util.format` honours
|
|
6295
|
+
* `%s`/`%d`/`%j`/`%O` specifiers; a tainted format string can fingerprint
|
|
6296
|
+
* argument types and is the documented sink for CWE-134 in Node.
|
|
6297
|
+
*/
|
|
6298
|
+
export function findJsUtilFormatFormatStringFindings(code, file) {
|
|
6299
|
+
const findings = [];
|
|
6300
|
+
if (typeof code !== 'string' || code.length === 0)
|
|
6301
|
+
return findings;
|
|
6302
|
+
if (!/\butil\s*\.\s*format\s*\(/.test(code))
|
|
6303
|
+
return findings;
|
|
6304
|
+
if (!/\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/.test(code)) {
|
|
6305
|
+
return findings;
|
|
6306
|
+
}
|
|
6307
|
+
const lines = code.split('\n');
|
|
6308
|
+
const reqExtractRe = /\breq\s*\.\s*(?:query|body|params|headers|cookies)\b/;
|
|
6309
|
+
const taintedVars = new Set();
|
|
6310
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
6311
|
+
const before = taintedVars.size;
|
|
6312
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6313
|
+
const t = lines[i].trim();
|
|
6314
|
+
if (t.startsWith('//'))
|
|
6315
|
+
continue;
|
|
6316
|
+
const a = t.match(/^(?:const|let|var)\s+(\w+)\s*=\s*(.+?);?$/);
|
|
6317
|
+
if (!a)
|
|
6318
|
+
continue;
|
|
6319
|
+
const lhs = a[1];
|
|
6320
|
+
const rhs = a[2];
|
|
6321
|
+
if (taintedVars.has(lhs))
|
|
6322
|
+
continue;
|
|
6323
|
+
if (reqExtractRe.test(rhs)) {
|
|
6324
|
+
taintedVars.add(lhs);
|
|
6325
|
+
continue;
|
|
6326
|
+
}
|
|
6327
|
+
for (const v of taintedVars) {
|
|
6328
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
6329
|
+
taintedVars.add(lhs);
|
|
6330
|
+
break;
|
|
6331
|
+
}
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
if (taintedVars.size === before)
|
|
6335
|
+
break;
|
|
6336
|
+
}
|
|
6337
|
+
if (taintedVars.size === 0)
|
|
6338
|
+
return findings;
|
|
6339
|
+
// util.format(<firstArg>, ...) — check whether <firstArg> is tainted.
|
|
6340
|
+
const callRe = /\butil\s*\.\s*format\s*\(\s*([\w.]+)\s*[,)]/;
|
|
6341
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6342
|
+
const line = lines[i];
|
|
6343
|
+
const m = line.match(callRe);
|
|
6344
|
+
if (!m)
|
|
6345
|
+
continue;
|
|
6346
|
+
const arg = m[1];
|
|
6347
|
+
// First token of dotted path is the var name.
|
|
6348
|
+
const root = arg.split('.')[0];
|
|
6349
|
+
if (!taintedVars.has(root) && !reqExtractRe.test(arg))
|
|
6350
|
+
continue;
|
|
6351
|
+
findings.push({
|
|
6352
|
+
id: `format_string-${file}-${i + 1}-js-util-format`,
|
|
6353
|
+
pass: 'language-sources',
|
|
6354
|
+
category: 'security',
|
|
6355
|
+
rule_id: 'format_string',
|
|
6356
|
+
cwe: 'CWE-134',
|
|
6357
|
+
severity: 'medium',
|
|
6358
|
+
level: 'error',
|
|
6359
|
+
message: 'Format-string injection: Node `util.format(<tainted>, ...)` uses ' +
|
|
6360
|
+
'an HTTP-request-controlled format string. The attacker can ' +
|
|
6361
|
+
'manipulate `%s`/`%j`/`%O` specifiers to alter the rendered ' +
|
|
6362
|
+
'output. Pass user input as a subsequent argument with a literal ' +
|
|
6363
|
+
'format string.',
|
|
6364
|
+
file,
|
|
6365
|
+
line: i + 1,
|
|
6366
|
+
snippet: line.trim(),
|
|
6367
|
+
});
|
|
6368
|
+
}
|
|
6369
|
+
return findings;
|
|
6370
|
+
}
|
|
6371
|
+
/**
|
|
6372
|
+
* Sprint 86 detector C (#189) — Python HTTP header CRLF injection via
|
|
6373
|
+
* Flask/Werkzeug `response.headers['X-Custom'] = <tainted concat>` or
|
|
6374
|
+
* `response.headers.add(...)` / `response.headers.set(...)`. CWE-113.
|
|
6375
|
+
*/
|
|
6376
|
+
export function findPythonHeaderCrlfInjectionFindings(code, file) {
|
|
6377
|
+
const findings = [];
|
|
6378
|
+
if (typeof code !== 'string' || code.length === 0)
|
|
6379
|
+
return findings;
|
|
6380
|
+
if (!/\b\w+\s*\.\s*headers\b/.test(code))
|
|
6381
|
+
return findings;
|
|
6382
|
+
if (!/\b(?:request\.(?:args|form|values|json|files|cookies|headers|data)|flask\.request)\b/.test(code)) {
|
|
6383
|
+
return findings;
|
|
6384
|
+
}
|
|
6385
|
+
const lines = code.split('\n');
|
|
6386
|
+
const reqExtractRe = /\b(?:request\.(?:args|form|values|json|files|cookies|headers|data)\b|flask\.request\b)/;
|
|
6387
|
+
const taintedVars = new Set();
|
|
6388
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
6389
|
+
const before = taintedVars.size;
|
|
6390
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6391
|
+
const t = lines[i].trim();
|
|
6392
|
+
if (t.startsWith('#'))
|
|
6393
|
+
continue;
|
|
6394
|
+
const a = t.match(/^(\w+)\s*=\s*(.+?)$/);
|
|
6395
|
+
if (!a)
|
|
6396
|
+
continue;
|
|
6397
|
+
const lhs = a[1];
|
|
6398
|
+
const rhs = a[2];
|
|
6399
|
+
if (taintedVars.has(lhs))
|
|
6400
|
+
continue;
|
|
6401
|
+
if (reqExtractRe.test(rhs)) {
|
|
6402
|
+
taintedVars.add(lhs);
|
|
6403
|
+
continue;
|
|
6404
|
+
}
|
|
6405
|
+
for (const v of taintedVars) {
|
|
6406
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
6407
|
+
taintedVars.add(lhs);
|
|
6408
|
+
break;
|
|
6409
|
+
}
|
|
6410
|
+
}
|
|
6411
|
+
}
|
|
6412
|
+
if (taintedVars.size === before)
|
|
6413
|
+
break;
|
|
6414
|
+
}
|
|
6415
|
+
if (taintedVars.size === 0)
|
|
6416
|
+
return findings;
|
|
6417
|
+
// Patterns:
|
|
6418
|
+
// resp.headers['X-Custom'] = <expr>
|
|
6419
|
+
// resp.headers.add('X-Custom', <expr>)
|
|
6420
|
+
// resp.headers.set('X-Custom', <expr>)
|
|
6421
|
+
// resp.headers.update({'X-Custom': <expr>})
|
|
6422
|
+
const subscriptRe = /\b\w+\s*\.\s*headers\s*\[\s*['"][^'"]+['"]\s*\]\s*=\s*(.+)$/;
|
|
6423
|
+
const methodRe = /\b\w+\s*\.\s*headers\s*\.\s*(?:add|set|setdefault|append)\s*\(\s*['"][^'"]+['"]\s*,\s*(.+?)\)/;
|
|
6424
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6425
|
+
const line = lines[i];
|
|
6426
|
+
const t = line.trim();
|
|
6427
|
+
if (t.startsWith('#'))
|
|
6428
|
+
continue;
|
|
6429
|
+
let expr = null;
|
|
6430
|
+
const sm = line.match(subscriptRe);
|
|
6431
|
+
if (sm)
|
|
6432
|
+
expr = sm[1];
|
|
6433
|
+
else {
|
|
6434
|
+
const mm = line.match(methodRe);
|
|
6435
|
+
if (mm)
|
|
6436
|
+
expr = mm[1];
|
|
6437
|
+
}
|
|
6438
|
+
if (!expr)
|
|
6439
|
+
continue;
|
|
6440
|
+
let tainted = reqExtractRe.test(expr);
|
|
6441
|
+
if (!tainted) {
|
|
6442
|
+
for (const v of taintedVars) {
|
|
6443
|
+
if (new RegExp(`\\b${v}\\b`).test(expr)) {
|
|
6444
|
+
tainted = true;
|
|
6445
|
+
break;
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
}
|
|
6449
|
+
if (!tainted)
|
|
6450
|
+
continue;
|
|
6451
|
+
findings.push({
|
|
6452
|
+
id: `crlf-${file}-${i + 1}-py-headers`,
|
|
6453
|
+
pass: 'language-sources',
|
|
6454
|
+
category: 'security',
|
|
6455
|
+
rule_id: 'crlf',
|
|
6456
|
+
cwe: 'CWE-113',
|
|
6457
|
+
severity: 'medium',
|
|
6458
|
+
level: 'error',
|
|
6459
|
+
message: 'CRLF / header injection: Python Flask/Werkzeug ' +
|
|
6460
|
+
'`response.headers[...] = <tainted>` lets an attacker inject a ' +
|
|
6461
|
+
'`\\r\\n` sequence and forge additional headers or split the ' +
|
|
6462
|
+
'response body. Validate the value (reject control characters) or ' +
|
|
6463
|
+
'use a fixed allowlist.',
|
|
6464
|
+
file,
|
|
6465
|
+
line: i + 1,
|
|
6466
|
+
snippet: line.trim(),
|
|
6467
|
+
});
|
|
6468
|
+
}
|
|
6469
|
+
return findings;
|
|
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
|
+
}
|
|
6134
7134
|
//# sourceMappingURL=language-sources-pass.js.map
|