xploitscan-shared-rules 1.2.0 → 1.2.2
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/LICENSE +1 -1
- package/README.md +58 -0
- package/dist/index.cjs +1078 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +115 -1
- package/dist/index.d.ts +115 -1
- package/dist/index.js +1070 -135
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
package/dist/index.cjs
CHANGED
|
@@ -34,7 +34,9 @@ __export(index_exports, {
|
|
|
34
34
|
allRules: () => allRules,
|
|
35
35
|
androidDebuggable: () => androidDebuggable,
|
|
36
36
|
blockingMainThread: () => blockingMainThread,
|
|
37
|
+
buildTaintMap: () => buildTaintMap,
|
|
37
38
|
calculateGrade: () => calculateGrade,
|
|
39
|
+
callSpreads: () => callSpreads,
|
|
38
40
|
callbackHell: () => callbackHell,
|
|
39
41
|
clickjacking: () => clickjacking,
|
|
40
42
|
clientComponentSecret: () => clientComponentSecret,
|
|
@@ -75,6 +77,7 @@ __export(index_exports, {
|
|
|
75
77
|
firebaseClientConfig: () => firebaseClientConfig,
|
|
76
78
|
flaskSecretKey: () => flaskSecretKey,
|
|
77
79
|
freeRules: () => freeRules,
|
|
80
|
+
getObjectProperty: () => getObjectProperty,
|
|
78
81
|
getSnippet: () => getSnippet,
|
|
79
82
|
githubActionsInjection: () => githubActionsInjection,
|
|
80
83
|
graphqlIntrospection: () => graphqlIntrospection,
|
|
@@ -111,6 +114,8 @@ __export(index_exports, {
|
|
|
111
114
|
insecureRandomness: () => insecureRandomness,
|
|
112
115
|
insecureWebSocket: () => insecureWebSocket,
|
|
113
116
|
ipcPathTraversal: () => ipcPathTraversal,
|
|
117
|
+
isCalleeNamed: () => isCalleeNamed,
|
|
118
|
+
isMethodCall: () => isMethodCall,
|
|
114
119
|
javaDeserialization: () => javaDeserialization,
|
|
115
120
|
jwtAlgConfusion: () => jwtAlgConfusion,
|
|
116
121
|
k8sNoResourceLimits: () => k8sNoResourceLimits,
|
|
@@ -148,6 +153,7 @@ __export(index_exports, {
|
|
|
148
153
|
nosqlInjection: () => nosqlInjection,
|
|
149
154
|
openRedirectParams: () => openRedirectParams,
|
|
150
155
|
overlyPermissiveIAM: () => overlyPermissiveIAM,
|
|
156
|
+
parseFile: () => parseFile,
|
|
151
157
|
pathTraversal: () => pathTraversal,
|
|
152
158
|
pickleDeserialization: () => pickleDeserialization,
|
|
153
159
|
piiInLogs: () => piiInLogs,
|
|
@@ -191,6 +197,8 @@ __export(index_exports, {
|
|
|
191
197
|
unvalidatedAPIParams: () => unvalidatedAPIParams,
|
|
192
198
|
unvalidatedEventData: () => unvalidatedEventData,
|
|
193
199
|
unvalidatedRedirect: () => unvalidatedRedirect,
|
|
200
|
+
visitBinary: () => visitBinary,
|
|
201
|
+
visitCalls: () => visitCalls,
|
|
194
202
|
vulnerableDependencies: () => vulnerableDependencies,
|
|
195
203
|
weakHashing: () => weakHashing,
|
|
196
204
|
weakPasswordRequirements: () => weakPasswordRequirements,
|
|
@@ -213,11 +221,381 @@ function getSnippet(content, line, contextLines = 2) {
|
|
|
213
221
|
}).join("\n");
|
|
214
222
|
}
|
|
215
223
|
|
|
224
|
+
// src/ast/parse.ts
|
|
225
|
+
var import_parser = require("@babel/parser");
|
|
226
|
+
var MAX_CACHE = 256;
|
|
227
|
+
var cache = /* @__PURE__ */ new Map();
|
|
228
|
+
function cacheKey(filename, contentHash) {
|
|
229
|
+
return `${filename}:${contentHash}`;
|
|
230
|
+
}
|
|
231
|
+
function quickHash(s) {
|
|
232
|
+
let h = 5381;
|
|
233
|
+
const step = Math.max(1, Math.floor(s.length / 32));
|
|
234
|
+
for (let i = 0; i < s.length; i += step) {
|
|
235
|
+
h = (h << 5) + h + s.charCodeAt(i) | 0;
|
|
236
|
+
}
|
|
237
|
+
return h * 31 + s.length | 0;
|
|
238
|
+
}
|
|
239
|
+
function pickLanguage(filename) {
|
|
240
|
+
if (/\.tsx$/i.test(filename)) return "tsx";
|
|
241
|
+
if (/\.jsx$/i.test(filename)) return "jsx";
|
|
242
|
+
if (/\.(ts|cts|mts)$/i.test(filename)) return "ts";
|
|
243
|
+
if (/\.(js|cjs|mjs)$/i.test(filename)) return "js";
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function parseFile(content, filename) {
|
|
247
|
+
const lang = pickLanguage(filename);
|
|
248
|
+
if (!lang) return null;
|
|
249
|
+
if (!content || content.length === 0) return null;
|
|
250
|
+
const key = cacheKey(filename, quickHash(content));
|
|
251
|
+
if (cache.has(key)) return cache.get(key) ?? null;
|
|
252
|
+
const plugins = [];
|
|
253
|
+
if (lang === "ts" || lang === "tsx") plugins.push("typescript");
|
|
254
|
+
if (lang === "jsx" || lang === "tsx") plugins.push("jsx");
|
|
255
|
+
plugins.push("decorators-legacy", "classProperties", "dynamicImport", "topLevelAwait");
|
|
256
|
+
let ast = null;
|
|
257
|
+
try {
|
|
258
|
+
ast = (0, import_parser.parse)(content, {
|
|
259
|
+
sourceType: "unambiguous",
|
|
260
|
+
allowImportExportEverywhere: true,
|
|
261
|
+
allowReturnOutsideFunction: true,
|
|
262
|
+
allowAwaitOutsideFunction: true,
|
|
263
|
+
allowUndeclaredExports: true,
|
|
264
|
+
errorRecovery: true,
|
|
265
|
+
plugins
|
|
266
|
+
});
|
|
267
|
+
} catch {
|
|
268
|
+
cache.set(key, null);
|
|
269
|
+
if (cache.size > MAX_CACHE) {
|
|
270
|
+
const firstKey = cache.keys().next().value;
|
|
271
|
+
if (firstKey !== void 0) cache.delete(firstKey);
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const entry = { ast, language: lang };
|
|
276
|
+
cache.set(key, entry);
|
|
277
|
+
if (cache.size > MAX_CACHE) {
|
|
278
|
+
const firstKey = cache.keys().next().value;
|
|
279
|
+
if (firstKey !== void 0) cache.delete(firstKey);
|
|
280
|
+
}
|
|
281
|
+
return entry;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/ast/taint.ts
|
|
285
|
+
var import_traverse = __toESM(require("@babel/traverse"), 1);
|
|
286
|
+
var traverse = typeof import_traverse.default === "function" ? import_traverse.default : import_traverse.default.default;
|
|
287
|
+
var TAINTED_PROP_SUFFIXES = /* @__PURE__ */ new Set([
|
|
288
|
+
"body",
|
|
289
|
+
"query",
|
|
290
|
+
"params",
|
|
291
|
+
"headers",
|
|
292
|
+
"cookies",
|
|
293
|
+
"queryStringParameters",
|
|
294
|
+
"pathParameters",
|
|
295
|
+
"rawBody",
|
|
296
|
+
"searchParams"
|
|
297
|
+
]);
|
|
298
|
+
var TAINTED_REQUEST_OBJECTS = /* @__PURE__ */ new Set([
|
|
299
|
+
"req",
|
|
300
|
+
"request",
|
|
301
|
+
"ctx",
|
|
302
|
+
// Koa
|
|
303
|
+
"context",
|
|
304
|
+
// Koa alt
|
|
305
|
+
"event"
|
|
306
|
+
// Lambda
|
|
307
|
+
]);
|
|
308
|
+
function buildTaintMap(parsed) {
|
|
309
|
+
const tainted = /* @__PURE__ */ new Set();
|
|
310
|
+
function reachesRequestIdent(node) {
|
|
311
|
+
if (!node) return false;
|
|
312
|
+
if (node.type === "Identifier") return TAINTED_REQUEST_OBJECTS.has(node.name);
|
|
313
|
+
if (node.type === "MemberExpression") {
|
|
314
|
+
if (node.property.type === "Identifier" && node.property.name === "request" && node.object.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(node.object.name)) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
return reachesRequestIdent(node.object);
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
function nodeIsTaintedSource(node) {
|
|
322
|
+
if (!node) return false;
|
|
323
|
+
if (node.type === "MemberExpression") {
|
|
324
|
+
const prop = node.property;
|
|
325
|
+
const propName = prop.type === "Identifier" ? prop.name : prop.type === "StringLiteral" ? prop.value : "";
|
|
326
|
+
if (TAINTED_PROP_SUFFIXES.has(propName)) {
|
|
327
|
+
const obj = node.object;
|
|
328
|
+
if (obj.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(obj.name)) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
if (reachesRequestIdent(obj)) return true;
|
|
332
|
+
if (nodeIsTaintedSource(obj)) return true;
|
|
333
|
+
}
|
|
334
|
+
if (node.object.type === "Identifier" && node.object.name === "process" && prop.type === "Identifier" && prop.name === "argv") {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (nodeIsTaintedSource(node.object)) return true;
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (node.type === "CallExpression") {
|
|
341
|
+
const callee = node.callee;
|
|
342
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && callee.property.name === "get" && nodeIsTaintedSource(callee.object)) {
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
const BODY_READERS = /* @__PURE__ */ new Set(["json", "formData", "text", "arrayBuffer", "blob"]);
|
|
346
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && BODY_READERS.has(callee.property.name)) {
|
|
347
|
+
const obj = callee.object;
|
|
348
|
+
if (obj.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(obj.name)) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
if (reachesRequestIdent(obj)) return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (node.type === "AwaitExpression") {
|
|
355
|
+
return nodeIsTaintedSource(node.argument);
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
function exprIsTainted(node) {
|
|
360
|
+
if (!node) return false;
|
|
361
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
362
|
+
if (node.type === "Identifier") return tainted.has(node.name);
|
|
363
|
+
if (node.type === "TemplateLiteral") {
|
|
364
|
+
return node.expressions.some((e) => exprIsTainted(e));
|
|
365
|
+
}
|
|
366
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
367
|
+
return exprIsTainted(node.left) || exprIsTainted(node.right);
|
|
368
|
+
}
|
|
369
|
+
if (node.type === "LogicalExpression" && (node.operator === "||" || node.operator === "??")) {
|
|
370
|
+
return exprIsTainted(node.left) || exprIsTainted(node.right);
|
|
371
|
+
}
|
|
372
|
+
if (node.type === "ConditionalExpression") {
|
|
373
|
+
return exprIsTainted(node.consequent) || exprIsTainted(node.alternate);
|
|
374
|
+
}
|
|
375
|
+
if (node.type === "MemberExpression") {
|
|
376
|
+
return exprIsTainted(node.object);
|
|
377
|
+
}
|
|
378
|
+
if (node.type === "CallExpression") {
|
|
379
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
380
|
+
if (node.callee.type === "MemberExpression") {
|
|
381
|
+
if (exprIsTainted(node.callee.object)) return true;
|
|
382
|
+
const obj = node.callee.object;
|
|
383
|
+
const prop = node.callee.property;
|
|
384
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "path" && ["join", "resolve", "normalize", "format", "parse", "relative"].includes(prop.name)) {
|
|
385
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
386
|
+
}
|
|
387
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "Buffer" && ["from", "concat", "alloc", "allocUnsafe"].includes(prop.name)) {
|
|
388
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (node.callee.type === "Identifier") {
|
|
392
|
+
if (["String", "Number", "Boolean", "URL", "URLSearchParams"].includes(node.callee.name)) {
|
|
393
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (node.type === "AwaitExpression") {
|
|
398
|
+
return exprIsTainted(node.argument);
|
|
399
|
+
}
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
traverse(parsed.ast, {
|
|
403
|
+
VariableDeclarator(path) {
|
|
404
|
+
const node = path.node;
|
|
405
|
+
if (node.type !== "VariableDeclarator") return;
|
|
406
|
+
const init = node.init;
|
|
407
|
+
if (!init) return;
|
|
408
|
+
if (node.id.type === "Identifier") {
|
|
409
|
+
if (exprIsTainted(init)) {
|
|
410
|
+
tainted.add(node.id.name);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (node.id.type === "ObjectPattern") {
|
|
414
|
+
const isTainted2 = nodeIsTaintedSource(init) || init.type === "Identifier" && tainted.has(init.name);
|
|
415
|
+
if (isTainted2) {
|
|
416
|
+
for (const prop of node.id.properties) {
|
|
417
|
+
if (prop.type === "ObjectProperty") {
|
|
418
|
+
if (prop.value.type === "Identifier") tainted.add(prop.value.name);
|
|
419
|
+
} else if (prop.type === "RestElement") {
|
|
420
|
+
if (prop.argument.type === "Identifier") tainted.add(prop.argument.name);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
AssignmentExpression(path) {
|
|
427
|
+
const node = path.node;
|
|
428
|
+
if (node.type !== "AssignmentExpression") return;
|
|
429
|
+
if (node.operator !== "=" && node.operator !== "||=" && node.operator !== "??=") return;
|
|
430
|
+
if (node.left.type !== "Identifier") return;
|
|
431
|
+
if (exprIsTainted(node.right)) {
|
|
432
|
+
tainted.add(node.left.name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
const isTainted = (node) => {
|
|
437
|
+
if (!node) return false;
|
|
438
|
+
if (node.type === "Identifier") return tainted.has(node.name);
|
|
439
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
440
|
+
if (node.type === "TemplateLiteral") {
|
|
441
|
+
return node.expressions.some((e) => isTainted(e));
|
|
442
|
+
}
|
|
443
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
444
|
+
return isTainted(node.left) || isTainted(node.right);
|
|
445
|
+
}
|
|
446
|
+
if (node.type === "LogicalExpression" && (node.operator === "||" || node.operator === "??" || node.operator === "&&")) {
|
|
447
|
+
return isTainted(node.left) || isTainted(node.right);
|
|
448
|
+
}
|
|
449
|
+
if (node.type === "ConditionalExpression") {
|
|
450
|
+
return isTainted(node.consequent) || isTainted(node.alternate);
|
|
451
|
+
}
|
|
452
|
+
if (node.type === "AwaitExpression") {
|
|
453
|
+
return isTainted(node.argument);
|
|
454
|
+
}
|
|
455
|
+
if (node.type === "MemberExpression") {
|
|
456
|
+
return isTainted(node.object);
|
|
457
|
+
}
|
|
458
|
+
if (node.type === "CallExpression") {
|
|
459
|
+
if (node.callee.type === "MemberExpression") {
|
|
460
|
+
if (isTainted(node.callee.object)) return true;
|
|
461
|
+
const obj = node.callee.object;
|
|
462
|
+
const prop = node.callee.property;
|
|
463
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "path" && ["join", "resolve", "normalize", "format", "relative", "parse"].includes(prop.name)) {
|
|
464
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
465
|
+
}
|
|
466
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "Buffer" && ["from", "concat", "alloc", "allocUnsafe"].includes(prop.name)) {
|
|
467
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (node.callee.type === "Identifier") {
|
|
471
|
+
if (["String", "Number", "Boolean", "URL", "URLSearchParams"].includes(node.callee.name)) {
|
|
472
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
477
|
+
};
|
|
478
|
+
return {
|
|
479
|
+
isTainted,
|
|
480
|
+
isTaintedIdent: (name) => tainted.has(name),
|
|
481
|
+
taintedNames: () => Array.from(tainted)
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/ast/traverse.ts
|
|
486
|
+
var import_traverse2 = __toESM(require("@babel/traverse"), 1);
|
|
487
|
+
var traverse2 = typeof import_traverse2.default === "function" ? import_traverse2.default : import_traverse2.default.default;
|
|
488
|
+
function visitBinary(parsed, visit) {
|
|
489
|
+
traverse2(parsed.ast, {
|
|
490
|
+
BinaryExpression(path) {
|
|
491
|
+
const line = path.node.loc?.start.line ?? 1;
|
|
492
|
+
visit(path.node, line);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function visitCalls(parsed, matchCallee, visit) {
|
|
497
|
+
traverse2(parsed.ast, {
|
|
498
|
+
CallExpression(path) {
|
|
499
|
+
const node = path.node;
|
|
500
|
+
if (!matchCallee(node.callee)) return;
|
|
501
|
+
const line = node.loc?.start.line ?? 1;
|
|
502
|
+
visit(node, line);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
function isCalleeNamed(callee, name) {
|
|
507
|
+
if (callee.type === "Identifier") return callee.name === name;
|
|
508
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
509
|
+
return callee.property.name === name;
|
|
510
|
+
}
|
|
511
|
+
if (callee.type === "OptionalMemberExpression" && callee.property.type === "Identifier") {
|
|
512
|
+
return callee.property.name === name;
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
function isMethodCall(callee, objName, methodName) {
|
|
517
|
+
if (callee.type !== "MemberExpression" && callee.type !== "OptionalMemberExpression") {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
if (callee.property.type !== "Identifier") return false;
|
|
521
|
+
if (callee.property.name !== methodName) return false;
|
|
522
|
+
if (callee.object.type !== "Identifier") return false;
|
|
523
|
+
return callee.object.name === objName;
|
|
524
|
+
}
|
|
525
|
+
function getObjectProperty(node, key) {
|
|
526
|
+
for (const prop of node.properties) {
|
|
527
|
+
if (prop.type === "ObjectProperty") {
|
|
528
|
+
if (prop.key.type === "Identifier" && prop.key.name === key) {
|
|
529
|
+
return { value: prop.value };
|
|
530
|
+
}
|
|
531
|
+
if (prop.key.type === "StringLiteral" && prop.key.value === key) {
|
|
532
|
+
return { value: prop.value };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
function callSpreads(call, matcher) {
|
|
539
|
+
for (const arg of call.arguments) {
|
|
540
|
+
if (arg.type === "SpreadElement") {
|
|
541
|
+
if (matcher(arg.argument)) return true;
|
|
542
|
+
}
|
|
543
|
+
if (arg.type === "ObjectExpression") {
|
|
544
|
+
for (const prop of arg.properties) {
|
|
545
|
+
if (prop.type === "SpreadElement") {
|
|
546
|
+
if (matcher(prop.argument)) return true;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
216
554
|
// src/rules.ts
|
|
217
555
|
var TEST_FILE_PATTERN = /(?:\.test\.|\.spec\.|__tests__|__mocks__|\.stories\.|\.story\.|\/test\/|\/tests\/|\/fixtures?\/|\/mocks?\/|\.mock\.|test-utils|testing|\.cy\.|\.e2e\.)/i;
|
|
218
556
|
function isTestFile(filePath) {
|
|
219
557
|
return TEST_FILE_PATTERN.test(filePath);
|
|
220
558
|
}
|
|
559
|
+
var SERVER_SIDE_PATH_RE = new RegExp(
|
|
560
|
+
[
|
|
561
|
+
// Directory-style anchors — match with OR without leading slash so
|
|
562
|
+
// relative paths (`routes/users.js`) work as well as absolute.
|
|
563
|
+
"(?:^|/)api/",
|
|
564
|
+
"(?:^|/)routes?/",
|
|
565
|
+
"(?:^|/)controllers?/",
|
|
566
|
+
"(?:^|/)endpoints?/",
|
|
567
|
+
"(?:^|/)handlers?/",
|
|
568
|
+
"(?:^|/)middleware/",
|
|
569
|
+
"(?:^|/)webhooks?/",
|
|
570
|
+
"(?:^|/)services?/",
|
|
571
|
+
"(?:^|/)lambda/",
|
|
572
|
+
"(?:^|/)functions?/",
|
|
573
|
+
"(?:^|/)pages/api/",
|
|
574
|
+
"(?:^|/)app/.*/route\\.(?:m?[jt]sx?|cjs)$",
|
|
575
|
+
// Bare-filename anchors (with or without leading directory).
|
|
576
|
+
// Allows compound names like webhook-handler.js, cron-runner.js,
|
|
577
|
+
// user-service.ts via the optional (?:[-_]\w+)* suffix group.
|
|
578
|
+
"(?:^|/)(?:api|routes?|controllers?|endpoints?|handlers?|middleware|webhooks?|services?|server|app|index|main|lambda|function|ingest|runner|worker|resolvers?)(?:[-_]\\w+)*\\.(?:m?[jt]sx?|cjs|py|rb|go)$",
|
|
579
|
+
// Common functional names that imply request handling.
|
|
580
|
+
"(?:^|/)(?:auth|login|logout|signup|signin|register|password|reset|callback|oauth|admin|dashboard|profile|account|checkout|payment|webhook|audit|cron|search|upload|download|errors?|create-?\\w*|update-?\\w*|delete-?\\w*)(?:[-_]\\w+)*\\.(?:m?[jt]sx?|cjs|py|rb|go)$"
|
|
581
|
+
].join("|"),
|
|
582
|
+
"i"
|
|
583
|
+
);
|
|
584
|
+
function isServerSideFile(filePath) {
|
|
585
|
+
if (isTestFile(filePath)) return false;
|
|
586
|
+
return SERVER_SIDE_PATH_RE.test(filePath);
|
|
587
|
+
}
|
|
588
|
+
var CONFIG_FILE_PATTERN = new RegExp(
|
|
589
|
+
[
|
|
590
|
+
"\\.config\\.(?:m?[jt]sx?|cjs)$",
|
|
591
|
+
"(?:^|/)config\\.(?:m?[jt]sx?|cjs|py|rb|json|ya?ml)$",
|
|
592
|
+
"(?:^|/)settings\\.(?:m?[jt]sx?|cjs|py)$",
|
|
593
|
+
"(?:^|/)\\.env(?:\\.|$)",
|
|
594
|
+
"(?:^|/)(?:knex|drizzle|next|vite|rollup|webpack|tailwind|postcss|jest|vitest|tsup|babel)\\.config\\.(?:m?[jt]sx?|cjs)$",
|
|
595
|
+
"(?:^|/)(?:db|database|connection|pool)\\.(?:config\\.)?(?:m?[jt]sx?|cjs|py|rb)$"
|
|
596
|
+
].join("|"),
|
|
597
|
+
"i"
|
|
598
|
+
);
|
|
221
599
|
function isCommentLine(content, matchIndex) {
|
|
222
600
|
const lineStart = content.lastIndexOf("\n", matchIndex - 1) + 1;
|
|
223
601
|
const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex)).trimStart();
|
|
@@ -226,7 +604,7 @@ function isCommentLine(content, matchIndex) {
|
|
|
226
604
|
function isInsideFixMessage(content, matchIndex) {
|
|
227
605
|
const lineStart = content.lastIndexOf("\n", matchIndex - 1) + 1;
|
|
228
606
|
const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex));
|
|
229
|
-
return /(?:fix|description|message|suggestion|hint|help|example|doc|comment)\s*[:=(]/i.test(lineText) || /return\s*["'`]
|
|
607
|
+
return /(?:fix|description|message|suggestion|hint|help|example|doc|comment)\s*[:=(]/i.test(lineText) || /return\s*["'`].*\b(?:Use|Replace|Add|Move|Set|Enable|Disable|Never|Don't|Do not|Instead)\b/i.test(lineText);
|
|
230
608
|
}
|
|
231
609
|
function findMatches(content, pattern, rule, filePath, fixTemplate) {
|
|
232
610
|
const matches = [];
|
|
@@ -250,6 +628,23 @@ function findMatches(content, pattern, rule, filePath, fixTemplate) {
|
|
|
250
628
|
}
|
|
251
629
|
return matches;
|
|
252
630
|
}
|
|
631
|
+
function astMatch(content, filePath, line, rule, fix) {
|
|
632
|
+
return {
|
|
633
|
+
rule: rule.id,
|
|
634
|
+
title: rule.title,
|
|
635
|
+
severity: rule.severity,
|
|
636
|
+
category: rule.category,
|
|
637
|
+
file: filePath,
|
|
638
|
+
line,
|
|
639
|
+
snippet: getSnippet(content, line),
|
|
640
|
+
fix
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function tryParse(content, filePath) {
|
|
644
|
+
const parsed = parseFile(content, filePath);
|
|
645
|
+
if (!parsed) return null;
|
|
646
|
+
return { parsed, taint: buildTaintMap(parsed) };
|
|
647
|
+
}
|
|
253
648
|
var hardcodedSecrets = {
|
|
254
649
|
id: "VC001",
|
|
255
650
|
title: "Hardcoded API Key or Secret",
|
|
@@ -273,6 +668,17 @@ var hardcodedSecrets = {
|
|
|
273
668
|
/sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/g,
|
|
274
669
|
// Generic tokens in assignments — require standalone word and longer min length
|
|
275
670
|
/(?:^|[\s,({])(?:token|secret|password|passwd|pwd)\s*[:=]\s*["'`]([a-zA-Z0-9_\-!@#$%^&*]{20,})["'`]/gim,
|
|
671
|
+
// SCREAMING_CASE identifiers ending in SECRET/TOKEN/KEY/PASSWORD
|
|
672
|
+
// (OAUTH_CLIENT_SECRET, JWT_PRIVATE_KEY, STRIPE_WEBHOOK_SECRET, etc.)
|
|
673
|
+
// that are assigned a string literal, not an env var or function call.
|
|
674
|
+
/[A-Z][A-Z0-9_]*_(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD)\s*[:=]\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/]{12,})["'`]/g,
|
|
675
|
+
// camelCase / snake_case object properties that look like credentials:
|
|
676
|
+
// apiKey: "...", webhookUrl: "https://...", accountSid: "...", authToken: "..."
|
|
677
|
+
// password: "..." caught here separately with a lower threshold (many
|
|
678
|
+
// real passwords are 8-16 chars, below the 12-char floor of the
|
|
679
|
+
// generic `secret = "..."` pattern).
|
|
680
|
+
/\b(?:apiKey|api_key|authToken|auth_token|webhookUrl|webhook_url|accountSid|account_sid|clientSecret|client_secret|refreshToken|refresh_token)\s*:\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/:\-]{12,})["'`]/gi,
|
|
681
|
+
/\b(?:password|passwd|pwd)\s*:\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/:\-]{8,})["'`]/gi,
|
|
276
682
|
// Private keys
|
|
277
683
|
/-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g,
|
|
278
684
|
// Database URLs with credentials
|
|
@@ -285,19 +691,49 @@ var hardcodedSecrets = {
|
|
|
285
691
|
"Supabase key detected. Service role keys bypass Row Level Security and grant full database read/write access. Move to a server-side environment variable immediately.",
|
|
286
692
|
"OpenAI API key detected \u2014 grants full API access and can incur charges. Rotate at platform.openai.com \u2192 API Keys.",
|
|
287
693
|
"Hardcoded token or password detected. Move to an environment variable and rotate the credential if it has been committed to version control.",
|
|
694
|
+
"Named secret constant detected (XXX_SECRET / XXX_TOKEN / XXX_KEY). Move to an environment variable: `const NAME = process.env.NAME;`. Rotate the credential if this file has been committed to version control.",
|
|
695
|
+
"Credential assigned to a config object property. Move to an environment variable \u2014 `apiKey: process.env.API_KEY` \u2014 and rotate the value if this file has been committed.",
|
|
696
|
+
"Hardcoded password detected in a config object. Move to an environment variable (`password: process.env.DB_PASSWORD`) and rotate the password if this file has been committed.",
|
|
288
697
|
"Private key found in source code. If this has been committed to version control, consider the key compromised \u2014 generate a new key pair and revoke the old one.",
|
|
289
698
|
"Database credentials in connection string. An attacker with this URL has full database access. Move to an environment variable, restrict network access, and rotate the password."
|
|
290
699
|
];
|
|
700
|
+
const minLen = [
|
|
701
|
+
12,
|
|
702
|
+
// [0] generic API key
|
|
703
|
+
16,
|
|
704
|
+
// [1] AWS — already rigid in the regex, length floor is defensive
|
|
705
|
+
20,
|
|
706
|
+
// [2] Stripe
|
|
707
|
+
50,
|
|
708
|
+
// [3] Supabase JWT
|
|
709
|
+
40,
|
|
710
|
+
// [4] OpenAI
|
|
711
|
+
20,
|
|
712
|
+
// [5] generic tokens with {20,} in the regex
|
|
713
|
+
12,
|
|
714
|
+
// [6] SCREAMING_CASE named constants
|
|
715
|
+
12,
|
|
716
|
+
// [7] camelCase config props (apiKey/webhookUrl/etc.)
|
|
717
|
+
8,
|
|
718
|
+
// [8] password: "..." — lower floor per the pattern comment above
|
|
719
|
+
0,
|
|
720
|
+
// [9] private keys — no length check
|
|
721
|
+
0
|
|
722
|
+
// [10] DB URLs — no length check
|
|
723
|
+
];
|
|
291
724
|
const matches = [];
|
|
292
725
|
for (let pi = 0; pi < patterns.length; pi++) {
|
|
293
726
|
const pattern = patterns[pi];
|
|
294
727
|
const rawMatches = findMatches(content, pattern, hardcodedSecrets, filePath, () => fixMessages[pi]);
|
|
728
|
+
const floor = minLen[pi] ?? 12;
|
|
295
729
|
for (const rm of rawMatches) {
|
|
296
730
|
const lineText = content.split("\n")[rm.line - 1] || "";
|
|
297
731
|
const trimmed = lineText.trimStart();
|
|
298
732
|
if (trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
|
|
299
|
-
|
|
300
|
-
|
|
733
|
+
if (floor > 0) {
|
|
734
|
+
const secretMatch = lineText.match(/[:=]\s*["'`]([^"'`]*)["'`]/);
|
|
735
|
+
if (secretMatch && secretMatch[1].length < floor) continue;
|
|
736
|
+
}
|
|
301
737
|
matches.push(rm);
|
|
302
738
|
}
|
|
303
739
|
}
|
|
@@ -333,8 +769,7 @@ var missingAuthMiddleware = {
|
|
|
333
769
|
category: "Authentication",
|
|
334
770
|
description: "API routes without authentication checks allow unauthorized access.",
|
|
335
771
|
check(content, filePath) {
|
|
336
|
-
|
|
337
|
-
if (!isApiRoute) return [];
|
|
772
|
+
if (!isServerSideFile(filePath)) return [];
|
|
338
773
|
const routePatterns = [
|
|
339
774
|
// Express/Hono style
|
|
340
775
|
/\.(get|post|put|patch|delete)\s*\(\s*["'`][^"'`]+["'`]\s*,\s*(?:async\s+)?\(?(?:req|c|ctx)/gi,
|
|
@@ -347,35 +782,36 @@ var missingAuthMiddleware = {
|
|
|
347
782
|
if (isAuthRoute) return [];
|
|
348
783
|
const isWebhookRoute = /\/webhook/i.test(filePath);
|
|
349
784
|
if (isWebhookRoute) return [];
|
|
785
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
350
786
|
const authPatterns = [
|
|
351
|
-
/
|
|
352
|
-
/
|
|
353
|
-
/
|
|
354
|
-
/
|
|
355
|
-
/
|
|
356
|
-
/
|
|
357
|
-
/
|
|
358
|
-
/
|
|
359
|
-
/
|
|
360
|
-
/
|
|
361
|
-
/
|
|
362
|
-
/
|
|
787
|
+
/\bgetUser\b/i,
|
|
788
|
+
/\bcurrentUser\b/i,
|
|
789
|
+
/\bisAuthenticated\b/i,
|
|
790
|
+
/\brequireAuth\b/i,
|
|
791
|
+
/\brequireUser\b/i,
|
|
792
|
+
/\brequireUserForApi\b/i,
|
|
793
|
+
/\bwithAuth\b/i,
|
|
794
|
+
/\bgetServerSession\b/i,
|
|
795
|
+
/\bgetToken\b/i,
|
|
796
|
+
/\bverifyToken\b/i,
|
|
797
|
+
/\bvalidateToken\b/i,
|
|
798
|
+
/\bcheckApiKey\b/i,
|
|
799
|
+
/\bverifyCronSecret\b/i,
|
|
800
|
+
/\bverifySecret\b/i,
|
|
363
801
|
/supabase\.auth/i,
|
|
364
|
-
/getServerSession/i,
|
|
365
|
-
/getToken/i,
|
|
366
|
-
/protect/i,
|
|
367
|
-
/guard/i,
|
|
368
|
-
/verifyToken/i,
|
|
369
|
-
/validateToken/i,
|
|
370
|
-
/withAuth/i,
|
|
371
|
-
/passport/i,
|
|
372
802
|
/firebase\.auth/i,
|
|
373
|
-
/
|
|
374
|
-
/
|
|
375
|
-
/
|
|
376
|
-
/
|
|
803
|
+
/\bclerk\b/i,
|
|
804
|
+
/\bpassport\b/i,
|
|
805
|
+
/\bcognito\b/i,
|
|
806
|
+
/\bauth\(\)/i,
|
|
807
|
+
/req\.user\b/i,
|
|
808
|
+
/req\.session\.\w+/i,
|
|
809
|
+
/\bjwt\.verify\b/i,
|
|
810
|
+
/\bbearer\s/i,
|
|
811
|
+
/\b(?:protect|guard)\(/i,
|
|
812
|
+
/\.use\([^)]*(?:auth|session|protect|requireAuth)/i
|
|
377
813
|
];
|
|
378
|
-
const hasAuth = authPatterns.some((p) => p.test(
|
|
814
|
+
const hasAuth = authPatterns.some((p) => p.test(codeOnly));
|
|
379
815
|
if (hasAuth) return [];
|
|
380
816
|
const matches = [];
|
|
381
817
|
for (const pattern of routePatterns) {
|
|
@@ -550,6 +986,37 @@ var xssVulnerability = {
|
|
|
550
986
|
matches.push(m);
|
|
551
987
|
}
|
|
552
988
|
}
|
|
989
|
+
if (!/\.(?:send|end|write)\s*\(/.test(content) || !/\$\{/.test(content)) {
|
|
990
|
+
return matches;
|
|
991
|
+
}
|
|
992
|
+
const ctx = tryParse(content, filePath);
|
|
993
|
+
if (!ctx) return matches;
|
|
994
|
+
const { parsed, taint } = ctx;
|
|
995
|
+
visitCalls(
|
|
996
|
+
parsed,
|
|
997
|
+
(callee) => {
|
|
998
|
+
if (callee.type !== "MemberExpression") return false;
|
|
999
|
+
if (callee.property.type !== "Identifier") return false;
|
|
1000
|
+
return ["send", "end", "write"].includes(callee.property.name);
|
|
1001
|
+
},
|
|
1002
|
+
(call, line) => {
|
|
1003
|
+
const first = call.arguments[0];
|
|
1004
|
+
if (!first || first.type !== "TemplateLiteral") return;
|
|
1005
|
+
const literalParts = first.quasis.map((q) => q.value.raw).join("");
|
|
1006
|
+
if (!/<\/?\w+/.test(literalParts)) return;
|
|
1007
|
+
if (!taint.isTainted(first)) return;
|
|
1008
|
+
if (matches.some((m) => m.line === line)) return;
|
|
1009
|
+
matches.push(
|
|
1010
|
+
astMatch(
|
|
1011
|
+
content,
|
|
1012
|
+
filePath,
|
|
1013
|
+
line,
|
|
1014
|
+
xssVulnerability,
|
|
1015
|
+
"Never echo user-supplied HTML back to the browser unescaped. Sanitize with DOMPurify, use a templating engine that auto-escapes, or set `res.type('text/plain')` if you never intended HTML."
|
|
1016
|
+
)
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
);
|
|
553
1020
|
return matches;
|
|
554
1021
|
}
|
|
555
1022
|
};
|
|
@@ -1003,7 +1470,7 @@ var prototypePollution = {
|
|
|
1003
1470
|
title: "Prototype Pollution Risk",
|
|
1004
1471
|
severity: "high",
|
|
1005
1472
|
category: "Injection",
|
|
1006
|
-
description: "
|
|
1473
|
+
description: "Deep-merging or Object.assign-ing attacker-controlled data (request bodies, parsed localStorage, query params) into a host object can pollute Object.prototype via keys like __proto__ and constructor.prototype.",
|
|
1007
1474
|
check(content, filePath) {
|
|
1008
1475
|
const matches = [];
|
|
1009
1476
|
const storageParsePatterns = [
|
|
@@ -1011,26 +1478,70 @@ var prototypePollution = {
|
|
|
1011
1478
|
/JSON\.parse\s*\(\s*window\.localStorage/g
|
|
1012
1479
|
];
|
|
1013
1480
|
const hasValidation = /schema|validate|sanitize|whitelist|allowedKeys|pick\(|Object\.freeze|zod|yup|joi|ajv/i.test(content);
|
|
1014
|
-
if (hasValidation)
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1481
|
+
if (!hasValidation) {
|
|
1482
|
+
const hasUnsafeMerge = /Object\.assign\s*\([^)]*JSON\.parse|\.\.\.JSON\.parse|\{.*\.\.\.(?:stored|saved|cached|parsed|data)/i.test(content);
|
|
1483
|
+
if (hasUnsafeMerge) {
|
|
1484
|
+
matches.push(...findMatches(
|
|
1485
|
+
content,
|
|
1486
|
+
/Object\.assign\s*\([^)]*JSON\.parse|\.\.\.JSON\.parse/g,
|
|
1487
|
+
prototypePollution,
|
|
1488
|
+
filePath,
|
|
1489
|
+
() => "Validate parsed data against an expected schema before merging into objects. Use Object.freeze(), a validation library (Zod, Yup), or manually check for __proto__ and constructor keys."
|
|
1490
|
+
));
|
|
1491
|
+
}
|
|
1492
|
+
for (const p of storageParsePatterns) {
|
|
1493
|
+
matches.push(...findMatches(
|
|
1494
|
+
content,
|
|
1495
|
+
p,
|
|
1496
|
+
prototypePollution,
|
|
1497
|
+
filePath,
|
|
1498
|
+
() => "Validate localStorage data against an expected schema before using it. Malicious extensions or XSS can modify localStorage values."
|
|
1499
|
+
));
|
|
1500
|
+
}
|
|
1033
1501
|
}
|
|
1502
|
+
if (!/\b(?:merge|assign|extend|defaults)\s*\(/.test(content)) return matches;
|
|
1503
|
+
const ctx = tryParse(content, filePath);
|
|
1504
|
+
if (!ctx) return matches;
|
|
1505
|
+
const { parsed, taint } = ctx;
|
|
1506
|
+
const ALL_ARG_SINKS = [
|
|
1507
|
+
{ obj: "_", method: "merge" },
|
|
1508
|
+
{ obj: "lodash", method: "merge" },
|
|
1509
|
+
{ method: "merge" },
|
|
1510
|
+
// bare `merge(target, src)` from ESM import
|
|
1511
|
+
{ obj: "_", method: "defaultsDeep" },
|
|
1512
|
+
{ obj: "lodash", method: "defaultsDeep" },
|
|
1513
|
+
{ obj: "Object", method: "assign" },
|
|
1514
|
+
{ method: "extend" },
|
|
1515
|
+
// jquery/underscore style
|
|
1516
|
+
{ obj: "$", method: "extend" }
|
|
1517
|
+
];
|
|
1518
|
+
visitCalls(
|
|
1519
|
+
parsed,
|
|
1520
|
+
(callee) => {
|
|
1521
|
+
for (const sink of ALL_ARG_SINKS) {
|
|
1522
|
+
if (sink.obj && isMethodCall(callee, sink.obj, sink.method)) return true;
|
|
1523
|
+
if (!sink.obj && isCalleeNamed(callee, sink.method)) return true;
|
|
1524
|
+
}
|
|
1525
|
+
return false;
|
|
1526
|
+
},
|
|
1527
|
+
(call, line) => {
|
|
1528
|
+
const sources = call.arguments.slice(1);
|
|
1529
|
+
const tainted = sources.some((arg) => {
|
|
1530
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
1531
|
+
return taint.isTainted(arg);
|
|
1532
|
+
});
|
|
1533
|
+
if (!tainted) return;
|
|
1534
|
+
matches.push(
|
|
1535
|
+
astMatch(
|
|
1536
|
+
content,
|
|
1537
|
+
filePath,
|
|
1538
|
+
line,
|
|
1539
|
+
prototypePollution,
|
|
1540
|
+
"Filter __proto__, constructor, and prototype keys before merging user-supplied data into an object. Prefer lodash.mergeWith() with a customizer that returns undefined for those keys, or validate the source with a schema library (Zod, Yup, Joi, Ajv) first."
|
|
1541
|
+
)
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
);
|
|
1034
1545
|
return matches;
|
|
1035
1546
|
}
|
|
1036
1547
|
};
|
|
@@ -1063,13 +1574,13 @@ var unsanitizedFilenames = {
|
|
|
1063
1574
|
description: "Using user-supplied filenames without sanitization in file operations can enable path traversal, overwriting system files, or executing commands via special characters.",
|
|
1064
1575
|
check(content, filePath) {
|
|
1065
1576
|
const matches = [];
|
|
1577
|
+
const hasSanitization = /sanitize|cleanFilename|safeFilename|replace\s*\(\s*\/\[.*\]\/|\.startsWith\s*\(\s*(?:UPLOADS_DIR|\w+_DIR)/i.test(content);
|
|
1578
|
+
if (hasSanitization) return [];
|
|
1066
1579
|
const patterns = [
|
|
1067
1580
|
/(?:writeFile|writeFileSync|createWriteStream|rename|copyFile)\s*\(\s*(?:`[^`]*\$\{|[^"'`\s,]+\s*\+)/g,
|
|
1068
1581
|
/(?:dialog\.showSaveDialog|saveDialog).*(?:defaultPath|fileName)\s*:\s*(?!["'`])/g,
|
|
1069
1582
|
/\.download\s*=\s*(?!["'`])/g
|
|
1070
1583
|
];
|
|
1071
|
-
const hasSanitization = /sanitize|cleanFilename|safeFilename|replace\s*\(\s*\/\[.*\]\//i.test(content);
|
|
1072
|
-
if (hasSanitization) return [];
|
|
1073
1584
|
for (const p of patterns) {
|
|
1074
1585
|
matches.push(...findMatches(
|
|
1075
1586
|
content,
|
|
@@ -1079,6 +1590,61 @@ var unsanitizedFilenames = {
|
|
|
1079
1590
|
() => "Sanitize filenames before use: strip path separators (/ \\), special chars, and '..' sequences. Example: name.replace(/[^a-zA-Z0-9._-]/g, '_')"
|
|
1080
1591
|
));
|
|
1081
1592
|
}
|
|
1593
|
+
if (!/\b(?:fs|fsPromises)\.(?:readFile|writeFile|appendFile|readFileSync|writeFileSync|appendFileSync|createReadStream|createWriteStream|unlink|unlinkSync|stat|statSync|rm|rmSync|mkdir|mkdirSync)\s*\(/.test(content)) {
|
|
1594
|
+
return matches;
|
|
1595
|
+
}
|
|
1596
|
+
const ctx = tryParse(content, filePath);
|
|
1597
|
+
if (!ctx) return matches;
|
|
1598
|
+
const { parsed, taint } = ctx;
|
|
1599
|
+
const FS_SINKS = /* @__PURE__ */ new Set([
|
|
1600
|
+
"readFile",
|
|
1601
|
+
"writeFile",
|
|
1602
|
+
"appendFile",
|
|
1603
|
+
"readFileSync",
|
|
1604
|
+
"writeFileSync",
|
|
1605
|
+
"appendFileSync",
|
|
1606
|
+
"createReadStream",
|
|
1607
|
+
"createWriteStream",
|
|
1608
|
+
"unlink",
|
|
1609
|
+
"unlinkSync",
|
|
1610
|
+
"rm",
|
|
1611
|
+
"rmSync",
|
|
1612
|
+
"stat",
|
|
1613
|
+
"statSync",
|
|
1614
|
+
"mkdir",
|
|
1615
|
+
"mkdirSync"
|
|
1616
|
+
]);
|
|
1617
|
+
const isFsReceiver = (obj) => {
|
|
1618
|
+
if (obj.type === "Identifier") return obj.name === "fs" || obj.name === "fsPromises";
|
|
1619
|
+
if (obj.type === "MemberExpression") {
|
|
1620
|
+
return obj.object.type === "Identifier" && obj.object.name === "fs" && obj.property.type === "Identifier" && obj.property.name === "promises";
|
|
1621
|
+
}
|
|
1622
|
+
return false;
|
|
1623
|
+
};
|
|
1624
|
+
visitCalls(
|
|
1625
|
+
parsed,
|
|
1626
|
+
(callee) => {
|
|
1627
|
+
if (callee.type !== "MemberExpression") return false;
|
|
1628
|
+
if (callee.property.type !== "Identifier") return false;
|
|
1629
|
+
if (!FS_SINKS.has(callee.property.name)) return false;
|
|
1630
|
+
return isFsReceiver(callee.object);
|
|
1631
|
+
},
|
|
1632
|
+
(call, line) => {
|
|
1633
|
+
const first = call.arguments[0];
|
|
1634
|
+
if (!first || first.type === "SpreadElement") return;
|
|
1635
|
+
if (!taint.isTainted(first)) return;
|
|
1636
|
+
if (matches.some((m) => m.line === line)) return;
|
|
1637
|
+
matches.push(
|
|
1638
|
+
astMatch(
|
|
1639
|
+
content,
|
|
1640
|
+
filePath,
|
|
1641
|
+
line,
|
|
1642
|
+
unsanitizedFilenames,
|
|
1643
|
+
"Resolve the path against a fixed base directory and verify it stays within that base before the fs call: `const target = path.resolve(BASE, req.body.name); if (!target.startsWith(BASE + path.sep)) throw new Error('path traversal');`"
|
|
1644
|
+
)
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
);
|
|
1082
1648
|
return matches;
|
|
1083
1649
|
}
|
|
1084
1650
|
};
|
|
@@ -1229,8 +1795,9 @@ var insecureDeserialization = {
|
|
|
1229
1795
|
/unserialize\s*\(/g,
|
|
1230
1796
|
// Ruby Marshal
|
|
1231
1797
|
/Marshal\.load\s*\(/g,
|
|
1232
|
-
// YAML unsafe load (Python)
|
|
1233
|
-
|
|
1798
|
+
// YAML unsafe load (Python + js-yaml). Filtered below so safe schema
|
|
1799
|
+
// arguments (SAFE_SCHEMA, FAILSAFE_SCHEMA, yaml.SafeLoader) don't FP.
|
|
1800
|
+
/yaml\.load\s*\(/g,
|
|
1234
1801
|
/yaml\.unsafe_load\s*\(/g,
|
|
1235
1802
|
// Java ObjectInputStream
|
|
1236
1803
|
/ObjectInputStream\s*\(/g,
|
|
@@ -1246,7 +1813,20 @@ var insecureDeserialization = {
|
|
|
1246
1813
|
() => "Never deserialize untrusted data. Use JSON instead of pickle/Marshal/unserialize. For YAML, use yaml.safe_load(). Validate and sanitize all input before deserialization."
|
|
1247
1814
|
));
|
|
1248
1815
|
}
|
|
1249
|
-
return matches
|
|
1816
|
+
return matches.filter((m) => {
|
|
1817
|
+
if (!/yaml\.load\s*\(/.test(m.snippet ?? "")) return true;
|
|
1818
|
+
const lineText = (m.snippet ?? "").toLowerCase();
|
|
1819
|
+
if (/safe_schema|failsafe_schema|safe_load|safeloader/.test(lineText)) {
|
|
1820
|
+
return false;
|
|
1821
|
+
}
|
|
1822
|
+
const lineIdx = m.line - 1;
|
|
1823
|
+
const lines = content.split("\n");
|
|
1824
|
+
const ctx = lines.slice(lineIdx, lineIdx + 3).join("\n").toLowerCase();
|
|
1825
|
+
if (/safe_schema|failsafe_schema|safe_load|safeloader/.test(ctx)) {
|
|
1826
|
+
return false;
|
|
1827
|
+
}
|
|
1828
|
+
return true;
|
|
1829
|
+
});
|
|
1250
1830
|
}
|
|
1251
1831
|
};
|
|
1252
1832
|
var hardcodedJWTSecret = {
|
|
@@ -1324,7 +1904,12 @@ var exposedDebugMode = {
|
|
|
1324
1904
|
/app\.debug\s*=\s*True/g,
|
|
1325
1905
|
/app\.run\s*\([^)]*debug\s*=\s*True/g,
|
|
1326
1906
|
// Source maps in production
|
|
1327
|
-
/devtool\s*:\s*["'`](?:eval|cheap|source-map|inline-source-map)["'`]/g
|
|
1907
|
+
/devtool\s*:\s*["'`](?:eval|cheap|source-map|inline-source-map)["'`]/g,
|
|
1908
|
+
// Debug endpoints that leak environment / process internals in the
|
|
1909
|
+
// response body. Very high-signal — a production service shouldn't
|
|
1910
|
+
// serialize process.env or process.version to callers.
|
|
1911
|
+
/res\.(?:json|send)\s*\([^)]*process\.(?:env|version|pid|arch|platform)/gi,
|
|
1912
|
+
/(?:env|environment)\s*:\s*process\.env\b/gi
|
|
1328
1913
|
];
|
|
1329
1914
|
for (const p of patterns) {
|
|
1330
1915
|
matches.push(...findMatches(
|
|
@@ -1362,6 +1947,21 @@ var insecureRandomness = {
|
|
|
1362
1947
|
if (nonSecurityVarNames.test(lineText)) continue;
|
|
1363
1948
|
matches.push(rm);
|
|
1364
1949
|
}
|
|
1950
|
+
const SECURITY_FN_CONTEXT = /\b(?:function|def|async|const|let|var)\s+\w*(?:generate|create|make|new|mint|issue)[A-Z_]\w*(?:Token|Session|Nonce|Salt|Secret|Password|Otp|Csrf|Key|Code|Auth)\w*/i;
|
|
1951
|
+
const SECURITY_IDENT_CONTEXT = /\b(?:session[_-]?token|csrf[_-]?token|reset[_-]?token|verify[_-]?code|otp[_-]?code|refresh[_-]?token|api[_-]?key)\b/i;
|
|
1952
|
+
if (SECURITY_FN_CONTEXT.test(content) || SECURITY_IDENT_CONTEXT.test(content)) {
|
|
1953
|
+
const raw = findMatches(
|
|
1954
|
+
content,
|
|
1955
|
+
/Math\.random\s*\(\s*\)/g,
|
|
1956
|
+
insecureRandomness,
|
|
1957
|
+
filePath,
|
|
1958
|
+
() => "Math.random() is not cryptographically secure. Use crypto.randomBytes() (Node), crypto.getRandomValues() (browser), or crypto.randomUUID() for tokens, session IDs, salts, and similar values."
|
|
1959
|
+
);
|
|
1960
|
+
for (const m of raw) {
|
|
1961
|
+
if (matches.some((existing) => existing.line === m.line)) continue;
|
|
1962
|
+
matches.push(m);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1365
1965
|
return matches;
|
|
1366
1966
|
}
|
|
1367
1967
|
};
|
|
@@ -1373,13 +1973,13 @@ var openRedirectParams = {
|
|
|
1373
1973
|
description: "Redirect parameters like ?redirect_url=, ?return_to=, ?next= passed directly to redirects enable phishing attacks.",
|
|
1374
1974
|
check(content, filePath) {
|
|
1375
1975
|
const matches = [];
|
|
1976
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
1977
|
+
const hasValidation = /allowed[_-]?(?:urls?|domains?|hosts?|paths?|routes?|list)|allow[_-]?list|valid[_-]?url|safe[_-]?domain|whitelist|startsWith.*https|new URL.*hostname/i.test(codeOnly);
|
|
1978
|
+
if (hasValidation) return [];
|
|
1376
1979
|
const patterns = [
|
|
1377
|
-
// Reading redirect-like query params and using in redirect
|
|
1378
1980
|
/(?:redirect_url|redirect_uri|return_to|return_url|next|callback_url|continue|goto|target|dest|destination|forward|redir)\s*(?:=|:)\s*(?:req\.query|req\.params|searchParams|query|params)\./gi,
|
|
1379
1981
|
/redirect\s*\(\s*(?:req\.query|req\.params|searchParams\.get)\s*\(\s*["'`](?:redirect|return|next|callback|url|goto)/gi
|
|
1380
1982
|
];
|
|
1381
|
-
const hasValidation = /allowedUrls|allowedDomains|allowedHosts|validUrl|safeDomain|whitelist|startsWith.*https|new URL.*hostname/i.test(content);
|
|
1382
|
-
if (hasValidation) return [];
|
|
1383
1983
|
for (const p of patterns) {
|
|
1384
1984
|
matches.push(...findMatches(
|
|
1385
1985
|
content,
|
|
@@ -1389,6 +1989,29 @@ var openRedirectParams = {
|
|
|
1389
1989
|
() => "Validate redirect URLs against an allowlist of trusted domains. Use: const url = new URL(input); if (!ALLOWED_HOSTS.includes(url.hostname)) reject."
|
|
1390
1990
|
));
|
|
1391
1991
|
}
|
|
1992
|
+
if (!/\.redirect\s*\(/.test(content)) return matches;
|
|
1993
|
+
const ctx = tryParse(content, filePath);
|
|
1994
|
+
if (!ctx) return matches;
|
|
1995
|
+
const { parsed, taint } = ctx;
|
|
1996
|
+
visitCalls(
|
|
1997
|
+
parsed,
|
|
1998
|
+
(callee) => isCalleeNamed(callee, "redirect"),
|
|
1999
|
+
(call, line) => {
|
|
2000
|
+
const first = call.arguments[0];
|
|
2001
|
+
if (!first || first.type === "SpreadElement") return;
|
|
2002
|
+
if (!taint.isTainted(first)) return;
|
|
2003
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2004
|
+
matches.push(
|
|
2005
|
+
astMatch(
|
|
2006
|
+
content,
|
|
2007
|
+
filePath,
|
|
2008
|
+
line,
|
|
2009
|
+
openRedirectParams,
|
|
2010
|
+
"Validate redirect targets against an allowlist before calling res.redirect(). `const ALLOWED = new Set(['/dashboard', '/settings']); if (!ALLOWED.has(next)) return res.redirect('/dashboard');`"
|
|
2011
|
+
)
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
);
|
|
1392
2015
|
return matches;
|
|
1393
2016
|
}
|
|
1394
2017
|
};
|
|
@@ -1431,8 +2054,7 @@ var exposedStackTraces = {
|
|
|
1431
2054
|
category: "Information Leakage",
|
|
1432
2055
|
description: "Returning error.stack or detailed error messages in API responses reveals internal code paths, file structure, and dependencies to attackers.",
|
|
1433
2056
|
check(content, filePath) {
|
|
1434
|
-
|
|
1435
|
-
if (!isApiFile) return [];
|
|
2057
|
+
if (!isServerSideFile(filePath)) return [];
|
|
1436
2058
|
const matches = [];
|
|
1437
2059
|
const patterns = [
|
|
1438
2060
|
// Sending stack trace in response
|
|
@@ -1463,9 +2085,10 @@ var insecureFileUpload = {
|
|
|
1463
2085
|
description: "File uploads validated only by extension (not MIME type or content) allow attackers to upload executable files disguised as images or documents.",
|
|
1464
2086
|
check(content, filePath) {
|
|
1465
2087
|
if (!/upload|multer|formidable|busboy|multipart/i.test(content)) return [];
|
|
2088
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
1466
2089
|
const matches = [];
|
|
1467
|
-
const hasExtCheck = /\.(?:endsWith|match|test)\s*\([^)]*(?:\.jpg|\.png|\.pdf|\.doc|ext)/i.test(
|
|
1468
|
-
const hasMimeCheck =
|
|
2090
|
+
const hasExtCheck = /\.(?:endsWith|match|test)\s*\([^)]*(?:\.jpg|\.png|\.pdf|\.doc|ext)/i.test(codeOnly) || /\/\\\.\((?:\?:)?[a-z]{2,5}(?:\|[a-z]{2,5})+\)/i.test(codeOnly);
|
|
2091
|
+
const hasMimeCheck = /\bmimetype\b|\bcontent-type\b|\bfile\.type\b|\bmime\.\w|\bmagic\.detect\b|\bfile-type\b|fileTypeFromBuffer/i.test(codeOnly);
|
|
1469
2092
|
if (hasExtCheck && !hasMimeCheck) {
|
|
1470
2093
|
matches.push(...findMatches(
|
|
1471
2094
|
content,
|
|
@@ -1529,34 +2152,53 @@ var ssrfVulnerability = {
|
|
|
1529
2152
|
category: "Injection",
|
|
1530
2153
|
description: "Fetching URLs from user input without validation allows attackers to access internal services, cloud metadata endpoints (169.254.169.254), and private networks.",
|
|
1531
2154
|
check(content, filePath) {
|
|
1532
|
-
if (
|
|
1533
|
-
const isServerFile = /(?:\/api\/|routes?\/|controllers?\/|server\.|handler|middleware)/i.test(filePath);
|
|
1534
|
-
if (!isServerFile) return [];
|
|
2155
|
+
if (!isServerSideFile(filePath)) return [];
|
|
1535
2156
|
const matches = [];
|
|
1536
|
-
const patterns = [
|
|
1537
|
-
/(?:fetch|axios\.get|axios\.post|axios|got|request|http\.get|https\.get)\s*\(\s*(?:req\.(?:body|query|params))\./gi
|
|
1538
|
-
];
|
|
1539
2157
|
const hasValidation = /allowedHosts|allowedDomains|allowedUrls|safeDomain|whitelist|urlValidator|new URL.*hostname.*includes|isAllowedUrl|validateUrl|isValidUrl/i.test(content);
|
|
1540
2158
|
if (hasValidation) return [];
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
2159
|
+
const inlinePattern = /(?:fetch|axios\.get|axios\.post|axios|got|request|http\.get|https\.get)\s*\(\s*(?:req\.(?:body|query|params))\./gi;
|
|
2160
|
+
matches.push(...findMatches(
|
|
2161
|
+
content,
|
|
2162
|
+
inlinePattern,
|
|
2163
|
+
ssrfVulnerability,
|
|
2164
|
+
filePath,
|
|
2165
|
+
() => "Validate URLs against an allowlist before fetching. Block internal IPs: 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.169.254 (cloud metadata). Use: const url = new URL(input); if (!ALLOWED_HOSTS.includes(url.hostname)) throw new Error('Blocked');"
|
|
2166
|
+
));
|
|
2167
|
+
if (!/\b(?:fetch|axios|got|request|http\.get|https\.get)\b/.test(content)) return matches;
|
|
2168
|
+
const ctx = tryParse(content, filePath);
|
|
2169
|
+
if (!ctx) return matches;
|
|
2170
|
+
const { parsed, taint } = ctx;
|
|
2171
|
+
const FETCH_CALLEES = /* @__PURE__ */ new Set(["fetch", "axios", "got", "request"]);
|
|
2172
|
+
const FETCH_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "request"]);
|
|
2173
|
+
visitCalls(
|
|
2174
|
+
parsed,
|
|
2175
|
+
(callee) => {
|
|
2176
|
+
if (callee.type === "Identifier" && FETCH_CALLEES.has(callee.name)) return true;
|
|
2177
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
2178
|
+
if (!FETCH_METHODS.has(callee.property.name)) return false;
|
|
2179
|
+
const obj = callee.object;
|
|
2180
|
+
if (obj.type === "Identifier") {
|
|
2181
|
+
return obj.name === "axios" || obj.name === "got" || obj.name === "http" || obj.name === "https";
|
|
2182
|
+
}
|
|
1556
2183
|
}
|
|
1557
|
-
|
|
2184
|
+
return false;
|
|
2185
|
+
},
|
|
2186
|
+
(call, line) => {
|
|
2187
|
+
const first = call.arguments[0];
|
|
2188
|
+
if (!first || first.type === "SpreadElement") return;
|
|
2189
|
+
if (!taint.isTainted(first)) return;
|
|
2190
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2191
|
+
matches.push(
|
|
2192
|
+
astMatch(
|
|
2193
|
+
content,
|
|
2194
|
+
filePath,
|
|
2195
|
+
line,
|
|
2196
|
+
ssrfVulnerability,
|
|
2197
|
+
"Validate URLs against an allowlist before fetching. Parse the URL, check the hostname against a static set, and block internal IPs (127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.169.254)."
|
|
2198
|
+
)
|
|
2199
|
+
);
|
|
1558
2200
|
}
|
|
1559
|
-
|
|
2201
|
+
);
|
|
1560
2202
|
return matches;
|
|
1561
2203
|
}
|
|
1562
2204
|
};
|
|
@@ -1567,19 +2209,15 @@ var massAssignment = {
|
|
|
1567
2209
|
category: "Authorization",
|
|
1568
2210
|
description: "Spreading or assigning request body directly into database models allows attackers to set fields they shouldn't (e.g., isAdmin, role, verified).",
|
|
1569
2211
|
check(content, filePath) {
|
|
1570
|
-
|
|
1571
|
-
|
|
2212
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2213
|
+
const hasSanitization = /pick\(|omit\(|allowedFields|sanitize|whitelist|permit|strong_params/i.test(content);
|
|
2214
|
+
if (hasSanitization) return [];
|
|
1572
2215
|
const matches = [];
|
|
1573
2216
|
const patterns = [
|
|
1574
|
-
// Object.assign(model, req.body)
|
|
1575
2217
|
/Object\.assign\s*\(\s*(?:user|account|profile|record|doc|model|entity)[^,]*,\s*(?:req\.body|body|input|data)\s*\)/gi,
|
|
1576
|
-
// Spread req.body into create/update
|
|
1577
2218
|
/(?:create|update|insert|save|findOneAndUpdate|updateOne|upsert)\s*\(\s*\{[^}]*\.\.\.(?:req\.body|body|input|data)/gi,
|
|
1578
|
-
// Direct req.body into DB
|
|
1579
2219
|
/(?:create|insert|save)\s*\(\s*(?:req\.body|body)\s*\)/gi
|
|
1580
2220
|
];
|
|
1581
|
-
const hasSanitization = /pick\(|omit\(|allowedFields|sanitize|whitelist|permit|strong_params/i.test(content);
|
|
1582
|
-
if (hasSanitization) return [];
|
|
1583
2221
|
for (const p of patterns) {
|
|
1584
2222
|
matches.push(...findMatches(
|
|
1585
2223
|
content,
|
|
@@ -1589,6 +2227,61 @@ var massAssignment = {
|
|
|
1589
2227
|
() => "Never pass req.body directly to database operations. Explicitly pick allowed fields: const { name, email } = req.body; await db.create({ name, email });"
|
|
1590
2228
|
));
|
|
1591
2229
|
}
|
|
2230
|
+
const ORM_METHOD_PREFIXES = [
|
|
2231
|
+
"create",
|
|
2232
|
+
"insert",
|
|
2233
|
+
"save",
|
|
2234
|
+
"update",
|
|
2235
|
+
"upsert",
|
|
2236
|
+
"patch",
|
|
2237
|
+
"bulkCreate",
|
|
2238
|
+
"bulkInsert",
|
|
2239
|
+
"bulkUpsert",
|
|
2240
|
+
"findOneAndUpdate",
|
|
2241
|
+
"findByIdAndUpdate",
|
|
2242
|
+
"findAndUpdate",
|
|
2243
|
+
"build",
|
|
2244
|
+
"merge",
|
|
2245
|
+
"replace"
|
|
2246
|
+
];
|
|
2247
|
+
const isOrmMethod = (name) => ORM_METHOD_PREFIXES.some((p) => name === p || name.startsWith(p));
|
|
2248
|
+
if (!/\b(?:create|insert|save|update|upsert|patch|bulk|findOneAndUpdate|findByIdAndUpdate|findAndUpdate|build|merge|replace)\w*\s*\(/.test(content)) {
|
|
2249
|
+
return matches;
|
|
2250
|
+
}
|
|
2251
|
+
const ctx = tryParse(content, filePath);
|
|
2252
|
+
if (!ctx) return matches;
|
|
2253
|
+
const { parsed, taint } = ctx;
|
|
2254
|
+
visitCalls(
|
|
2255
|
+
parsed,
|
|
2256
|
+
(callee) => {
|
|
2257
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
2258
|
+
return isOrmMethod(callee.property.name);
|
|
2259
|
+
}
|
|
2260
|
+
return false;
|
|
2261
|
+
},
|
|
2262
|
+
(call, line) => {
|
|
2263
|
+
const tainted = call.arguments.some((arg) => {
|
|
2264
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
2265
|
+
if (arg.type === "ObjectExpression") {
|
|
2266
|
+
return arg.properties.some(
|
|
2267
|
+
(p) => p.type === "SpreadElement" && taint.isTainted(p.argument)
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
return taint.isTainted(arg);
|
|
2271
|
+
});
|
|
2272
|
+
if (!tainted) return;
|
|
2273
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2274
|
+
matches.push(
|
|
2275
|
+
astMatch(
|
|
2276
|
+
content,
|
|
2277
|
+
filePath,
|
|
2278
|
+
line,
|
|
2279
|
+
massAssignment,
|
|
2280
|
+
"Pick allowed fields explicitly instead of spreading the request body. `User.create({ email: req.body.email, name: req.body.name })` rather than `User.create({ ...req.body })`."
|
|
2281
|
+
)
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
);
|
|
1592
2285
|
return matches;
|
|
1593
2286
|
}
|
|
1594
2287
|
};
|
|
@@ -1601,12 +2294,12 @@ var timingAttack = {
|
|
|
1601
2294
|
check(content, filePath) {
|
|
1602
2295
|
if (isTestFile(filePath)) return [];
|
|
1603
2296
|
const matches = [];
|
|
2297
|
+
const hasTimingSafe = /timingSafeEqual|constantTimeEqual|safeCompare|secureCompare/i.test(content);
|
|
2298
|
+
if (hasTimingSafe) return [];
|
|
1604
2299
|
const patterns = [
|
|
1605
2300
|
/(?:token|secret|hash|digest|signature|hmac|apiKey|api_key)\s*(?:===|!==)\s*(?:req\.|body\.|params\.|query\.|input)/gi,
|
|
1606
2301
|
/(?:^|[^.\w])(?:req\.|body\.|params\.|query\.|input)[\w.]*(?:token|secret|hash|digest|signature|hmac)\s*(?:===|!==)/gim
|
|
1607
2302
|
];
|
|
1608
|
-
const hasTimingSafe = /timingSafeEqual|constantTimeEqual|safeCompare|secureCompare/i.test(content);
|
|
1609
|
-
if (hasTimingSafe) return [];
|
|
1610
2303
|
for (const p of patterns) {
|
|
1611
2304
|
const raw = findMatches(
|
|
1612
2305
|
content,
|
|
@@ -1621,6 +2314,43 @@ var timingAttack = {
|
|
|
1621
2314
|
matches.push(m);
|
|
1622
2315
|
}
|
|
1623
2316
|
}
|
|
2317
|
+
if (!/===|!==/.test(content)) return matches;
|
|
2318
|
+
const ctx = tryParse(content, filePath);
|
|
2319
|
+
if (!ctx) return matches;
|
|
2320
|
+
const SECRET_NAME_RE = /(?:secret|token|api[_-]?key|auth[_-]?key|jwt[_-]?secret|signature|hmac|password|passwd|pwd_hash|pwd_digest|digest)/i;
|
|
2321
|
+
function looksLikeSecret(node) {
|
|
2322
|
+
if (node.type === "Identifier") return SECRET_NAME_RE.test(node.name);
|
|
2323
|
+
if (node.type === "MemberExpression") {
|
|
2324
|
+
if (node.property.type === "Identifier" && SECRET_NAME_RE.test(node.property.name)) return true;
|
|
2325
|
+
if (node.property.type === "StringLiteral" && SECRET_NAME_RE.test(node.property.value)) return true;
|
|
2326
|
+
return looksLikeSecret(node.object);
|
|
2327
|
+
}
|
|
2328
|
+
return false;
|
|
2329
|
+
}
|
|
2330
|
+
visitBinary(ctx.parsed, (n, line) => {
|
|
2331
|
+
if (n.operator !== "===" && n.operator !== "!==" && n.operator !== "==" && n.operator !== "!=") {
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
if (n.left.type === "UnaryExpression" && n.left.operator === "typeof") return;
|
|
2335
|
+
if (n.right.type === "UnaryExpression" && n.right.operator === "typeof") return;
|
|
2336
|
+
const leftSecret = looksLikeSecret(n.left);
|
|
2337
|
+
const rightSecret = looksLikeSecret(n.right);
|
|
2338
|
+
if (!leftSecret && !rightSecret) return;
|
|
2339
|
+
const otherSide = leftSecret ? n.right : n.left;
|
|
2340
|
+
if (otherSide.type === "StringLiteral" || otherSide.type === "NumericLiteral" || otherSide.type === "NullLiteral" || otherSide.type === "BooleanLiteral") {
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2344
|
+
matches.push(
|
|
2345
|
+
astMatch(
|
|
2346
|
+
content,
|
|
2347
|
+
filePath,
|
|
2348
|
+
line,
|
|
2349
|
+
timingAttack,
|
|
2350
|
+
"Compare secrets with crypto.timingSafeEqual() after a length check. === short-circuits on the first differing byte and leaks information via timing."
|
|
2351
|
+
)
|
|
2352
|
+
);
|
|
2353
|
+
});
|
|
1624
2354
|
return matches;
|
|
1625
2355
|
}
|
|
1626
2356
|
};
|
|
@@ -1631,16 +2361,14 @@ var logInjection = {
|
|
|
1631
2361
|
category: "Injection",
|
|
1632
2362
|
description: "Logging unsanitized user input allows attackers to forge log entries, inject malicious content, or exploit log aggregation systems via newlines and special characters.",
|
|
1633
2363
|
check(content, filePath) {
|
|
1634
|
-
if (
|
|
1635
|
-
const
|
|
1636
|
-
if (
|
|
2364
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2365
|
+
const hasSanitization = /replace\s*\(\s*\/\[?\\r\\n\]|sanitizeLog|stripNewlines|sanitizeForLog/i.test(content);
|
|
2366
|
+
if (hasSanitization) return [];
|
|
1637
2367
|
const matches = [];
|
|
1638
2368
|
const patterns = [
|
|
1639
2369
|
/console\.(?:log|warn|error|info)\s*\([^)]*(?:req\.body|req\.query|req\.params|req\.headers)\s*\)/gi,
|
|
1640
2370
|
/(?:logger|log)\.(?:info|warn|error|debug)\s*\([^)]*(?:req\.body|req\.query|req\.params)\s*\)/gi
|
|
1641
2371
|
];
|
|
1642
|
-
const hasSanitization = /sanitize|escape|JSON\.stringify|replace.*\\n/i.test(content);
|
|
1643
|
-
if (hasSanitization) return [];
|
|
1644
2372
|
for (const p of patterns) {
|
|
1645
2373
|
matches.push(...findMatches(
|
|
1646
2374
|
content,
|
|
@@ -1650,6 +2378,41 @@ var logInjection = {
|
|
|
1650
2378
|
() => "Sanitize user input before logging: strip newlines and control characters. Use JSON.stringify() or a structured logger (e.g., pino, winston) that escapes values automatically."
|
|
1651
2379
|
));
|
|
1652
2380
|
}
|
|
2381
|
+
if (!/(?:console\.|logger\.|log\.)\s*\w+\s*\(/.test(content)) return matches;
|
|
2382
|
+
const ctx = tryParse(content, filePath);
|
|
2383
|
+
if (!ctx) return matches;
|
|
2384
|
+
const { parsed, taint } = ctx;
|
|
2385
|
+
const LOG_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug", "trace"]);
|
|
2386
|
+
visitCalls(
|
|
2387
|
+
parsed,
|
|
2388
|
+
(callee) => {
|
|
2389
|
+
if (callee.type !== "MemberExpression") return false;
|
|
2390
|
+
if (callee.property.type !== "Identifier") return false;
|
|
2391
|
+
if (!LOG_METHODS.has(callee.property.name)) return false;
|
|
2392
|
+
const obj = callee.object;
|
|
2393
|
+
if (obj.type === "Identifier") {
|
|
2394
|
+
return obj.name === "console" || obj.name === "logger" || obj.name === "log";
|
|
2395
|
+
}
|
|
2396
|
+
return false;
|
|
2397
|
+
},
|
|
2398
|
+
(call, line) => {
|
|
2399
|
+
const tainted = call.arguments.some((arg) => {
|
|
2400
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
2401
|
+
return taint.isTainted(arg);
|
|
2402
|
+
});
|
|
2403
|
+
if (!tainted) return;
|
|
2404
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2405
|
+
matches.push(
|
|
2406
|
+
astMatch(
|
|
2407
|
+
content,
|
|
2408
|
+
filePath,
|
|
2409
|
+
line,
|
|
2410
|
+
logInjection,
|
|
2411
|
+
"Strip newlines/control chars before embedding user input in a log line. `value.replace(/[\\r\\n]/g, ' ')` for quick fix; a structured logger escapes values automatically."
|
|
2412
|
+
)
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
);
|
|
1653
2416
|
return matches;
|
|
1654
2417
|
}
|
|
1655
2418
|
};
|
|
@@ -1662,7 +2425,33 @@ var weakPasswordRequirements = {
|
|
|
1662
2425
|
check(content, filePath) {
|
|
1663
2426
|
if (isTestFile(filePath)) return [];
|
|
1664
2427
|
if (!/(?:password|passwd|pwd)/i.test(content)) return [];
|
|
1665
|
-
|
|
2428
|
+
const isPasswordContext = /(?:register|signup|sign.up|createUser|create.user|changePassword|resetPassword|set.password|validatePassword|validate.password|passwordPolicy|password.policy)/i.test(
|
|
2429
|
+
content
|
|
2430
|
+
) || isServerSideFile(filePath);
|
|
2431
|
+
const matches = [];
|
|
2432
|
+
if (isPasswordContext) {
|
|
2433
|
+
const weakThresholdPatterns = [
|
|
2434
|
+
// password.length < 4, password.length < 6, etc.
|
|
2435
|
+
/(?:password|pwd|passwd)\s*\.length\s*<\s*[1-7]\b/gi,
|
|
2436
|
+
/(?:password|pwd|passwd)\s*\.length\s*<=\s*[0-6]\b/gi,
|
|
2437
|
+
// password.length >= 4, password.length >= 6 (min length set too low)
|
|
2438
|
+
/(?:password|pwd|passwd)\s*\.length\s*>=\s*[1-7]\b/gi,
|
|
2439
|
+
/(?:password|pwd|passwd)\s*\.length\s*>\s*[0-6]\b/gi,
|
|
2440
|
+
// Python: len(password) < 8 with a low threshold
|
|
2441
|
+
/len\s*\(\s*(?:password|pwd|passwd)\s*\)\s*<\s*[1-7]\b/gi
|
|
2442
|
+
];
|
|
2443
|
+
for (const p of weakThresholdPatterns) {
|
|
2444
|
+
matches.push(...findMatches(
|
|
2445
|
+
content,
|
|
2446
|
+
p,
|
|
2447
|
+
weakPasswordRequirements,
|
|
2448
|
+
filePath,
|
|
2449
|
+
() => "Minimum password length is too low. Require at least 8 characters with complexity (upper+lower+digit+symbol) or 12+ characters without complexity. OWASP ASVS L1 requires 8+; NIST 800-63B recommends 12+."
|
|
2450
|
+
));
|
|
2451
|
+
}
|
|
2452
|
+
if (matches.length > 0) return matches;
|
|
2453
|
+
}
|
|
2454
|
+
if (!isPasswordContext) return [];
|
|
1666
2455
|
const hasValidation = /(?:password|pwd).*(?:\.length|minLength|minlength|min_length)\s*(?:>=?|<|>)\s*\d|(?:password|pwd).*(?:match|test|regex|pattern)|zxcvbn|password-validator|passwordStrength|isStrongPassword|joi\.|yup\.|zod\.|validate|schema/i.test(content);
|
|
1667
2456
|
if (hasValidation) return [];
|
|
1668
2457
|
const hasPasswordHandling = /(?:password|pwd)\s*[:=]\s*(?:req\.body|body|input|params|args)\./i.test(content);
|
|
@@ -1695,7 +2484,8 @@ var sessionFixation = {
|
|
|
1695
2484
|
if (isTestFile(filePath)) return [];
|
|
1696
2485
|
if (!/(?:login|signin|sign.in|authenticate)/i.test(content)) return [];
|
|
1697
2486
|
if (!/session/i.test(content)) return [];
|
|
1698
|
-
const
|
|
2487
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
2488
|
+
const hasRegenerate = /regenerate|destroy.*create|req\.session\.id\s*=|session\.regenerateId|rotateSession|clearCookies/i.test(codeOnly);
|
|
1699
2489
|
if (hasRegenerate) return [];
|
|
1700
2490
|
const hasLogin = /(?:function\s+(?:login|signin|authenticate)|(?:login|signin|authenticate)\s*(?:=\s*(?:async\s*)?\(|:\s*(?:async\s*)?\())/i.test(content);
|
|
1701
2491
|
if (!hasLogin) return [];
|
|
@@ -1718,7 +2508,8 @@ var missingBruteForce = {
|
|
|
1718
2508
|
const isLoginFile = /(?:login|signin|sign.in|auth)/i.test(filePath) || /(?:login|signin|authenticate).*(?:post|handler|route)/i.test(content);
|
|
1719
2509
|
if (!isLoginFile) return [];
|
|
1720
2510
|
if (!/(?:password|credential)/i.test(content)) return [];
|
|
1721
|
-
const
|
|
2511
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
2512
|
+
const hasBruteForce = /rate.?limit|throttle|lockout|maxAttempts|max_attempts|failedAttempts|loginAttempts|express-brute|express-rate-limit|slowDown/i.test(codeOnly);
|
|
1722
2513
|
if (hasBruteForce) return [];
|
|
1723
2514
|
return findMatches(
|
|
1724
2515
|
content,
|
|
@@ -1745,7 +2536,10 @@ var nosqlInjection = {
|
|
|
1745
2536
|
// $where with user input
|
|
1746
2537
|
/\$where\s*:\s*(?!["'`])/g,
|
|
1747
2538
|
// Direct variable in query without sanitization
|
|
1748
|
-
/\.(?:findOne|findById|deleteOne|updateOne|findOneAndUpdate)\s*\(\s*\{[^}]*:\s*(?:req\.(?:body|query|params))\./gi
|
|
2539
|
+
/\.(?:findOne|findById|deleteOne|updateOne|findOneAndUpdate)\s*\(\s*\{[^}]*:\s*(?:req\.(?:body|query|params))\./gi,
|
|
2540
|
+
// findOneAndUpdate / updateOne / deleteOne / find with req.body as
|
|
2541
|
+
// the entire filter (attacker controls the filter shape).
|
|
2542
|
+
/\.(?:findOne|findById|findOneAndUpdate|updateOne|deleteOne|deleteMany|updateMany|replaceOne|find)\s*\(\s*req\.body\s*[,)]/gi
|
|
1749
2543
|
];
|
|
1750
2544
|
const hasSanitization = /sanitize|escape|mongo-sanitize|express-mongo-sanitize|validator|typeof.*===.*string/i.test(content);
|
|
1751
2545
|
if (hasSanitization) return [];
|
|
@@ -1824,7 +2618,7 @@ var graphqlIntrospection = {
|
|
|
1824
2618
|
category: "Information Leakage",
|
|
1825
2619
|
description: "GraphQL introspection exposes your entire API schema, types, queries, and mutations to attackers, making it easy to find attack vectors.",
|
|
1826
2620
|
check(content, filePath) {
|
|
1827
|
-
if (!/graphql/i.test(content) && !/graphql/i.test(filePath)) return [];
|
|
2621
|
+
if (!/graphql|apollo|ApolloServer|GraphQLServer|createYoga|buildSchema|makeExecutableSchema/i.test(content) && !/graphql|apollo/i.test(filePath)) return [];
|
|
1828
2622
|
const matches = [];
|
|
1829
2623
|
if (/introspection\s*:\s*true/i.test(content)) {
|
|
1830
2624
|
matches.push(...findMatches(
|
|
@@ -1963,6 +2757,15 @@ var exposedSourceMaps = {
|
|
|
1963
2757
|
() => "Set productionSourceMap: false to avoid exposing source code in production."
|
|
1964
2758
|
));
|
|
1965
2759
|
}
|
|
2760
|
+
if (/productionBrowserSourceMaps\s*:\s*true/i.test(content)) {
|
|
2761
|
+
matches.push(...findMatches(
|
|
2762
|
+
content,
|
|
2763
|
+
/productionBrowserSourceMaps\s*:\s*true/gi,
|
|
2764
|
+
exposedSourceMaps,
|
|
2765
|
+
filePath,
|
|
2766
|
+
() => "Set productionBrowserSourceMaps: false in next.config.js. If you need source maps for error tracking, upload them to your error tracker instead (e.g. Sentry upload-sourcemaps) rather than serving them publicly."
|
|
2767
|
+
));
|
|
2768
|
+
}
|
|
1966
2769
|
return matches;
|
|
1967
2770
|
}
|
|
1968
2771
|
};
|
|
@@ -2088,14 +2891,14 @@ var weakHashing = {
|
|
|
2088
2891
|
if (filePath.includes("test") || filePath.includes("mock")) return [];
|
|
2089
2892
|
if (/bcrypt|scrypt|argon2/i.test(content)) return [];
|
|
2090
2893
|
const matches = [];
|
|
2091
|
-
const
|
|
2894
|
+
const inlinePatterns = [
|
|
2092
2895
|
/(?:md5|sha1|sha256|sha512)\s*\([^)]*(?:password|passwd|pwd)/gi,
|
|
2093
2896
|
/createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\).*(?:password|passwd|pwd)/gi,
|
|
2094
2897
|
/(?:password|passwd|pwd).*createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\)/gi,
|
|
2095
2898
|
/hashlib\.(?:md5|sha1|sha256)\s*\([^)]*(?:password|passwd|pwd)/gi,
|
|
2096
2899
|
/Digest::(?:MD5|SHA1|SHA256).*(?:password|passwd|pwd)/gi
|
|
2097
2900
|
];
|
|
2098
|
-
for (const p of
|
|
2901
|
+
for (const p of inlinePatterns) {
|
|
2099
2902
|
matches.push(...findMatches(
|
|
2100
2903
|
content,
|
|
2101
2904
|
p,
|
|
@@ -2104,6 +2907,27 @@ var weakHashing = {
|
|
|
2104
2907
|
() => "Use bcrypt, scrypt, or argon2 for password hashing \u2014 they're intentionally slow. Example: const hash = await bcrypt.hash(password, 12);"
|
|
2105
2908
|
));
|
|
2106
2909
|
}
|
|
2910
|
+
const hasPasswordContext = /\b(?:hashPassword|hash_password|verify_password|verifyPassword|password_hash|password_hash_verify|password_hashing)\b/i.test(content) || /\b(?:function|def|async)\s+\w*(?:password|pwd)\w*/i.test(content) || /\bpassword\s*[:=]/i.test(content);
|
|
2911
|
+
if (hasPasswordContext) {
|
|
2912
|
+
const weakCallPatterns = [
|
|
2913
|
+
/createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\s*\)/gi,
|
|
2914
|
+
/hashlib\.(?:md5|sha1|sha256)\s*\(/gi,
|
|
2915
|
+
/Digest::(?:MD5|SHA1|SHA256)\./gi
|
|
2916
|
+
];
|
|
2917
|
+
for (const p of weakCallPatterns) {
|
|
2918
|
+
const raw = findMatches(
|
|
2919
|
+
content,
|
|
2920
|
+
p,
|
|
2921
|
+
weakHashing,
|
|
2922
|
+
filePath,
|
|
2923
|
+
() => "Use bcrypt, scrypt, or argon2 for password hashing. These algorithms are intentionally slow so brute-force attacks are infeasible."
|
|
2924
|
+
);
|
|
2925
|
+
for (const m of raw) {
|
|
2926
|
+
if (matches.some((existing) => existing.line === m.line)) continue;
|
|
2927
|
+
matches.push(m);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2107
2931
|
return matches;
|
|
2108
2932
|
}
|
|
2109
2933
|
};
|
|
@@ -2186,7 +3010,8 @@ var dangerousInnerHTML = {
|
|
|
2186
3010
|
if (!filePath.match(/\.(jsx|tsx)$/)) return [];
|
|
2187
3011
|
if (isTestFile(filePath)) return [];
|
|
2188
3012
|
if (!/dangerouslySetInnerHTML/i.test(content)) return [];
|
|
2189
|
-
const
|
|
3013
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, "");
|
|
3014
|
+
const hasSanitize = /\b(?:DOMPurify|sanitizeHtml|sanitize-html|isomorphic-dompurify)\b|\b(?:sanitize|purify|xss)\s*\(/i.test(codeOnly);
|
|
2190
3015
|
if (hasSanitize) return [];
|
|
2191
3016
|
const findings = [];
|
|
2192
3017
|
const re = /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([^}]+)\}/g;
|
|
@@ -2474,7 +3299,8 @@ var missingCSRF = {
|
|
|
2474
3299
|
() => "Remove @csrf_exempt and use proper CSRF tokens. For APIs, use token-based auth (JWT) instead of session cookies."
|
|
2475
3300
|
));
|
|
2476
3301
|
}
|
|
2477
|
-
|
|
3302
|
+
const pythonCodeOnly = content.replace(/^\s*#.*$/gm, "");
|
|
3303
|
+
if (/MIDDLEWARE.*=.*\[/s.test(pythonCodeOnly) && !/CsrfViewMiddleware/i.test(pythonCodeOnly) && /django/i.test(content)) {
|
|
2478
3304
|
matches.push(...findMatches(
|
|
2479
3305
|
content,
|
|
2480
3306
|
/MIDDLEWARE\s*=/g,
|
|
@@ -2493,20 +3319,47 @@ var githubActionsInjection = {
|
|
|
2493
3319
|
category: "Injection",
|
|
2494
3320
|
description: "Using ${{ github.event.* }} directly in 'run:' steps allows attackers to inject shell commands via PR titles, issue bodies, or branch names.",
|
|
2495
3321
|
check(content, filePath) {
|
|
2496
|
-
|
|
3322
|
+
const isWorkflowPath = /\.github\/workflows\/.*\.(ya?ml)$/i.test(filePath);
|
|
3323
|
+
const looksLikeWorkflow = /\.(ya?ml)$/i.test(filePath) && /^\s*on\s*:/m.test(content) && /^\s*jobs\s*:/m.test(content);
|
|
3324
|
+
if (!isWorkflowPath && !looksLikeWorkflow) return [];
|
|
2497
3325
|
const matches = [];
|
|
2498
3326
|
const patterns = [
|
|
2499
3327
|
/run:.*\$\{\{\s*github\.event\.(?:issue|pull_request|comment|review|head_commit)\.(?:title|body|message)/gi,
|
|
2500
|
-
/run:.*\$\{\{\s*github\.event\.(?:inputs|head_ref|base_ref)/gi
|
|
3328
|
+
/run:.*\$\{\{\s*github\.event\.(?:inputs|head_ref|base_ref)/gi,
|
|
3329
|
+
// Literal-block `run: |` with user-controllable interpolation on the
|
|
3330
|
+
// SAME line or a following indented line. Anchored to `run:` (either
|
|
3331
|
+
// on the same line or within the prior few lines before the
|
|
3332
|
+
// interpolation) so `env: TITLE: ${{ github.event.issue.title }}` —
|
|
3333
|
+
// the recommended-fix shape — doesn't false-positive.
|
|
3334
|
+
/(?:^\s*(?:-\s+)?run:\s*\|?\s*\n(?:(?!^\s*(?:[-]\s*)?(?:env|with|id|name|if|timeout-minutes|continue-on-error|shell|working-directory|uses):).+\n){0,20}\s*.*)?\$\{\{\s*github\.event\.(?:issue|pull_request|comment|review|head_commit)\.(?:title|body|message|name)/gmi
|
|
2501
3335
|
];
|
|
3336
|
+
const lines = content.split("\n");
|
|
3337
|
+
const isEnvMappingLine = (lineNum) => {
|
|
3338
|
+
const lineText = lines[lineNum - 1] ?? "";
|
|
3339
|
+
if (/^\s*env\s*:/.test(lineText)) return true;
|
|
3340
|
+
const indent = lineText.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
3341
|
+
if (indent === 0) return false;
|
|
3342
|
+
for (let i = lineNum - 2; i >= 0; i--) {
|
|
3343
|
+
const prev = lines[i] ?? "";
|
|
3344
|
+
if (!prev.trim()) continue;
|
|
3345
|
+
const prevIndent = prev.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
3346
|
+
if (prevIndent < indent) {
|
|
3347
|
+
return /^\s*env\s*:/.test(prev);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
return false;
|
|
3351
|
+
};
|
|
2502
3352
|
for (const p of patterns) {
|
|
2503
|
-
|
|
3353
|
+
for (const m of findMatches(
|
|
2504
3354
|
content,
|
|
2505
3355
|
p,
|
|
2506
3356
|
githubActionsInjection,
|
|
2507
3357
|
filePath,
|
|
2508
3358
|
() => "Never use ${{ github.event.* }} directly in 'run:'. Pass it as an environment variable: env: TITLE: ${{ github.event.issue.title }} then use $TITLE in the script."
|
|
2509
|
-
))
|
|
3359
|
+
)) {
|
|
3360
|
+
if (isEnvMappingLine(m.line)) continue;
|
|
3361
|
+
matches.push(m);
|
|
3362
|
+
}
|
|
2510
3363
|
}
|
|
2511
3364
|
return matches;
|
|
2512
3365
|
}
|
|
@@ -2554,6 +3407,15 @@ var corsServerless = {
|
|
|
2554
3407
|
() => "Replace wildcard CORS with specific origins: allowOrigin: ['https://yourdomain.com']. Wildcard allows any site to call your API."
|
|
2555
3408
|
));
|
|
2556
3409
|
}
|
|
3410
|
+
if (/^\s*origin\s*:\s*['"]\*['"]/m.test(content)) {
|
|
3411
|
+
matches.push(...findMatches(
|
|
3412
|
+
content,
|
|
3413
|
+
/^\s*origin\s*:\s*['"]\*['"]/gim,
|
|
3414
|
+
corsServerless,
|
|
3415
|
+
filePath,
|
|
3416
|
+
() => "Replace the wildcard origin with a concrete allowlist: `origin: https://yourdomain.com` (or an array). Combined with `allowCredentials: true`, wildcard CORS is rejected by the browser; combined with auth headers, it's a data-exfiltration surface."
|
|
3417
|
+
));
|
|
3418
|
+
}
|
|
2557
3419
|
return matches;
|
|
2558
3420
|
}
|
|
2559
3421
|
};
|
|
@@ -2633,7 +3495,7 @@ function calculateGrade(findings, _totalFiles) {
|
|
|
2633
3495
|
else if (medium >= 1) grade = capGrade("A");
|
|
2634
3496
|
let summary;
|
|
2635
3497
|
if (critical > 0) {
|
|
2636
|
-
summary = `${critical} critical ${critical === 1 ? "vulnerability" : "vulnerabilities"}
|
|
3498
|
+
summary = `${critical} critical ${critical === 1 ? "vulnerability requires" : "vulnerabilities require"} immediate attention.`;
|
|
2637
3499
|
} else if (high >= 3) {
|
|
2638
3500
|
summary = `${high} high-severity issues require urgent attention.`;
|
|
2639
3501
|
} else if (high > 0) {
|
|
@@ -2715,27 +3577,55 @@ var xxeVulnerability = {
|
|
|
2715
3577
|
category: "Injection",
|
|
2716
3578
|
description: "XML parsers that process external entities allow attackers to read files, perform SSRF, or cause DoS via billion-laughs attacks.",
|
|
2717
3579
|
check(content, filePath) {
|
|
2718
|
-
if (!/xml|parseXML|DOMParser|SAXParser|etree|lxml/i.test(content)) return [];
|
|
3580
|
+
if (!/xml|parseXml|parseXML|DOMParser|SAXParser|etree|lxml|libxml/i.test(content)) return [];
|
|
2719
3581
|
const matches = [];
|
|
2720
3582
|
const patterns = [
|
|
2721
|
-
/\.
|
|
3583
|
+
/\.parseXm?l\s*\(/gi,
|
|
3584
|
+
// catches parseXml (libxmljs) AND parseXML
|
|
2722
3585
|
/new\s+DOMParser\s*\(\)/g,
|
|
2723
3586
|
/etree\.parse\s*\(/g,
|
|
2724
3587
|
/lxml\.etree/g,
|
|
2725
3588
|
/SAXParserFactory/g,
|
|
2726
3589
|
/XMLReaderFactory/g
|
|
2727
3590
|
];
|
|
2728
|
-
const hasProtection = /noent
|
|
2729
|
-
if (hasProtection)
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
3591
|
+
const hasProtection = /noent\s*:\s*false|resolveExternals\s*:\s*false|FEATURE_EXTERNAL.*false|defusedxml|disallow-doctype-decl|nonet\s*:\s*true/i.test(content);
|
|
3592
|
+
if (!hasProtection) {
|
|
3593
|
+
for (const p of patterns) {
|
|
3594
|
+
matches.push(...findMatches(
|
|
3595
|
+
content,
|
|
3596
|
+
p,
|
|
3597
|
+
xxeVulnerability,
|
|
3598
|
+
filePath,
|
|
3599
|
+
() => "Disable external entities: set noent: false + dtdload: false + nonet: true (libxmljs), or use defusedxml (Python), or factory.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true) (Java)."
|
|
3600
|
+
));
|
|
3601
|
+
}
|
|
2738
3602
|
}
|
|
3603
|
+
if (!/parseXml\s*\(/.test(content)) return matches;
|
|
3604
|
+
const ctx = tryParse(content, filePath);
|
|
3605
|
+
if (!ctx) return matches;
|
|
3606
|
+
visitCalls(
|
|
3607
|
+
ctx.parsed,
|
|
3608
|
+
(callee) => isCalleeNamed(callee, "parseXml") || isCalleeNamed(callee, "parseXML"),
|
|
3609
|
+
(call, line) => {
|
|
3610
|
+
const opts = call.arguments[1];
|
|
3611
|
+
if (!opts || opts.type !== "ObjectExpression") return;
|
|
3612
|
+
const noent = getObjectProperty(opts, "noent");
|
|
3613
|
+
const dtdload = getObjectProperty(opts, "dtdload");
|
|
3614
|
+
const external = getObjectProperty(opts, "resolveExternals");
|
|
3615
|
+
const dangerous = noent?.value.type === "BooleanLiteral" && noent.value.value === true || dtdload?.value.type === "BooleanLiteral" && dtdload.value.value === true || external?.value.type === "BooleanLiteral" && external.value.value === true;
|
|
3616
|
+
if (!dangerous) return;
|
|
3617
|
+
if (matches.some((m) => m.line === line)) return;
|
|
3618
|
+
matches.push(
|
|
3619
|
+
astMatch(
|
|
3620
|
+
content,
|
|
3621
|
+
filePath,
|
|
3622
|
+
line,
|
|
3623
|
+
xxeVulnerability,
|
|
3624
|
+
"Set noent: false, dtdload: false, and nonet: true on the parser options to disable external-entity resolution."
|
|
3625
|
+
)
|
|
3626
|
+
);
|
|
3627
|
+
}
|
|
3628
|
+
);
|
|
2739
3629
|
return matches;
|
|
2740
3630
|
}
|
|
2741
3631
|
};
|
|
@@ -2764,6 +3654,46 @@ var ssti = {
|
|
|
2764
3654
|
() => "Never render templates from user input. Use pre-defined templates and pass data as context variables: render_template('template.html', data=user_data)."
|
|
2765
3655
|
));
|
|
2766
3656
|
}
|
|
3657
|
+
if (!/(?:\.compile|\.render|renderString|render_template_string)\s*\(/.test(content)) {
|
|
3658
|
+
return matches;
|
|
3659
|
+
}
|
|
3660
|
+
const ctx = tryParse(content, filePath);
|
|
3661
|
+
if (!ctx) return matches;
|
|
3662
|
+
const { parsed, taint } = ctx;
|
|
3663
|
+
const SSTI_METHODS = /* @__PURE__ */ new Set([
|
|
3664
|
+
"compile",
|
|
3665
|
+
// Handlebars.compile, pug.compile, _.template (returns a function)
|
|
3666
|
+
"render",
|
|
3667
|
+
// ejs.render, engine.render, mustache.render
|
|
3668
|
+
"renderString",
|
|
3669
|
+
// nunjucks.renderString
|
|
3670
|
+
"render_template_string"
|
|
3671
|
+
]);
|
|
3672
|
+
visitCalls(
|
|
3673
|
+
parsed,
|
|
3674
|
+
(callee) => {
|
|
3675
|
+
if (callee.type === "Identifier" && SSTI_METHODS.has(callee.name)) return true;
|
|
3676
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
3677
|
+
return SSTI_METHODS.has(callee.property.name);
|
|
3678
|
+
}
|
|
3679
|
+
return false;
|
|
3680
|
+
},
|
|
3681
|
+
(call, line) => {
|
|
3682
|
+
const first = call.arguments[0];
|
|
3683
|
+
if (!first || first.type === "SpreadElement") return;
|
|
3684
|
+
if (!taint.isTainted(first)) return;
|
|
3685
|
+
if (matches.some((m) => m.line === line)) return;
|
|
3686
|
+
matches.push(
|
|
3687
|
+
astMatch(
|
|
3688
|
+
content,
|
|
3689
|
+
filePath,
|
|
3690
|
+
line,
|
|
3691
|
+
ssti,
|
|
3692
|
+
"Compile templates from a trusted, static source (a file path or a constant string). Pass user data only as context values."
|
|
3693
|
+
)
|
|
3694
|
+
);
|
|
3695
|
+
}
|
|
3696
|
+
);
|
|
2767
3697
|
return matches;
|
|
2768
3698
|
}
|
|
2769
3699
|
};
|
|
@@ -2833,7 +3763,7 @@ var exposedAdminRoutes = {
|
|
|
2833
3763
|
category: "Information Leakage",
|
|
2834
3764
|
description: "Routes like /admin, /debug, /phpinfo, or /actuator without authentication expose sensitive controls and information to attackers.",
|
|
2835
3765
|
check(content, filePath) {
|
|
2836
|
-
if (
|
|
3766
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2837
3767
|
const matches = [];
|
|
2838
3768
|
const patterns = [
|
|
2839
3769
|
/[.'"]\s*(?:get|use|all)\s*\(\s*["'`]\/(?:admin|debug|_debug|__debug__|phpinfo|actuator|graphiql|playground|swagger|reset|seed|test|dev|mock)["'`]/gi
|
|
@@ -2900,9 +3830,10 @@ var sensitiveURLParams = {
|
|
|
2900
3830
|
if (isTestFile(filePath)) return [];
|
|
2901
3831
|
const matches = [];
|
|
2902
3832
|
const patterns = [
|
|
2903
|
-
//
|
|
2904
|
-
/[
|
|
2905
|
-
|
|
3833
|
+
// Template literals embedding sensitive values in URL query strings
|
|
3834
|
+
/[?&](?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|session(?:[_-]?id)?|token|jwt|ssn|credit[_-]?card)\s*=\s*\$\{/gi,
|
|
3835
|
+
// String concat building the same
|
|
3836
|
+
/[?&](?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|session(?:[_-]?id)?|token|jwt|ssn|credit[_-]?card)\s*=["']\s*\+/gi
|
|
2906
3837
|
];
|
|
2907
3838
|
for (const p of patterns) {
|
|
2908
3839
|
matches.push(...findMatches(
|
|
@@ -2924,7 +3855,7 @@ var missingContentDisposition = {
|
|
|
2924
3855
|
description: "File download endpoints without Content-Disposition headers may render files inline, leading to XSS if the file contains HTML/JS.",
|
|
2925
3856
|
check(content, filePath) {
|
|
2926
3857
|
if (!/(?:download|sendFile|send_file|pipe|createReadStream)/i.test(content)) return [];
|
|
2927
|
-
if (
|
|
3858
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2928
3859
|
if (/Content-Disposition|attachment|download/i.test(content)) return [];
|
|
2929
3860
|
return findMatches(
|
|
2930
3861
|
content,
|
|
@@ -2966,9 +3897,13 @@ var raceCondition = {
|
|
|
2966
3897
|
if (filePath.includes("test") || filePath.includes("mock")) return [];
|
|
2967
3898
|
const matches = [];
|
|
2968
3899
|
const patterns = [
|
|
2969
|
-
/
|
|
2970
|
-
/
|
|
2971
|
-
|
|
3900
|
+
// Check-then-act with writes / deletes / renames
|
|
3901
|
+
/(?:existsSync|exists)\s*\([^)]+\)[\s\S]{0,80}(?:writeFileSync|writeFile|unlinkSync|unlink|renameSync|rename)\s*\(/g,
|
|
3902
|
+
// Check-then-read — also vulnerable: an attacker can swap the symlink
|
|
3903
|
+
// between the exists/stat call and the read.
|
|
3904
|
+
/(?:existsSync|statSync)\s*\([^)]+\)[\s\S]{0,80}(?:readFileSync|readFile|createReadStream)\s*\(/g,
|
|
3905
|
+
/os\.path\.exists\s*\([^)]+\)[\s\S]{0,80}open\s*\(/g,
|
|
3906
|
+
/File\.exists\?\s*\([^)]+\)[\s\S]{0,80}File\.(?:write|delete|read)/g
|
|
2972
3907
|
];
|
|
2973
3908
|
for (const p of patterns) {
|
|
2974
3909
|
matches.push(...findMatches(
|
|
@@ -3017,7 +3952,7 @@ var unprotectedDownload = {
|
|
|
3017
3952
|
description: "File download endpoints that accept user-controlled filenames without path validation allow directory traversal to read arbitrary files.",
|
|
3018
3953
|
check(content, filePath) {
|
|
3019
3954
|
if (!/(?:download|sendFile|send_file)/i.test(content)) return [];
|
|
3020
|
-
if (
|
|
3955
|
+
if (!isServerSideFile(filePath)) return [];
|
|
3021
3956
|
const matches = [];
|
|
3022
3957
|
if (/(?:sendFile|download|send_file)\s*\([^)]*(?:req\.|params\.|query\.|body\.)/i.test(content)) {
|
|
3023
3958
|
const hasValidation = /path\.resolve|path\.normalize|path\.join.*__dirname|realpath|includes\s*\(\s*["']\.\./i.test(content);
|
|
@@ -5613,7 +6548,9 @@ function scanEntropy(files) {
|
|
|
5613
6548
|
allRules,
|
|
5614
6549
|
androidDebuggable,
|
|
5615
6550
|
blockingMainThread,
|
|
6551
|
+
buildTaintMap,
|
|
5616
6552
|
calculateGrade,
|
|
6553
|
+
callSpreads,
|
|
5617
6554
|
callbackHell,
|
|
5618
6555
|
clickjacking,
|
|
5619
6556
|
clientComponentSecret,
|
|
@@ -5654,6 +6591,7 @@ function scanEntropy(files) {
|
|
|
5654
6591
|
firebaseClientConfig,
|
|
5655
6592
|
flaskSecretKey,
|
|
5656
6593
|
freeRules,
|
|
6594
|
+
getObjectProperty,
|
|
5657
6595
|
getSnippet,
|
|
5658
6596
|
githubActionsInjection,
|
|
5659
6597
|
graphqlIntrospection,
|
|
@@ -5690,6 +6628,8 @@ function scanEntropy(files) {
|
|
|
5690
6628
|
insecureRandomness,
|
|
5691
6629
|
insecureWebSocket,
|
|
5692
6630
|
ipcPathTraversal,
|
|
6631
|
+
isCalleeNamed,
|
|
6632
|
+
isMethodCall,
|
|
5693
6633
|
javaDeserialization,
|
|
5694
6634
|
jwtAlgConfusion,
|
|
5695
6635
|
k8sNoResourceLimits,
|
|
@@ -5727,6 +6667,7 @@ function scanEntropy(files) {
|
|
|
5727
6667
|
nosqlInjection,
|
|
5728
6668
|
openRedirectParams,
|
|
5729
6669
|
overlyPermissiveIAM,
|
|
6670
|
+
parseFile,
|
|
5730
6671
|
pathTraversal,
|
|
5731
6672
|
pickleDeserialization,
|
|
5732
6673
|
piiInLogs,
|
|
@@ -5770,6 +6711,8 @@ function scanEntropy(files) {
|
|
|
5770
6711
|
unvalidatedAPIParams,
|
|
5771
6712
|
unvalidatedEventData,
|
|
5772
6713
|
unvalidatedRedirect,
|
|
6714
|
+
visitBinary,
|
|
6715
|
+
visitCalls,
|
|
5773
6716
|
vulnerableDependencies,
|
|
5774
6717
|
weakHashing,
|
|
5775
6718
|
weakPasswordRequirements,
|