xploitscan-shared-rules 1.2.1 → 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 +1077 -134
- 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 +1069 -134
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
package/dist/index.js
CHANGED
|
@@ -10,11 +10,381 @@ function getSnippet(content, line, contextLines = 2) {
|
|
|
10
10
|
}).join("\n");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// src/ast/parse.ts
|
|
14
|
+
import { parse } from "@babel/parser";
|
|
15
|
+
var MAX_CACHE = 256;
|
|
16
|
+
var cache = /* @__PURE__ */ new Map();
|
|
17
|
+
function cacheKey(filename, contentHash) {
|
|
18
|
+
return `${filename}:${contentHash}`;
|
|
19
|
+
}
|
|
20
|
+
function quickHash(s) {
|
|
21
|
+
let h = 5381;
|
|
22
|
+
const step = Math.max(1, Math.floor(s.length / 32));
|
|
23
|
+
for (let i = 0; i < s.length; i += step) {
|
|
24
|
+
h = (h << 5) + h + s.charCodeAt(i) | 0;
|
|
25
|
+
}
|
|
26
|
+
return h * 31 + s.length | 0;
|
|
27
|
+
}
|
|
28
|
+
function pickLanguage(filename) {
|
|
29
|
+
if (/\.tsx$/i.test(filename)) return "tsx";
|
|
30
|
+
if (/\.jsx$/i.test(filename)) return "jsx";
|
|
31
|
+
if (/\.(ts|cts|mts)$/i.test(filename)) return "ts";
|
|
32
|
+
if (/\.(js|cjs|mjs)$/i.test(filename)) return "js";
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function parseFile(content, filename) {
|
|
36
|
+
const lang = pickLanguage(filename);
|
|
37
|
+
if (!lang) return null;
|
|
38
|
+
if (!content || content.length === 0) return null;
|
|
39
|
+
const key = cacheKey(filename, quickHash(content));
|
|
40
|
+
if (cache.has(key)) return cache.get(key) ?? null;
|
|
41
|
+
const plugins = [];
|
|
42
|
+
if (lang === "ts" || lang === "tsx") plugins.push("typescript");
|
|
43
|
+
if (lang === "jsx" || lang === "tsx") plugins.push("jsx");
|
|
44
|
+
plugins.push("decorators-legacy", "classProperties", "dynamicImport", "topLevelAwait");
|
|
45
|
+
let ast = null;
|
|
46
|
+
try {
|
|
47
|
+
ast = parse(content, {
|
|
48
|
+
sourceType: "unambiguous",
|
|
49
|
+
allowImportExportEverywhere: true,
|
|
50
|
+
allowReturnOutsideFunction: true,
|
|
51
|
+
allowAwaitOutsideFunction: true,
|
|
52
|
+
allowUndeclaredExports: true,
|
|
53
|
+
errorRecovery: true,
|
|
54
|
+
plugins
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
cache.set(key, null);
|
|
58
|
+
if (cache.size > MAX_CACHE) {
|
|
59
|
+
const firstKey = cache.keys().next().value;
|
|
60
|
+
if (firstKey !== void 0) cache.delete(firstKey);
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const entry = { ast, language: lang };
|
|
65
|
+
cache.set(key, entry);
|
|
66
|
+
if (cache.size > MAX_CACHE) {
|
|
67
|
+
const firstKey = cache.keys().next().value;
|
|
68
|
+
if (firstKey !== void 0) cache.delete(firstKey);
|
|
69
|
+
}
|
|
70
|
+
return entry;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/ast/taint.ts
|
|
74
|
+
import _traverse from "@babel/traverse";
|
|
75
|
+
var traverse = typeof _traverse === "function" ? _traverse : _traverse.default;
|
|
76
|
+
var TAINTED_PROP_SUFFIXES = /* @__PURE__ */ new Set([
|
|
77
|
+
"body",
|
|
78
|
+
"query",
|
|
79
|
+
"params",
|
|
80
|
+
"headers",
|
|
81
|
+
"cookies",
|
|
82
|
+
"queryStringParameters",
|
|
83
|
+
"pathParameters",
|
|
84
|
+
"rawBody",
|
|
85
|
+
"searchParams"
|
|
86
|
+
]);
|
|
87
|
+
var TAINTED_REQUEST_OBJECTS = /* @__PURE__ */ new Set([
|
|
88
|
+
"req",
|
|
89
|
+
"request",
|
|
90
|
+
"ctx",
|
|
91
|
+
// Koa
|
|
92
|
+
"context",
|
|
93
|
+
// Koa alt
|
|
94
|
+
"event"
|
|
95
|
+
// Lambda
|
|
96
|
+
]);
|
|
97
|
+
function buildTaintMap(parsed) {
|
|
98
|
+
const tainted = /* @__PURE__ */ new Set();
|
|
99
|
+
function reachesRequestIdent(node) {
|
|
100
|
+
if (!node) return false;
|
|
101
|
+
if (node.type === "Identifier") return TAINTED_REQUEST_OBJECTS.has(node.name);
|
|
102
|
+
if (node.type === "MemberExpression") {
|
|
103
|
+
if (node.property.type === "Identifier" && node.property.name === "request" && node.object.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(node.object.name)) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return reachesRequestIdent(node.object);
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
function nodeIsTaintedSource(node) {
|
|
111
|
+
if (!node) return false;
|
|
112
|
+
if (node.type === "MemberExpression") {
|
|
113
|
+
const prop = node.property;
|
|
114
|
+
const propName = prop.type === "Identifier" ? prop.name : prop.type === "StringLiteral" ? prop.value : "";
|
|
115
|
+
if (TAINTED_PROP_SUFFIXES.has(propName)) {
|
|
116
|
+
const obj = node.object;
|
|
117
|
+
if (obj.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(obj.name)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (reachesRequestIdent(obj)) return true;
|
|
121
|
+
if (nodeIsTaintedSource(obj)) return true;
|
|
122
|
+
}
|
|
123
|
+
if (node.object.type === "Identifier" && node.object.name === "process" && prop.type === "Identifier" && prop.name === "argv") {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (nodeIsTaintedSource(node.object)) return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (node.type === "CallExpression") {
|
|
130
|
+
const callee = node.callee;
|
|
131
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && callee.property.name === "get" && nodeIsTaintedSource(callee.object)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const BODY_READERS = /* @__PURE__ */ new Set(["json", "formData", "text", "arrayBuffer", "blob"]);
|
|
135
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && BODY_READERS.has(callee.property.name)) {
|
|
136
|
+
const obj = callee.object;
|
|
137
|
+
if (obj.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(obj.name)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (reachesRequestIdent(obj)) return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (node.type === "AwaitExpression") {
|
|
144
|
+
return nodeIsTaintedSource(node.argument);
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
function exprIsTainted(node) {
|
|
149
|
+
if (!node) return false;
|
|
150
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
151
|
+
if (node.type === "Identifier") return tainted.has(node.name);
|
|
152
|
+
if (node.type === "TemplateLiteral") {
|
|
153
|
+
return node.expressions.some((e) => exprIsTainted(e));
|
|
154
|
+
}
|
|
155
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
156
|
+
return exprIsTainted(node.left) || exprIsTainted(node.right);
|
|
157
|
+
}
|
|
158
|
+
if (node.type === "LogicalExpression" && (node.operator === "||" || node.operator === "??")) {
|
|
159
|
+
return exprIsTainted(node.left) || exprIsTainted(node.right);
|
|
160
|
+
}
|
|
161
|
+
if (node.type === "ConditionalExpression") {
|
|
162
|
+
return exprIsTainted(node.consequent) || exprIsTainted(node.alternate);
|
|
163
|
+
}
|
|
164
|
+
if (node.type === "MemberExpression") {
|
|
165
|
+
return exprIsTainted(node.object);
|
|
166
|
+
}
|
|
167
|
+
if (node.type === "CallExpression") {
|
|
168
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
169
|
+
if (node.callee.type === "MemberExpression") {
|
|
170
|
+
if (exprIsTainted(node.callee.object)) return true;
|
|
171
|
+
const obj = node.callee.object;
|
|
172
|
+
const prop = node.callee.property;
|
|
173
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "path" && ["join", "resolve", "normalize", "format", "parse", "relative"].includes(prop.name)) {
|
|
174
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
175
|
+
}
|
|
176
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "Buffer" && ["from", "concat", "alloc", "allocUnsafe"].includes(prop.name)) {
|
|
177
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (node.callee.type === "Identifier") {
|
|
181
|
+
if (["String", "Number", "Boolean", "URL", "URLSearchParams"].includes(node.callee.name)) {
|
|
182
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (node.type === "AwaitExpression") {
|
|
187
|
+
return exprIsTainted(node.argument);
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
traverse(parsed.ast, {
|
|
192
|
+
VariableDeclarator(path) {
|
|
193
|
+
const node = path.node;
|
|
194
|
+
if (node.type !== "VariableDeclarator") return;
|
|
195
|
+
const init = node.init;
|
|
196
|
+
if (!init) return;
|
|
197
|
+
if (node.id.type === "Identifier") {
|
|
198
|
+
if (exprIsTainted(init)) {
|
|
199
|
+
tainted.add(node.id.name);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (node.id.type === "ObjectPattern") {
|
|
203
|
+
const isTainted2 = nodeIsTaintedSource(init) || init.type === "Identifier" && tainted.has(init.name);
|
|
204
|
+
if (isTainted2) {
|
|
205
|
+
for (const prop of node.id.properties) {
|
|
206
|
+
if (prop.type === "ObjectProperty") {
|
|
207
|
+
if (prop.value.type === "Identifier") tainted.add(prop.value.name);
|
|
208
|
+
} else if (prop.type === "RestElement") {
|
|
209
|
+
if (prop.argument.type === "Identifier") tainted.add(prop.argument.name);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
AssignmentExpression(path) {
|
|
216
|
+
const node = path.node;
|
|
217
|
+
if (node.type !== "AssignmentExpression") return;
|
|
218
|
+
if (node.operator !== "=" && node.operator !== "||=" && node.operator !== "??=") return;
|
|
219
|
+
if (node.left.type !== "Identifier") return;
|
|
220
|
+
if (exprIsTainted(node.right)) {
|
|
221
|
+
tainted.add(node.left.name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
const isTainted = (node) => {
|
|
226
|
+
if (!node) return false;
|
|
227
|
+
if (node.type === "Identifier") return tainted.has(node.name);
|
|
228
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
229
|
+
if (node.type === "TemplateLiteral") {
|
|
230
|
+
return node.expressions.some((e) => isTainted(e));
|
|
231
|
+
}
|
|
232
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
233
|
+
return isTainted(node.left) || isTainted(node.right);
|
|
234
|
+
}
|
|
235
|
+
if (node.type === "LogicalExpression" && (node.operator === "||" || node.operator === "??" || node.operator === "&&")) {
|
|
236
|
+
return isTainted(node.left) || isTainted(node.right);
|
|
237
|
+
}
|
|
238
|
+
if (node.type === "ConditionalExpression") {
|
|
239
|
+
return isTainted(node.consequent) || isTainted(node.alternate);
|
|
240
|
+
}
|
|
241
|
+
if (node.type === "AwaitExpression") {
|
|
242
|
+
return isTainted(node.argument);
|
|
243
|
+
}
|
|
244
|
+
if (node.type === "MemberExpression") {
|
|
245
|
+
return isTainted(node.object);
|
|
246
|
+
}
|
|
247
|
+
if (node.type === "CallExpression") {
|
|
248
|
+
if (node.callee.type === "MemberExpression") {
|
|
249
|
+
if (isTainted(node.callee.object)) return true;
|
|
250
|
+
const obj = node.callee.object;
|
|
251
|
+
const prop = node.callee.property;
|
|
252
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "path" && ["join", "resolve", "normalize", "format", "relative", "parse"].includes(prop.name)) {
|
|
253
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
254
|
+
}
|
|
255
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "Buffer" && ["from", "concat", "alloc", "allocUnsafe"].includes(prop.name)) {
|
|
256
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (node.callee.type === "Identifier") {
|
|
260
|
+
if (["String", "Number", "Boolean", "URL", "URLSearchParams"].includes(node.callee.name)) {
|
|
261
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
isTainted,
|
|
269
|
+
isTaintedIdent: (name) => tainted.has(name),
|
|
270
|
+
taintedNames: () => Array.from(tainted)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/ast/traverse.ts
|
|
275
|
+
import _traverse2 from "@babel/traverse";
|
|
276
|
+
var traverse2 = typeof _traverse2 === "function" ? _traverse2 : _traverse2.default;
|
|
277
|
+
function visitBinary(parsed, visit) {
|
|
278
|
+
traverse2(parsed.ast, {
|
|
279
|
+
BinaryExpression(path) {
|
|
280
|
+
const line = path.node.loc?.start.line ?? 1;
|
|
281
|
+
visit(path.node, line);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function visitCalls(parsed, matchCallee, visit) {
|
|
286
|
+
traverse2(parsed.ast, {
|
|
287
|
+
CallExpression(path) {
|
|
288
|
+
const node = path.node;
|
|
289
|
+
if (!matchCallee(node.callee)) return;
|
|
290
|
+
const line = node.loc?.start.line ?? 1;
|
|
291
|
+
visit(node, line);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function isCalleeNamed(callee, name) {
|
|
296
|
+
if (callee.type === "Identifier") return callee.name === name;
|
|
297
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
298
|
+
return callee.property.name === name;
|
|
299
|
+
}
|
|
300
|
+
if (callee.type === "OptionalMemberExpression" && callee.property.type === "Identifier") {
|
|
301
|
+
return callee.property.name === name;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
function isMethodCall(callee, objName, methodName) {
|
|
306
|
+
if (callee.type !== "MemberExpression" && callee.type !== "OptionalMemberExpression") {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (callee.property.type !== "Identifier") return false;
|
|
310
|
+
if (callee.property.name !== methodName) return false;
|
|
311
|
+
if (callee.object.type !== "Identifier") return false;
|
|
312
|
+
return callee.object.name === objName;
|
|
313
|
+
}
|
|
314
|
+
function getObjectProperty(node, key) {
|
|
315
|
+
for (const prop of node.properties) {
|
|
316
|
+
if (prop.type === "ObjectProperty") {
|
|
317
|
+
if (prop.key.type === "Identifier" && prop.key.name === key) {
|
|
318
|
+
return { value: prop.value };
|
|
319
|
+
}
|
|
320
|
+
if (prop.key.type === "StringLiteral" && prop.key.value === key) {
|
|
321
|
+
return { value: prop.value };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
function callSpreads(call, matcher) {
|
|
328
|
+
for (const arg of call.arguments) {
|
|
329
|
+
if (arg.type === "SpreadElement") {
|
|
330
|
+
if (matcher(arg.argument)) return true;
|
|
331
|
+
}
|
|
332
|
+
if (arg.type === "ObjectExpression") {
|
|
333
|
+
for (const prop of arg.properties) {
|
|
334
|
+
if (prop.type === "SpreadElement") {
|
|
335
|
+
if (matcher(prop.argument)) return true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
13
343
|
// src/rules.ts
|
|
14
344
|
var TEST_FILE_PATTERN = /(?:\.test\.|\.spec\.|__tests__|__mocks__|\.stories\.|\.story\.|\/test\/|\/tests\/|\/fixtures?\/|\/mocks?\/|\.mock\.|test-utils|testing|\.cy\.|\.e2e\.)/i;
|
|
15
345
|
function isTestFile(filePath) {
|
|
16
346
|
return TEST_FILE_PATTERN.test(filePath);
|
|
17
347
|
}
|
|
348
|
+
var SERVER_SIDE_PATH_RE = new RegExp(
|
|
349
|
+
[
|
|
350
|
+
// Directory-style anchors — match with OR without leading slash so
|
|
351
|
+
// relative paths (`routes/users.js`) work as well as absolute.
|
|
352
|
+
"(?:^|/)api/",
|
|
353
|
+
"(?:^|/)routes?/",
|
|
354
|
+
"(?:^|/)controllers?/",
|
|
355
|
+
"(?:^|/)endpoints?/",
|
|
356
|
+
"(?:^|/)handlers?/",
|
|
357
|
+
"(?:^|/)middleware/",
|
|
358
|
+
"(?:^|/)webhooks?/",
|
|
359
|
+
"(?:^|/)services?/",
|
|
360
|
+
"(?:^|/)lambda/",
|
|
361
|
+
"(?:^|/)functions?/",
|
|
362
|
+
"(?:^|/)pages/api/",
|
|
363
|
+
"(?:^|/)app/.*/route\\.(?:m?[jt]sx?|cjs)$",
|
|
364
|
+
// Bare-filename anchors (with or without leading directory).
|
|
365
|
+
// Allows compound names like webhook-handler.js, cron-runner.js,
|
|
366
|
+
// user-service.ts via the optional (?:[-_]\w+)* suffix group.
|
|
367
|
+
"(?:^|/)(?: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)$",
|
|
368
|
+
// Common functional names that imply request handling.
|
|
369
|
+
"(?:^|/)(?: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)$"
|
|
370
|
+
].join("|"),
|
|
371
|
+
"i"
|
|
372
|
+
);
|
|
373
|
+
function isServerSideFile(filePath) {
|
|
374
|
+
if (isTestFile(filePath)) return false;
|
|
375
|
+
return SERVER_SIDE_PATH_RE.test(filePath);
|
|
376
|
+
}
|
|
377
|
+
var CONFIG_FILE_PATTERN = new RegExp(
|
|
378
|
+
[
|
|
379
|
+
"\\.config\\.(?:m?[jt]sx?|cjs)$",
|
|
380
|
+
"(?:^|/)config\\.(?:m?[jt]sx?|cjs|py|rb|json|ya?ml)$",
|
|
381
|
+
"(?:^|/)settings\\.(?:m?[jt]sx?|cjs|py)$",
|
|
382
|
+
"(?:^|/)\\.env(?:\\.|$)",
|
|
383
|
+
"(?:^|/)(?:knex|drizzle|next|vite|rollup|webpack|tailwind|postcss|jest|vitest|tsup|babel)\\.config\\.(?:m?[jt]sx?|cjs)$",
|
|
384
|
+
"(?:^|/)(?:db|database|connection|pool)\\.(?:config\\.)?(?:m?[jt]sx?|cjs|py|rb)$"
|
|
385
|
+
].join("|"),
|
|
386
|
+
"i"
|
|
387
|
+
);
|
|
18
388
|
function isCommentLine(content, matchIndex) {
|
|
19
389
|
const lineStart = content.lastIndexOf("\n", matchIndex - 1) + 1;
|
|
20
390
|
const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex)).trimStart();
|
|
@@ -23,7 +393,7 @@ function isCommentLine(content, matchIndex) {
|
|
|
23
393
|
function isInsideFixMessage(content, matchIndex) {
|
|
24
394
|
const lineStart = content.lastIndexOf("\n", matchIndex - 1) + 1;
|
|
25
395
|
const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex));
|
|
26
|
-
return /(?:fix|description|message|suggestion|hint|help|example|doc|comment)\s*[:=(]/i.test(lineText) || /return\s*["'`]
|
|
396
|
+
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);
|
|
27
397
|
}
|
|
28
398
|
function findMatches(content, pattern, rule, filePath, fixTemplate) {
|
|
29
399
|
const matches = [];
|
|
@@ -47,6 +417,23 @@ function findMatches(content, pattern, rule, filePath, fixTemplate) {
|
|
|
47
417
|
}
|
|
48
418
|
return matches;
|
|
49
419
|
}
|
|
420
|
+
function astMatch(content, filePath, line, rule, fix) {
|
|
421
|
+
return {
|
|
422
|
+
rule: rule.id,
|
|
423
|
+
title: rule.title,
|
|
424
|
+
severity: rule.severity,
|
|
425
|
+
category: rule.category,
|
|
426
|
+
file: filePath,
|
|
427
|
+
line,
|
|
428
|
+
snippet: getSnippet(content, line),
|
|
429
|
+
fix
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function tryParse(content, filePath) {
|
|
433
|
+
const parsed = parseFile(content, filePath);
|
|
434
|
+
if (!parsed) return null;
|
|
435
|
+
return { parsed, taint: buildTaintMap(parsed) };
|
|
436
|
+
}
|
|
50
437
|
var hardcodedSecrets = {
|
|
51
438
|
id: "VC001",
|
|
52
439
|
title: "Hardcoded API Key or Secret",
|
|
@@ -70,6 +457,17 @@ var hardcodedSecrets = {
|
|
|
70
457
|
/sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/g,
|
|
71
458
|
// Generic tokens in assignments — require standalone word and longer min length
|
|
72
459
|
/(?:^|[\s,({])(?:token|secret|password|passwd|pwd)\s*[:=]\s*["'`]([a-zA-Z0-9_\-!@#$%^&*]{20,})["'`]/gim,
|
|
460
|
+
// SCREAMING_CASE identifiers ending in SECRET/TOKEN/KEY/PASSWORD
|
|
461
|
+
// (OAUTH_CLIENT_SECRET, JWT_PRIVATE_KEY, STRIPE_WEBHOOK_SECRET, etc.)
|
|
462
|
+
// that are assigned a string literal, not an env var or function call.
|
|
463
|
+
/[A-Z][A-Z0-9_]*_(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD)\s*[:=]\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/]{12,})["'`]/g,
|
|
464
|
+
// camelCase / snake_case object properties that look like credentials:
|
|
465
|
+
// apiKey: "...", webhookUrl: "https://...", accountSid: "...", authToken: "..."
|
|
466
|
+
// password: "..." caught here separately with a lower threshold (many
|
|
467
|
+
// real passwords are 8-16 chars, below the 12-char floor of the
|
|
468
|
+
// generic `secret = "..."` pattern).
|
|
469
|
+
/\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,
|
|
470
|
+
/\b(?:password|passwd|pwd)\s*:\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/:\-]{8,})["'`]/gi,
|
|
73
471
|
// Private keys
|
|
74
472
|
/-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g,
|
|
75
473
|
// Database URLs with credentials
|
|
@@ -82,19 +480,49 @@ var hardcodedSecrets = {
|
|
|
82
480
|
"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.",
|
|
83
481
|
"OpenAI API key detected \u2014 grants full API access and can incur charges. Rotate at platform.openai.com \u2192 API Keys.",
|
|
84
482
|
"Hardcoded token or password detected. Move to an environment variable and rotate the credential if it has been committed to version control.",
|
|
483
|
+
"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.",
|
|
484
|
+
"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.",
|
|
485
|
+
"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.",
|
|
85
486
|
"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.",
|
|
86
487
|
"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."
|
|
87
488
|
];
|
|
489
|
+
const minLen = [
|
|
490
|
+
12,
|
|
491
|
+
// [0] generic API key
|
|
492
|
+
16,
|
|
493
|
+
// [1] AWS — already rigid in the regex, length floor is defensive
|
|
494
|
+
20,
|
|
495
|
+
// [2] Stripe
|
|
496
|
+
50,
|
|
497
|
+
// [3] Supabase JWT
|
|
498
|
+
40,
|
|
499
|
+
// [4] OpenAI
|
|
500
|
+
20,
|
|
501
|
+
// [5] generic tokens with {20,} in the regex
|
|
502
|
+
12,
|
|
503
|
+
// [6] SCREAMING_CASE named constants
|
|
504
|
+
12,
|
|
505
|
+
// [7] camelCase config props (apiKey/webhookUrl/etc.)
|
|
506
|
+
8,
|
|
507
|
+
// [8] password: "..." — lower floor per the pattern comment above
|
|
508
|
+
0,
|
|
509
|
+
// [9] private keys — no length check
|
|
510
|
+
0
|
|
511
|
+
// [10] DB URLs — no length check
|
|
512
|
+
];
|
|
88
513
|
const matches = [];
|
|
89
514
|
for (let pi = 0; pi < patterns.length; pi++) {
|
|
90
515
|
const pattern = patterns[pi];
|
|
91
516
|
const rawMatches = findMatches(content, pattern, hardcodedSecrets, filePath, () => fixMessages[pi]);
|
|
517
|
+
const floor = minLen[pi] ?? 12;
|
|
92
518
|
for (const rm of rawMatches) {
|
|
93
519
|
const lineText = content.split("\n")[rm.line - 1] || "";
|
|
94
520
|
const trimmed = lineText.trimStart();
|
|
95
521
|
if (trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
|
|
96
|
-
|
|
97
|
-
|
|
522
|
+
if (floor > 0) {
|
|
523
|
+
const secretMatch = lineText.match(/[:=]\s*["'`]([^"'`]*)["'`]/);
|
|
524
|
+
if (secretMatch && secretMatch[1].length < floor) continue;
|
|
525
|
+
}
|
|
98
526
|
matches.push(rm);
|
|
99
527
|
}
|
|
100
528
|
}
|
|
@@ -130,8 +558,7 @@ var missingAuthMiddleware = {
|
|
|
130
558
|
category: "Authentication",
|
|
131
559
|
description: "API routes without authentication checks allow unauthorized access.",
|
|
132
560
|
check(content, filePath) {
|
|
133
|
-
|
|
134
|
-
if (!isApiRoute) return [];
|
|
561
|
+
if (!isServerSideFile(filePath)) return [];
|
|
135
562
|
const routePatterns = [
|
|
136
563
|
// Express/Hono style
|
|
137
564
|
/\.(get|post|put|patch|delete)\s*\(\s*["'`][^"'`]+["'`]\s*,\s*(?:async\s+)?\(?(?:req|c|ctx)/gi,
|
|
@@ -144,35 +571,36 @@ var missingAuthMiddleware = {
|
|
|
144
571
|
if (isAuthRoute) return [];
|
|
145
572
|
const isWebhookRoute = /\/webhook/i.test(filePath);
|
|
146
573
|
if (isWebhookRoute) return [];
|
|
574
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
147
575
|
const authPatterns = [
|
|
148
|
-
/
|
|
149
|
-
/
|
|
150
|
-
/
|
|
151
|
-
/
|
|
152
|
-
/
|
|
153
|
-
/
|
|
154
|
-
/
|
|
155
|
-
/
|
|
156
|
-
/
|
|
157
|
-
/
|
|
158
|
-
/
|
|
159
|
-
/
|
|
576
|
+
/\bgetUser\b/i,
|
|
577
|
+
/\bcurrentUser\b/i,
|
|
578
|
+
/\bisAuthenticated\b/i,
|
|
579
|
+
/\brequireAuth\b/i,
|
|
580
|
+
/\brequireUser\b/i,
|
|
581
|
+
/\brequireUserForApi\b/i,
|
|
582
|
+
/\bwithAuth\b/i,
|
|
583
|
+
/\bgetServerSession\b/i,
|
|
584
|
+
/\bgetToken\b/i,
|
|
585
|
+
/\bverifyToken\b/i,
|
|
586
|
+
/\bvalidateToken\b/i,
|
|
587
|
+
/\bcheckApiKey\b/i,
|
|
588
|
+
/\bverifyCronSecret\b/i,
|
|
589
|
+
/\bverifySecret\b/i,
|
|
160
590
|
/supabase\.auth/i,
|
|
161
|
-
/getServerSession/i,
|
|
162
|
-
/getToken/i,
|
|
163
|
-
/protect/i,
|
|
164
|
-
/guard/i,
|
|
165
|
-
/verifyToken/i,
|
|
166
|
-
/validateToken/i,
|
|
167
|
-
/withAuth/i,
|
|
168
|
-
/passport/i,
|
|
169
591
|
/firebase\.auth/i,
|
|
170
|
-
/
|
|
171
|
-
/
|
|
172
|
-
/
|
|
173
|
-
/
|
|
592
|
+
/\bclerk\b/i,
|
|
593
|
+
/\bpassport\b/i,
|
|
594
|
+
/\bcognito\b/i,
|
|
595
|
+
/\bauth\(\)/i,
|
|
596
|
+
/req\.user\b/i,
|
|
597
|
+
/req\.session\.\w+/i,
|
|
598
|
+
/\bjwt\.verify\b/i,
|
|
599
|
+
/\bbearer\s/i,
|
|
600
|
+
/\b(?:protect|guard)\(/i,
|
|
601
|
+
/\.use\([^)]*(?:auth|session|protect|requireAuth)/i
|
|
174
602
|
];
|
|
175
|
-
const hasAuth = authPatterns.some((p) => p.test(
|
|
603
|
+
const hasAuth = authPatterns.some((p) => p.test(codeOnly));
|
|
176
604
|
if (hasAuth) return [];
|
|
177
605
|
const matches = [];
|
|
178
606
|
for (const pattern of routePatterns) {
|
|
@@ -347,6 +775,37 @@ var xssVulnerability = {
|
|
|
347
775
|
matches.push(m);
|
|
348
776
|
}
|
|
349
777
|
}
|
|
778
|
+
if (!/\.(?:send|end|write)\s*\(/.test(content) || !/\$\{/.test(content)) {
|
|
779
|
+
return matches;
|
|
780
|
+
}
|
|
781
|
+
const ctx = tryParse(content, filePath);
|
|
782
|
+
if (!ctx) return matches;
|
|
783
|
+
const { parsed, taint } = ctx;
|
|
784
|
+
visitCalls(
|
|
785
|
+
parsed,
|
|
786
|
+
(callee) => {
|
|
787
|
+
if (callee.type !== "MemberExpression") return false;
|
|
788
|
+
if (callee.property.type !== "Identifier") return false;
|
|
789
|
+
return ["send", "end", "write"].includes(callee.property.name);
|
|
790
|
+
},
|
|
791
|
+
(call, line) => {
|
|
792
|
+
const first = call.arguments[0];
|
|
793
|
+
if (!first || first.type !== "TemplateLiteral") return;
|
|
794
|
+
const literalParts = first.quasis.map((q) => q.value.raw).join("");
|
|
795
|
+
if (!/<\/?\w+/.test(literalParts)) return;
|
|
796
|
+
if (!taint.isTainted(first)) return;
|
|
797
|
+
if (matches.some((m) => m.line === line)) return;
|
|
798
|
+
matches.push(
|
|
799
|
+
astMatch(
|
|
800
|
+
content,
|
|
801
|
+
filePath,
|
|
802
|
+
line,
|
|
803
|
+
xssVulnerability,
|
|
804
|
+
"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."
|
|
805
|
+
)
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
);
|
|
350
809
|
return matches;
|
|
351
810
|
}
|
|
352
811
|
};
|
|
@@ -800,7 +1259,7 @@ var prototypePollution = {
|
|
|
800
1259
|
title: "Prototype Pollution Risk",
|
|
801
1260
|
severity: "high",
|
|
802
1261
|
category: "Injection",
|
|
803
|
-
description: "
|
|
1262
|
+
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.",
|
|
804
1263
|
check(content, filePath) {
|
|
805
1264
|
const matches = [];
|
|
806
1265
|
const storageParsePatterns = [
|
|
@@ -808,26 +1267,70 @@ var prototypePollution = {
|
|
|
808
1267
|
/JSON\.parse\s*\(\s*window\.localStorage/g
|
|
809
1268
|
];
|
|
810
1269
|
const hasValidation = /schema|validate|sanitize|whitelist|allowedKeys|pick\(|Object\.freeze|zod|yup|joi|ajv/i.test(content);
|
|
811
|
-
if (hasValidation)
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1270
|
+
if (!hasValidation) {
|
|
1271
|
+
const hasUnsafeMerge = /Object\.assign\s*\([^)]*JSON\.parse|\.\.\.JSON\.parse|\{.*\.\.\.(?:stored|saved|cached|parsed|data)/i.test(content);
|
|
1272
|
+
if (hasUnsafeMerge) {
|
|
1273
|
+
matches.push(...findMatches(
|
|
1274
|
+
content,
|
|
1275
|
+
/Object\.assign\s*\([^)]*JSON\.parse|\.\.\.JSON\.parse/g,
|
|
1276
|
+
prototypePollution,
|
|
1277
|
+
filePath,
|
|
1278
|
+
() => "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."
|
|
1279
|
+
));
|
|
1280
|
+
}
|
|
1281
|
+
for (const p of storageParsePatterns) {
|
|
1282
|
+
matches.push(...findMatches(
|
|
1283
|
+
content,
|
|
1284
|
+
p,
|
|
1285
|
+
prototypePollution,
|
|
1286
|
+
filePath,
|
|
1287
|
+
() => "Validate localStorage data against an expected schema before using it. Malicious extensions or XSS can modify localStorage values."
|
|
1288
|
+
));
|
|
1289
|
+
}
|
|
830
1290
|
}
|
|
1291
|
+
if (!/\b(?:merge|assign|extend|defaults)\s*\(/.test(content)) return matches;
|
|
1292
|
+
const ctx = tryParse(content, filePath);
|
|
1293
|
+
if (!ctx) return matches;
|
|
1294
|
+
const { parsed, taint } = ctx;
|
|
1295
|
+
const ALL_ARG_SINKS = [
|
|
1296
|
+
{ obj: "_", method: "merge" },
|
|
1297
|
+
{ obj: "lodash", method: "merge" },
|
|
1298
|
+
{ method: "merge" },
|
|
1299
|
+
// bare `merge(target, src)` from ESM import
|
|
1300
|
+
{ obj: "_", method: "defaultsDeep" },
|
|
1301
|
+
{ obj: "lodash", method: "defaultsDeep" },
|
|
1302
|
+
{ obj: "Object", method: "assign" },
|
|
1303
|
+
{ method: "extend" },
|
|
1304
|
+
// jquery/underscore style
|
|
1305
|
+
{ obj: "$", method: "extend" }
|
|
1306
|
+
];
|
|
1307
|
+
visitCalls(
|
|
1308
|
+
parsed,
|
|
1309
|
+
(callee) => {
|
|
1310
|
+
for (const sink of ALL_ARG_SINKS) {
|
|
1311
|
+
if (sink.obj && isMethodCall(callee, sink.obj, sink.method)) return true;
|
|
1312
|
+
if (!sink.obj && isCalleeNamed(callee, sink.method)) return true;
|
|
1313
|
+
}
|
|
1314
|
+
return false;
|
|
1315
|
+
},
|
|
1316
|
+
(call, line) => {
|
|
1317
|
+
const sources = call.arguments.slice(1);
|
|
1318
|
+
const tainted = sources.some((arg) => {
|
|
1319
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
1320
|
+
return taint.isTainted(arg);
|
|
1321
|
+
});
|
|
1322
|
+
if (!tainted) return;
|
|
1323
|
+
matches.push(
|
|
1324
|
+
astMatch(
|
|
1325
|
+
content,
|
|
1326
|
+
filePath,
|
|
1327
|
+
line,
|
|
1328
|
+
prototypePollution,
|
|
1329
|
+
"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."
|
|
1330
|
+
)
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
);
|
|
831
1334
|
return matches;
|
|
832
1335
|
}
|
|
833
1336
|
};
|
|
@@ -860,13 +1363,13 @@ var unsanitizedFilenames = {
|
|
|
860
1363
|
description: "Using user-supplied filenames without sanitization in file operations can enable path traversal, overwriting system files, or executing commands via special characters.",
|
|
861
1364
|
check(content, filePath) {
|
|
862
1365
|
const matches = [];
|
|
1366
|
+
const hasSanitization = /sanitize|cleanFilename|safeFilename|replace\s*\(\s*\/\[.*\]\/|\.startsWith\s*\(\s*(?:UPLOADS_DIR|\w+_DIR)/i.test(content);
|
|
1367
|
+
if (hasSanitization) return [];
|
|
863
1368
|
const patterns = [
|
|
864
1369
|
/(?:writeFile|writeFileSync|createWriteStream|rename|copyFile)\s*\(\s*(?:`[^`]*\$\{|[^"'`\s,]+\s*\+)/g,
|
|
865
1370
|
/(?:dialog\.showSaveDialog|saveDialog).*(?:defaultPath|fileName)\s*:\s*(?!["'`])/g,
|
|
866
1371
|
/\.download\s*=\s*(?!["'`])/g
|
|
867
1372
|
];
|
|
868
|
-
const hasSanitization = /sanitize|cleanFilename|safeFilename|replace\s*\(\s*\/\[.*\]\//i.test(content);
|
|
869
|
-
if (hasSanitization) return [];
|
|
870
1373
|
for (const p of patterns) {
|
|
871
1374
|
matches.push(...findMatches(
|
|
872
1375
|
content,
|
|
@@ -876,6 +1379,61 @@ var unsanitizedFilenames = {
|
|
|
876
1379
|
() => "Sanitize filenames before use: strip path separators (/ \\), special chars, and '..' sequences. Example: name.replace(/[^a-zA-Z0-9._-]/g, '_')"
|
|
877
1380
|
));
|
|
878
1381
|
}
|
|
1382
|
+
if (!/\b(?:fs|fsPromises)\.(?:readFile|writeFile|appendFile|readFileSync|writeFileSync|appendFileSync|createReadStream|createWriteStream|unlink|unlinkSync|stat|statSync|rm|rmSync|mkdir|mkdirSync)\s*\(/.test(content)) {
|
|
1383
|
+
return matches;
|
|
1384
|
+
}
|
|
1385
|
+
const ctx = tryParse(content, filePath);
|
|
1386
|
+
if (!ctx) return matches;
|
|
1387
|
+
const { parsed, taint } = ctx;
|
|
1388
|
+
const FS_SINKS = /* @__PURE__ */ new Set([
|
|
1389
|
+
"readFile",
|
|
1390
|
+
"writeFile",
|
|
1391
|
+
"appendFile",
|
|
1392
|
+
"readFileSync",
|
|
1393
|
+
"writeFileSync",
|
|
1394
|
+
"appendFileSync",
|
|
1395
|
+
"createReadStream",
|
|
1396
|
+
"createWriteStream",
|
|
1397
|
+
"unlink",
|
|
1398
|
+
"unlinkSync",
|
|
1399
|
+
"rm",
|
|
1400
|
+
"rmSync",
|
|
1401
|
+
"stat",
|
|
1402
|
+
"statSync",
|
|
1403
|
+
"mkdir",
|
|
1404
|
+
"mkdirSync"
|
|
1405
|
+
]);
|
|
1406
|
+
const isFsReceiver = (obj) => {
|
|
1407
|
+
if (obj.type === "Identifier") return obj.name === "fs" || obj.name === "fsPromises";
|
|
1408
|
+
if (obj.type === "MemberExpression") {
|
|
1409
|
+
return obj.object.type === "Identifier" && obj.object.name === "fs" && obj.property.type === "Identifier" && obj.property.name === "promises";
|
|
1410
|
+
}
|
|
1411
|
+
return false;
|
|
1412
|
+
};
|
|
1413
|
+
visitCalls(
|
|
1414
|
+
parsed,
|
|
1415
|
+
(callee) => {
|
|
1416
|
+
if (callee.type !== "MemberExpression") return false;
|
|
1417
|
+
if (callee.property.type !== "Identifier") return false;
|
|
1418
|
+
if (!FS_SINKS.has(callee.property.name)) return false;
|
|
1419
|
+
return isFsReceiver(callee.object);
|
|
1420
|
+
},
|
|
1421
|
+
(call, line) => {
|
|
1422
|
+
const first = call.arguments[0];
|
|
1423
|
+
if (!first || first.type === "SpreadElement") return;
|
|
1424
|
+
if (!taint.isTainted(first)) return;
|
|
1425
|
+
if (matches.some((m) => m.line === line)) return;
|
|
1426
|
+
matches.push(
|
|
1427
|
+
astMatch(
|
|
1428
|
+
content,
|
|
1429
|
+
filePath,
|
|
1430
|
+
line,
|
|
1431
|
+
unsanitizedFilenames,
|
|
1432
|
+
"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');`"
|
|
1433
|
+
)
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
);
|
|
879
1437
|
return matches;
|
|
880
1438
|
}
|
|
881
1439
|
};
|
|
@@ -1026,8 +1584,9 @@ var insecureDeserialization = {
|
|
|
1026
1584
|
/unserialize\s*\(/g,
|
|
1027
1585
|
// Ruby Marshal
|
|
1028
1586
|
/Marshal\.load\s*\(/g,
|
|
1029
|
-
// YAML unsafe load (Python)
|
|
1030
|
-
|
|
1587
|
+
// YAML unsafe load (Python + js-yaml). Filtered below so safe schema
|
|
1588
|
+
// arguments (SAFE_SCHEMA, FAILSAFE_SCHEMA, yaml.SafeLoader) don't FP.
|
|
1589
|
+
/yaml\.load\s*\(/g,
|
|
1031
1590
|
/yaml\.unsafe_load\s*\(/g,
|
|
1032
1591
|
// Java ObjectInputStream
|
|
1033
1592
|
/ObjectInputStream\s*\(/g,
|
|
@@ -1043,7 +1602,20 @@ var insecureDeserialization = {
|
|
|
1043
1602
|
() => "Never deserialize untrusted data. Use JSON instead of pickle/Marshal/unserialize. For YAML, use yaml.safe_load(). Validate and sanitize all input before deserialization."
|
|
1044
1603
|
));
|
|
1045
1604
|
}
|
|
1046
|
-
return matches
|
|
1605
|
+
return matches.filter((m) => {
|
|
1606
|
+
if (!/yaml\.load\s*\(/.test(m.snippet ?? "")) return true;
|
|
1607
|
+
const lineText = (m.snippet ?? "").toLowerCase();
|
|
1608
|
+
if (/safe_schema|failsafe_schema|safe_load|safeloader/.test(lineText)) {
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
const lineIdx = m.line - 1;
|
|
1612
|
+
const lines = content.split("\n");
|
|
1613
|
+
const ctx = lines.slice(lineIdx, lineIdx + 3).join("\n").toLowerCase();
|
|
1614
|
+
if (/safe_schema|failsafe_schema|safe_load|safeloader/.test(ctx)) {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
return true;
|
|
1618
|
+
});
|
|
1047
1619
|
}
|
|
1048
1620
|
};
|
|
1049
1621
|
var hardcodedJWTSecret = {
|
|
@@ -1121,7 +1693,12 @@ var exposedDebugMode = {
|
|
|
1121
1693
|
/app\.debug\s*=\s*True/g,
|
|
1122
1694
|
/app\.run\s*\([^)]*debug\s*=\s*True/g,
|
|
1123
1695
|
// Source maps in production
|
|
1124
|
-
/devtool\s*:\s*["'`](?:eval|cheap|source-map|inline-source-map)["'`]/g
|
|
1696
|
+
/devtool\s*:\s*["'`](?:eval|cheap|source-map|inline-source-map)["'`]/g,
|
|
1697
|
+
// Debug endpoints that leak environment / process internals in the
|
|
1698
|
+
// response body. Very high-signal — a production service shouldn't
|
|
1699
|
+
// serialize process.env or process.version to callers.
|
|
1700
|
+
/res\.(?:json|send)\s*\([^)]*process\.(?:env|version|pid|arch|platform)/gi,
|
|
1701
|
+
/(?:env|environment)\s*:\s*process\.env\b/gi
|
|
1125
1702
|
];
|
|
1126
1703
|
for (const p of patterns) {
|
|
1127
1704
|
matches.push(...findMatches(
|
|
@@ -1159,6 +1736,21 @@ var insecureRandomness = {
|
|
|
1159
1736
|
if (nonSecurityVarNames.test(lineText)) continue;
|
|
1160
1737
|
matches.push(rm);
|
|
1161
1738
|
}
|
|
1739
|
+
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;
|
|
1740
|
+
const SECURITY_IDENT_CONTEXT = /\b(?:session[_-]?token|csrf[_-]?token|reset[_-]?token|verify[_-]?code|otp[_-]?code|refresh[_-]?token|api[_-]?key)\b/i;
|
|
1741
|
+
if (SECURITY_FN_CONTEXT.test(content) || SECURITY_IDENT_CONTEXT.test(content)) {
|
|
1742
|
+
const raw = findMatches(
|
|
1743
|
+
content,
|
|
1744
|
+
/Math\.random\s*\(\s*\)/g,
|
|
1745
|
+
insecureRandomness,
|
|
1746
|
+
filePath,
|
|
1747
|
+
() => "Math.random() is not cryptographically secure. Use crypto.randomBytes() (Node), crypto.getRandomValues() (browser), or crypto.randomUUID() for tokens, session IDs, salts, and similar values."
|
|
1748
|
+
);
|
|
1749
|
+
for (const m of raw) {
|
|
1750
|
+
if (matches.some((existing) => existing.line === m.line)) continue;
|
|
1751
|
+
matches.push(m);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1162
1754
|
return matches;
|
|
1163
1755
|
}
|
|
1164
1756
|
};
|
|
@@ -1170,13 +1762,13 @@ var openRedirectParams = {
|
|
|
1170
1762
|
description: "Redirect parameters like ?redirect_url=, ?return_to=, ?next= passed directly to redirects enable phishing attacks.",
|
|
1171
1763
|
check(content, filePath) {
|
|
1172
1764
|
const matches = [];
|
|
1765
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
1766
|
+
const hasValidation = /allowed[_-]?(?:urls?|domains?|hosts?|paths?|routes?|list)|allow[_-]?list|valid[_-]?url|safe[_-]?domain|whitelist|startsWith.*https|new URL.*hostname/i.test(codeOnly);
|
|
1767
|
+
if (hasValidation) return [];
|
|
1173
1768
|
const patterns = [
|
|
1174
|
-
// Reading redirect-like query params and using in redirect
|
|
1175
1769
|
/(?: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,
|
|
1176
1770
|
/redirect\s*\(\s*(?:req\.query|req\.params|searchParams\.get)\s*\(\s*["'`](?:redirect|return|next|callback|url|goto)/gi
|
|
1177
1771
|
];
|
|
1178
|
-
const hasValidation = /allowedUrls|allowedDomains|allowedHosts|validUrl|safeDomain|whitelist|startsWith.*https|new URL.*hostname/i.test(content);
|
|
1179
|
-
if (hasValidation) return [];
|
|
1180
1772
|
for (const p of patterns) {
|
|
1181
1773
|
matches.push(...findMatches(
|
|
1182
1774
|
content,
|
|
@@ -1186,6 +1778,29 @@ var openRedirectParams = {
|
|
|
1186
1778
|
() => "Validate redirect URLs against an allowlist of trusted domains. Use: const url = new URL(input); if (!ALLOWED_HOSTS.includes(url.hostname)) reject."
|
|
1187
1779
|
));
|
|
1188
1780
|
}
|
|
1781
|
+
if (!/\.redirect\s*\(/.test(content)) return matches;
|
|
1782
|
+
const ctx = tryParse(content, filePath);
|
|
1783
|
+
if (!ctx) return matches;
|
|
1784
|
+
const { parsed, taint } = ctx;
|
|
1785
|
+
visitCalls(
|
|
1786
|
+
parsed,
|
|
1787
|
+
(callee) => isCalleeNamed(callee, "redirect"),
|
|
1788
|
+
(call, line) => {
|
|
1789
|
+
const first = call.arguments[0];
|
|
1790
|
+
if (!first || first.type === "SpreadElement") return;
|
|
1791
|
+
if (!taint.isTainted(first)) return;
|
|
1792
|
+
if (matches.some((m) => m.line === line)) return;
|
|
1793
|
+
matches.push(
|
|
1794
|
+
astMatch(
|
|
1795
|
+
content,
|
|
1796
|
+
filePath,
|
|
1797
|
+
line,
|
|
1798
|
+
openRedirectParams,
|
|
1799
|
+
"Validate redirect targets against an allowlist before calling res.redirect(). `const ALLOWED = new Set(['/dashboard', '/settings']); if (!ALLOWED.has(next)) return res.redirect('/dashboard');`"
|
|
1800
|
+
)
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
);
|
|
1189
1804
|
return matches;
|
|
1190
1805
|
}
|
|
1191
1806
|
};
|
|
@@ -1228,8 +1843,7 @@ var exposedStackTraces = {
|
|
|
1228
1843
|
category: "Information Leakage",
|
|
1229
1844
|
description: "Returning error.stack or detailed error messages in API responses reveals internal code paths, file structure, and dependencies to attackers.",
|
|
1230
1845
|
check(content, filePath) {
|
|
1231
|
-
|
|
1232
|
-
if (!isApiFile) return [];
|
|
1846
|
+
if (!isServerSideFile(filePath)) return [];
|
|
1233
1847
|
const matches = [];
|
|
1234
1848
|
const patterns = [
|
|
1235
1849
|
// Sending stack trace in response
|
|
@@ -1260,9 +1874,10 @@ var insecureFileUpload = {
|
|
|
1260
1874
|
description: "File uploads validated only by extension (not MIME type or content) allow attackers to upload executable files disguised as images or documents.",
|
|
1261
1875
|
check(content, filePath) {
|
|
1262
1876
|
if (!/upload|multer|formidable|busboy|multipart/i.test(content)) return [];
|
|
1877
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
1263
1878
|
const matches = [];
|
|
1264
|
-
const hasExtCheck = /\.(?:endsWith|match|test)\s*\([^)]*(?:\.jpg|\.png|\.pdf|\.doc|ext)/i.test(
|
|
1265
|
-
const hasMimeCheck =
|
|
1879
|
+
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);
|
|
1880
|
+
const hasMimeCheck = /\bmimetype\b|\bcontent-type\b|\bfile\.type\b|\bmime\.\w|\bmagic\.detect\b|\bfile-type\b|fileTypeFromBuffer/i.test(codeOnly);
|
|
1266
1881
|
if (hasExtCheck && !hasMimeCheck) {
|
|
1267
1882
|
matches.push(...findMatches(
|
|
1268
1883
|
content,
|
|
@@ -1326,34 +1941,53 @@ var ssrfVulnerability = {
|
|
|
1326
1941
|
category: "Injection",
|
|
1327
1942
|
description: "Fetching URLs from user input without validation allows attackers to access internal services, cloud metadata endpoints (169.254.169.254), and private networks.",
|
|
1328
1943
|
check(content, filePath) {
|
|
1329
|
-
if (
|
|
1330
|
-
const isServerFile = /(?:\/api\/|routes?\/|controllers?\/|server\.|handler|middleware)/i.test(filePath);
|
|
1331
|
-
if (!isServerFile) return [];
|
|
1944
|
+
if (!isServerSideFile(filePath)) return [];
|
|
1332
1945
|
const matches = [];
|
|
1333
|
-
const patterns = [
|
|
1334
|
-
/(?:fetch|axios\.get|axios\.post|axios|got|request|http\.get|https\.get)\s*\(\s*(?:req\.(?:body|query|params))\./gi
|
|
1335
|
-
];
|
|
1336
1946
|
const hasValidation = /allowedHosts|allowedDomains|allowedUrls|safeDomain|whitelist|urlValidator|new URL.*hostname.*includes|isAllowedUrl|validateUrl|isValidUrl/i.test(content);
|
|
1337
1947
|
if (hasValidation) return [];
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1948
|
+
const inlinePattern = /(?:fetch|axios\.get|axios\.post|axios|got|request|http\.get|https\.get)\s*\(\s*(?:req\.(?:body|query|params))\./gi;
|
|
1949
|
+
matches.push(...findMatches(
|
|
1950
|
+
content,
|
|
1951
|
+
inlinePattern,
|
|
1952
|
+
ssrfVulnerability,
|
|
1953
|
+
filePath,
|
|
1954
|
+
() => "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');"
|
|
1955
|
+
));
|
|
1956
|
+
if (!/\b(?:fetch|axios|got|request|http\.get|https\.get)\b/.test(content)) return matches;
|
|
1957
|
+
const ctx = tryParse(content, filePath);
|
|
1958
|
+
if (!ctx) return matches;
|
|
1959
|
+
const { parsed, taint } = ctx;
|
|
1960
|
+
const FETCH_CALLEES = /* @__PURE__ */ new Set(["fetch", "axios", "got", "request"]);
|
|
1961
|
+
const FETCH_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "request"]);
|
|
1962
|
+
visitCalls(
|
|
1963
|
+
parsed,
|
|
1964
|
+
(callee) => {
|
|
1965
|
+
if (callee.type === "Identifier" && FETCH_CALLEES.has(callee.name)) return true;
|
|
1966
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
1967
|
+
if (!FETCH_METHODS.has(callee.property.name)) return false;
|
|
1968
|
+
const obj = callee.object;
|
|
1969
|
+
if (obj.type === "Identifier") {
|
|
1970
|
+
return obj.name === "axios" || obj.name === "got" || obj.name === "http" || obj.name === "https";
|
|
1971
|
+
}
|
|
1353
1972
|
}
|
|
1354
|
-
|
|
1973
|
+
return false;
|
|
1974
|
+
},
|
|
1975
|
+
(call, line) => {
|
|
1976
|
+
const first = call.arguments[0];
|
|
1977
|
+
if (!first || first.type === "SpreadElement") return;
|
|
1978
|
+
if (!taint.isTainted(first)) return;
|
|
1979
|
+
if (matches.some((m) => m.line === line)) return;
|
|
1980
|
+
matches.push(
|
|
1981
|
+
astMatch(
|
|
1982
|
+
content,
|
|
1983
|
+
filePath,
|
|
1984
|
+
line,
|
|
1985
|
+
ssrfVulnerability,
|
|
1986
|
+
"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)."
|
|
1987
|
+
)
|
|
1988
|
+
);
|
|
1355
1989
|
}
|
|
1356
|
-
|
|
1990
|
+
);
|
|
1357
1991
|
return matches;
|
|
1358
1992
|
}
|
|
1359
1993
|
};
|
|
@@ -1364,19 +1998,15 @@ var massAssignment = {
|
|
|
1364
1998
|
category: "Authorization",
|
|
1365
1999
|
description: "Spreading or assigning request body directly into database models allows attackers to set fields they shouldn't (e.g., isAdmin, role, verified).",
|
|
1366
2000
|
check(content, filePath) {
|
|
1367
|
-
|
|
1368
|
-
|
|
2001
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2002
|
+
const hasSanitization = /pick\(|omit\(|allowedFields|sanitize|whitelist|permit|strong_params/i.test(content);
|
|
2003
|
+
if (hasSanitization) return [];
|
|
1369
2004
|
const matches = [];
|
|
1370
2005
|
const patterns = [
|
|
1371
|
-
// Object.assign(model, req.body)
|
|
1372
2006
|
/Object\.assign\s*\(\s*(?:user|account|profile|record|doc|model|entity)[^,]*,\s*(?:req\.body|body|input|data)\s*\)/gi,
|
|
1373
|
-
// Spread req.body into create/update
|
|
1374
2007
|
/(?:create|update|insert|save|findOneAndUpdate|updateOne|upsert)\s*\(\s*\{[^}]*\.\.\.(?:req\.body|body|input|data)/gi,
|
|
1375
|
-
// Direct req.body into DB
|
|
1376
2008
|
/(?:create|insert|save)\s*\(\s*(?:req\.body|body)\s*\)/gi
|
|
1377
2009
|
];
|
|
1378
|
-
const hasSanitization = /pick\(|omit\(|allowedFields|sanitize|whitelist|permit|strong_params/i.test(content);
|
|
1379
|
-
if (hasSanitization) return [];
|
|
1380
2010
|
for (const p of patterns) {
|
|
1381
2011
|
matches.push(...findMatches(
|
|
1382
2012
|
content,
|
|
@@ -1386,6 +2016,61 @@ var massAssignment = {
|
|
|
1386
2016
|
() => "Never pass req.body directly to database operations. Explicitly pick allowed fields: const { name, email } = req.body; await db.create({ name, email });"
|
|
1387
2017
|
));
|
|
1388
2018
|
}
|
|
2019
|
+
const ORM_METHOD_PREFIXES = [
|
|
2020
|
+
"create",
|
|
2021
|
+
"insert",
|
|
2022
|
+
"save",
|
|
2023
|
+
"update",
|
|
2024
|
+
"upsert",
|
|
2025
|
+
"patch",
|
|
2026
|
+
"bulkCreate",
|
|
2027
|
+
"bulkInsert",
|
|
2028
|
+
"bulkUpsert",
|
|
2029
|
+
"findOneAndUpdate",
|
|
2030
|
+
"findByIdAndUpdate",
|
|
2031
|
+
"findAndUpdate",
|
|
2032
|
+
"build",
|
|
2033
|
+
"merge",
|
|
2034
|
+
"replace"
|
|
2035
|
+
];
|
|
2036
|
+
const isOrmMethod = (name) => ORM_METHOD_PREFIXES.some((p) => name === p || name.startsWith(p));
|
|
2037
|
+
if (!/\b(?:create|insert|save|update|upsert|patch|bulk|findOneAndUpdate|findByIdAndUpdate|findAndUpdate|build|merge|replace)\w*\s*\(/.test(content)) {
|
|
2038
|
+
return matches;
|
|
2039
|
+
}
|
|
2040
|
+
const ctx = tryParse(content, filePath);
|
|
2041
|
+
if (!ctx) return matches;
|
|
2042
|
+
const { parsed, taint } = ctx;
|
|
2043
|
+
visitCalls(
|
|
2044
|
+
parsed,
|
|
2045
|
+
(callee) => {
|
|
2046
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
2047
|
+
return isOrmMethod(callee.property.name);
|
|
2048
|
+
}
|
|
2049
|
+
return false;
|
|
2050
|
+
},
|
|
2051
|
+
(call, line) => {
|
|
2052
|
+
const tainted = call.arguments.some((arg) => {
|
|
2053
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
2054
|
+
if (arg.type === "ObjectExpression") {
|
|
2055
|
+
return arg.properties.some(
|
|
2056
|
+
(p) => p.type === "SpreadElement" && taint.isTainted(p.argument)
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
return taint.isTainted(arg);
|
|
2060
|
+
});
|
|
2061
|
+
if (!tainted) return;
|
|
2062
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2063
|
+
matches.push(
|
|
2064
|
+
astMatch(
|
|
2065
|
+
content,
|
|
2066
|
+
filePath,
|
|
2067
|
+
line,
|
|
2068
|
+
massAssignment,
|
|
2069
|
+
"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 })`."
|
|
2070
|
+
)
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
);
|
|
1389
2074
|
return matches;
|
|
1390
2075
|
}
|
|
1391
2076
|
};
|
|
@@ -1398,12 +2083,12 @@ var timingAttack = {
|
|
|
1398
2083
|
check(content, filePath) {
|
|
1399
2084
|
if (isTestFile(filePath)) return [];
|
|
1400
2085
|
const matches = [];
|
|
2086
|
+
const hasTimingSafe = /timingSafeEqual|constantTimeEqual|safeCompare|secureCompare/i.test(content);
|
|
2087
|
+
if (hasTimingSafe) return [];
|
|
1401
2088
|
const patterns = [
|
|
1402
2089
|
/(?:token|secret|hash|digest|signature|hmac|apiKey|api_key)\s*(?:===|!==)\s*(?:req\.|body\.|params\.|query\.|input)/gi,
|
|
1403
2090
|
/(?:^|[^.\w])(?:req\.|body\.|params\.|query\.|input)[\w.]*(?:token|secret|hash|digest|signature|hmac)\s*(?:===|!==)/gim
|
|
1404
2091
|
];
|
|
1405
|
-
const hasTimingSafe = /timingSafeEqual|constantTimeEqual|safeCompare|secureCompare/i.test(content);
|
|
1406
|
-
if (hasTimingSafe) return [];
|
|
1407
2092
|
for (const p of patterns) {
|
|
1408
2093
|
const raw = findMatches(
|
|
1409
2094
|
content,
|
|
@@ -1418,6 +2103,43 @@ var timingAttack = {
|
|
|
1418
2103
|
matches.push(m);
|
|
1419
2104
|
}
|
|
1420
2105
|
}
|
|
2106
|
+
if (!/===|!==/.test(content)) return matches;
|
|
2107
|
+
const ctx = tryParse(content, filePath);
|
|
2108
|
+
if (!ctx) return matches;
|
|
2109
|
+
const SECRET_NAME_RE = /(?:secret|token|api[_-]?key|auth[_-]?key|jwt[_-]?secret|signature|hmac|password|passwd|pwd_hash|pwd_digest|digest)/i;
|
|
2110
|
+
function looksLikeSecret(node) {
|
|
2111
|
+
if (node.type === "Identifier") return SECRET_NAME_RE.test(node.name);
|
|
2112
|
+
if (node.type === "MemberExpression") {
|
|
2113
|
+
if (node.property.type === "Identifier" && SECRET_NAME_RE.test(node.property.name)) return true;
|
|
2114
|
+
if (node.property.type === "StringLiteral" && SECRET_NAME_RE.test(node.property.value)) return true;
|
|
2115
|
+
return looksLikeSecret(node.object);
|
|
2116
|
+
}
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
visitBinary(ctx.parsed, (n, line) => {
|
|
2120
|
+
if (n.operator !== "===" && n.operator !== "!==" && n.operator !== "==" && n.operator !== "!=") {
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
if (n.left.type === "UnaryExpression" && n.left.operator === "typeof") return;
|
|
2124
|
+
if (n.right.type === "UnaryExpression" && n.right.operator === "typeof") return;
|
|
2125
|
+
const leftSecret = looksLikeSecret(n.left);
|
|
2126
|
+
const rightSecret = looksLikeSecret(n.right);
|
|
2127
|
+
if (!leftSecret && !rightSecret) return;
|
|
2128
|
+
const otherSide = leftSecret ? n.right : n.left;
|
|
2129
|
+
if (otherSide.type === "StringLiteral" || otherSide.type === "NumericLiteral" || otherSide.type === "NullLiteral" || otherSide.type === "BooleanLiteral") {
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2133
|
+
matches.push(
|
|
2134
|
+
astMatch(
|
|
2135
|
+
content,
|
|
2136
|
+
filePath,
|
|
2137
|
+
line,
|
|
2138
|
+
timingAttack,
|
|
2139
|
+
"Compare secrets with crypto.timingSafeEqual() after a length check. === short-circuits on the first differing byte and leaks information via timing."
|
|
2140
|
+
)
|
|
2141
|
+
);
|
|
2142
|
+
});
|
|
1421
2143
|
return matches;
|
|
1422
2144
|
}
|
|
1423
2145
|
};
|
|
@@ -1428,16 +2150,14 @@ var logInjection = {
|
|
|
1428
2150
|
category: "Injection",
|
|
1429
2151
|
description: "Logging unsanitized user input allows attackers to forge log entries, inject malicious content, or exploit log aggregation systems via newlines and special characters.",
|
|
1430
2152
|
check(content, filePath) {
|
|
1431
|
-
if (
|
|
1432
|
-
const
|
|
1433
|
-
if (
|
|
2153
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2154
|
+
const hasSanitization = /replace\s*\(\s*\/\[?\\r\\n\]|sanitizeLog|stripNewlines|sanitizeForLog/i.test(content);
|
|
2155
|
+
if (hasSanitization) return [];
|
|
1434
2156
|
const matches = [];
|
|
1435
2157
|
const patterns = [
|
|
1436
2158
|
/console\.(?:log|warn|error|info)\s*\([^)]*(?:req\.body|req\.query|req\.params|req\.headers)\s*\)/gi,
|
|
1437
2159
|
/(?:logger|log)\.(?:info|warn|error|debug)\s*\([^)]*(?:req\.body|req\.query|req\.params)\s*\)/gi
|
|
1438
2160
|
];
|
|
1439
|
-
const hasSanitization = /sanitize|escape|JSON\.stringify|replace.*\\n/i.test(content);
|
|
1440
|
-
if (hasSanitization) return [];
|
|
1441
2161
|
for (const p of patterns) {
|
|
1442
2162
|
matches.push(...findMatches(
|
|
1443
2163
|
content,
|
|
@@ -1447,6 +2167,41 @@ var logInjection = {
|
|
|
1447
2167
|
() => "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."
|
|
1448
2168
|
));
|
|
1449
2169
|
}
|
|
2170
|
+
if (!/(?:console\.|logger\.|log\.)\s*\w+\s*\(/.test(content)) return matches;
|
|
2171
|
+
const ctx = tryParse(content, filePath);
|
|
2172
|
+
if (!ctx) return matches;
|
|
2173
|
+
const { parsed, taint } = ctx;
|
|
2174
|
+
const LOG_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug", "trace"]);
|
|
2175
|
+
visitCalls(
|
|
2176
|
+
parsed,
|
|
2177
|
+
(callee) => {
|
|
2178
|
+
if (callee.type !== "MemberExpression") return false;
|
|
2179
|
+
if (callee.property.type !== "Identifier") return false;
|
|
2180
|
+
if (!LOG_METHODS.has(callee.property.name)) return false;
|
|
2181
|
+
const obj = callee.object;
|
|
2182
|
+
if (obj.type === "Identifier") {
|
|
2183
|
+
return obj.name === "console" || obj.name === "logger" || obj.name === "log";
|
|
2184
|
+
}
|
|
2185
|
+
return false;
|
|
2186
|
+
},
|
|
2187
|
+
(call, line) => {
|
|
2188
|
+
const tainted = call.arguments.some((arg) => {
|
|
2189
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
2190
|
+
return taint.isTainted(arg);
|
|
2191
|
+
});
|
|
2192
|
+
if (!tainted) return;
|
|
2193
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2194
|
+
matches.push(
|
|
2195
|
+
astMatch(
|
|
2196
|
+
content,
|
|
2197
|
+
filePath,
|
|
2198
|
+
line,
|
|
2199
|
+
logInjection,
|
|
2200
|
+
"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."
|
|
2201
|
+
)
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
);
|
|
1450
2205
|
return matches;
|
|
1451
2206
|
}
|
|
1452
2207
|
};
|
|
@@ -1459,7 +2214,33 @@ var weakPasswordRequirements = {
|
|
|
1459
2214
|
check(content, filePath) {
|
|
1460
2215
|
if (isTestFile(filePath)) return [];
|
|
1461
2216
|
if (!/(?:password|passwd|pwd)/i.test(content)) return [];
|
|
1462
|
-
|
|
2217
|
+
const isPasswordContext = /(?:register|signup|sign.up|createUser|create.user|changePassword|resetPassword|set.password|validatePassword|validate.password|passwordPolicy|password.policy)/i.test(
|
|
2218
|
+
content
|
|
2219
|
+
) || isServerSideFile(filePath);
|
|
2220
|
+
const matches = [];
|
|
2221
|
+
if (isPasswordContext) {
|
|
2222
|
+
const weakThresholdPatterns = [
|
|
2223
|
+
// password.length < 4, password.length < 6, etc.
|
|
2224
|
+
/(?:password|pwd|passwd)\s*\.length\s*<\s*[1-7]\b/gi,
|
|
2225
|
+
/(?:password|pwd|passwd)\s*\.length\s*<=\s*[0-6]\b/gi,
|
|
2226
|
+
// password.length >= 4, password.length >= 6 (min length set too low)
|
|
2227
|
+
/(?:password|pwd|passwd)\s*\.length\s*>=\s*[1-7]\b/gi,
|
|
2228
|
+
/(?:password|pwd|passwd)\s*\.length\s*>\s*[0-6]\b/gi,
|
|
2229
|
+
// Python: len(password) < 8 with a low threshold
|
|
2230
|
+
/len\s*\(\s*(?:password|pwd|passwd)\s*\)\s*<\s*[1-7]\b/gi
|
|
2231
|
+
];
|
|
2232
|
+
for (const p of weakThresholdPatterns) {
|
|
2233
|
+
matches.push(...findMatches(
|
|
2234
|
+
content,
|
|
2235
|
+
p,
|
|
2236
|
+
weakPasswordRequirements,
|
|
2237
|
+
filePath,
|
|
2238
|
+
() => "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+."
|
|
2239
|
+
));
|
|
2240
|
+
}
|
|
2241
|
+
if (matches.length > 0) return matches;
|
|
2242
|
+
}
|
|
2243
|
+
if (!isPasswordContext) return [];
|
|
1463
2244
|
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);
|
|
1464
2245
|
if (hasValidation) return [];
|
|
1465
2246
|
const hasPasswordHandling = /(?:password|pwd)\s*[:=]\s*(?:req\.body|body|input|params|args)\./i.test(content);
|
|
@@ -1492,7 +2273,8 @@ var sessionFixation = {
|
|
|
1492
2273
|
if (isTestFile(filePath)) return [];
|
|
1493
2274
|
if (!/(?:login|signin|sign.in|authenticate)/i.test(content)) return [];
|
|
1494
2275
|
if (!/session/i.test(content)) return [];
|
|
1495
|
-
const
|
|
2276
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
2277
|
+
const hasRegenerate = /regenerate|destroy.*create|req\.session\.id\s*=|session\.regenerateId|rotateSession|clearCookies/i.test(codeOnly);
|
|
1496
2278
|
if (hasRegenerate) return [];
|
|
1497
2279
|
const hasLogin = /(?:function\s+(?:login|signin|authenticate)|(?:login|signin|authenticate)\s*(?:=\s*(?:async\s*)?\(|:\s*(?:async\s*)?\())/i.test(content);
|
|
1498
2280
|
if (!hasLogin) return [];
|
|
@@ -1515,7 +2297,8 @@ var missingBruteForce = {
|
|
|
1515
2297
|
const isLoginFile = /(?:login|signin|sign.in|auth)/i.test(filePath) || /(?:login|signin|authenticate).*(?:post|handler|route)/i.test(content);
|
|
1516
2298
|
if (!isLoginFile) return [];
|
|
1517
2299
|
if (!/(?:password|credential)/i.test(content)) return [];
|
|
1518
|
-
const
|
|
2300
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
2301
|
+
const hasBruteForce = /rate.?limit|throttle|lockout|maxAttempts|max_attempts|failedAttempts|loginAttempts|express-brute|express-rate-limit|slowDown/i.test(codeOnly);
|
|
1519
2302
|
if (hasBruteForce) return [];
|
|
1520
2303
|
return findMatches(
|
|
1521
2304
|
content,
|
|
@@ -1542,7 +2325,10 @@ var nosqlInjection = {
|
|
|
1542
2325
|
// $where with user input
|
|
1543
2326
|
/\$where\s*:\s*(?!["'`])/g,
|
|
1544
2327
|
// Direct variable in query without sanitization
|
|
1545
|
-
/\.(?:findOne|findById|deleteOne|updateOne|findOneAndUpdate)\s*\(\s*\{[^}]*:\s*(?:req\.(?:body|query|params))\./gi
|
|
2328
|
+
/\.(?:findOne|findById|deleteOne|updateOne|findOneAndUpdate)\s*\(\s*\{[^}]*:\s*(?:req\.(?:body|query|params))\./gi,
|
|
2329
|
+
// findOneAndUpdate / updateOne / deleteOne / find with req.body as
|
|
2330
|
+
// the entire filter (attacker controls the filter shape).
|
|
2331
|
+
/\.(?:findOne|findById|findOneAndUpdate|updateOne|deleteOne|deleteMany|updateMany|replaceOne|find)\s*\(\s*req\.body\s*[,)]/gi
|
|
1546
2332
|
];
|
|
1547
2333
|
const hasSanitization = /sanitize|escape|mongo-sanitize|express-mongo-sanitize|validator|typeof.*===.*string/i.test(content);
|
|
1548
2334
|
if (hasSanitization) return [];
|
|
@@ -1621,7 +2407,7 @@ var graphqlIntrospection = {
|
|
|
1621
2407
|
category: "Information Leakage",
|
|
1622
2408
|
description: "GraphQL introspection exposes your entire API schema, types, queries, and mutations to attackers, making it easy to find attack vectors.",
|
|
1623
2409
|
check(content, filePath) {
|
|
1624
|
-
if (!/graphql/i.test(content) && !/graphql/i.test(filePath)) return [];
|
|
2410
|
+
if (!/graphql|apollo|ApolloServer|GraphQLServer|createYoga|buildSchema|makeExecutableSchema/i.test(content) && !/graphql|apollo/i.test(filePath)) return [];
|
|
1625
2411
|
const matches = [];
|
|
1626
2412
|
if (/introspection\s*:\s*true/i.test(content)) {
|
|
1627
2413
|
matches.push(...findMatches(
|
|
@@ -1760,6 +2546,15 @@ var exposedSourceMaps = {
|
|
|
1760
2546
|
() => "Set productionSourceMap: false to avoid exposing source code in production."
|
|
1761
2547
|
));
|
|
1762
2548
|
}
|
|
2549
|
+
if (/productionBrowserSourceMaps\s*:\s*true/i.test(content)) {
|
|
2550
|
+
matches.push(...findMatches(
|
|
2551
|
+
content,
|
|
2552
|
+
/productionBrowserSourceMaps\s*:\s*true/gi,
|
|
2553
|
+
exposedSourceMaps,
|
|
2554
|
+
filePath,
|
|
2555
|
+
() => "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."
|
|
2556
|
+
));
|
|
2557
|
+
}
|
|
1763
2558
|
return matches;
|
|
1764
2559
|
}
|
|
1765
2560
|
};
|
|
@@ -1885,14 +2680,14 @@ var weakHashing = {
|
|
|
1885
2680
|
if (filePath.includes("test") || filePath.includes("mock")) return [];
|
|
1886
2681
|
if (/bcrypt|scrypt|argon2/i.test(content)) return [];
|
|
1887
2682
|
const matches = [];
|
|
1888
|
-
const
|
|
2683
|
+
const inlinePatterns = [
|
|
1889
2684
|
/(?:md5|sha1|sha256|sha512)\s*\([^)]*(?:password|passwd|pwd)/gi,
|
|
1890
2685
|
/createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\).*(?:password|passwd|pwd)/gi,
|
|
1891
2686
|
/(?:password|passwd|pwd).*createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\)/gi,
|
|
1892
2687
|
/hashlib\.(?:md5|sha1|sha256)\s*\([^)]*(?:password|passwd|pwd)/gi,
|
|
1893
2688
|
/Digest::(?:MD5|SHA1|SHA256).*(?:password|passwd|pwd)/gi
|
|
1894
2689
|
];
|
|
1895
|
-
for (const p of
|
|
2690
|
+
for (const p of inlinePatterns) {
|
|
1896
2691
|
matches.push(...findMatches(
|
|
1897
2692
|
content,
|
|
1898
2693
|
p,
|
|
@@ -1901,6 +2696,27 @@ var weakHashing = {
|
|
|
1901
2696
|
() => "Use bcrypt, scrypt, or argon2 for password hashing \u2014 they're intentionally slow. Example: const hash = await bcrypt.hash(password, 12);"
|
|
1902
2697
|
));
|
|
1903
2698
|
}
|
|
2699
|
+
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);
|
|
2700
|
+
if (hasPasswordContext) {
|
|
2701
|
+
const weakCallPatterns = [
|
|
2702
|
+
/createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\s*\)/gi,
|
|
2703
|
+
/hashlib\.(?:md5|sha1|sha256)\s*\(/gi,
|
|
2704
|
+
/Digest::(?:MD5|SHA1|SHA256)\./gi
|
|
2705
|
+
];
|
|
2706
|
+
for (const p of weakCallPatterns) {
|
|
2707
|
+
const raw = findMatches(
|
|
2708
|
+
content,
|
|
2709
|
+
p,
|
|
2710
|
+
weakHashing,
|
|
2711
|
+
filePath,
|
|
2712
|
+
() => "Use bcrypt, scrypt, or argon2 for password hashing. These algorithms are intentionally slow so brute-force attacks are infeasible."
|
|
2713
|
+
);
|
|
2714
|
+
for (const m of raw) {
|
|
2715
|
+
if (matches.some((existing) => existing.line === m.line)) continue;
|
|
2716
|
+
matches.push(m);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
1904
2720
|
return matches;
|
|
1905
2721
|
}
|
|
1906
2722
|
};
|
|
@@ -1983,7 +2799,8 @@ var dangerousInnerHTML = {
|
|
|
1983
2799
|
if (!filePath.match(/\.(jsx|tsx)$/)) return [];
|
|
1984
2800
|
if (isTestFile(filePath)) return [];
|
|
1985
2801
|
if (!/dangerouslySetInnerHTML/i.test(content)) return [];
|
|
1986
|
-
const
|
|
2802
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, "");
|
|
2803
|
+
const hasSanitize = /\b(?:DOMPurify|sanitizeHtml|sanitize-html|isomorphic-dompurify)\b|\b(?:sanitize|purify|xss)\s*\(/i.test(codeOnly);
|
|
1987
2804
|
if (hasSanitize) return [];
|
|
1988
2805
|
const findings = [];
|
|
1989
2806
|
const re = /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([^}]+)\}/g;
|
|
@@ -2271,7 +3088,8 @@ var missingCSRF = {
|
|
|
2271
3088
|
() => "Remove @csrf_exempt and use proper CSRF tokens. For APIs, use token-based auth (JWT) instead of session cookies."
|
|
2272
3089
|
));
|
|
2273
3090
|
}
|
|
2274
|
-
|
|
3091
|
+
const pythonCodeOnly = content.replace(/^\s*#.*$/gm, "");
|
|
3092
|
+
if (/MIDDLEWARE.*=.*\[/s.test(pythonCodeOnly) && !/CsrfViewMiddleware/i.test(pythonCodeOnly) && /django/i.test(content)) {
|
|
2275
3093
|
matches.push(...findMatches(
|
|
2276
3094
|
content,
|
|
2277
3095
|
/MIDDLEWARE\s*=/g,
|
|
@@ -2290,20 +3108,47 @@ var githubActionsInjection = {
|
|
|
2290
3108
|
category: "Injection",
|
|
2291
3109
|
description: "Using ${{ github.event.* }} directly in 'run:' steps allows attackers to inject shell commands via PR titles, issue bodies, or branch names.",
|
|
2292
3110
|
check(content, filePath) {
|
|
2293
|
-
|
|
3111
|
+
const isWorkflowPath = /\.github\/workflows\/.*\.(ya?ml)$/i.test(filePath);
|
|
3112
|
+
const looksLikeWorkflow = /\.(ya?ml)$/i.test(filePath) && /^\s*on\s*:/m.test(content) && /^\s*jobs\s*:/m.test(content);
|
|
3113
|
+
if (!isWorkflowPath && !looksLikeWorkflow) return [];
|
|
2294
3114
|
const matches = [];
|
|
2295
3115
|
const patterns = [
|
|
2296
3116
|
/run:.*\$\{\{\s*github\.event\.(?:issue|pull_request|comment|review|head_commit)\.(?:title|body|message)/gi,
|
|
2297
|
-
/run:.*\$\{\{\s*github\.event\.(?:inputs|head_ref|base_ref)/gi
|
|
3117
|
+
/run:.*\$\{\{\s*github\.event\.(?:inputs|head_ref|base_ref)/gi,
|
|
3118
|
+
// Literal-block `run: |` with user-controllable interpolation on the
|
|
3119
|
+
// SAME line or a following indented line. Anchored to `run:` (either
|
|
3120
|
+
// on the same line or within the prior few lines before the
|
|
3121
|
+
// interpolation) so `env: TITLE: ${{ github.event.issue.title }}` —
|
|
3122
|
+
// the recommended-fix shape — doesn't false-positive.
|
|
3123
|
+
/(?:^\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
|
|
2298
3124
|
];
|
|
3125
|
+
const lines = content.split("\n");
|
|
3126
|
+
const isEnvMappingLine = (lineNum) => {
|
|
3127
|
+
const lineText = lines[lineNum - 1] ?? "";
|
|
3128
|
+
if (/^\s*env\s*:/.test(lineText)) return true;
|
|
3129
|
+
const indent = lineText.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
3130
|
+
if (indent === 0) return false;
|
|
3131
|
+
for (let i = lineNum - 2; i >= 0; i--) {
|
|
3132
|
+
const prev = lines[i] ?? "";
|
|
3133
|
+
if (!prev.trim()) continue;
|
|
3134
|
+
const prevIndent = prev.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
3135
|
+
if (prevIndent < indent) {
|
|
3136
|
+
return /^\s*env\s*:/.test(prev);
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
return false;
|
|
3140
|
+
};
|
|
2299
3141
|
for (const p of patterns) {
|
|
2300
|
-
|
|
3142
|
+
for (const m of findMatches(
|
|
2301
3143
|
content,
|
|
2302
3144
|
p,
|
|
2303
3145
|
githubActionsInjection,
|
|
2304
3146
|
filePath,
|
|
2305
3147
|
() => "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."
|
|
2306
|
-
))
|
|
3148
|
+
)) {
|
|
3149
|
+
if (isEnvMappingLine(m.line)) continue;
|
|
3150
|
+
matches.push(m);
|
|
3151
|
+
}
|
|
2307
3152
|
}
|
|
2308
3153
|
return matches;
|
|
2309
3154
|
}
|
|
@@ -2351,6 +3196,15 @@ var corsServerless = {
|
|
|
2351
3196
|
() => "Replace wildcard CORS with specific origins: allowOrigin: ['https://yourdomain.com']. Wildcard allows any site to call your API."
|
|
2352
3197
|
));
|
|
2353
3198
|
}
|
|
3199
|
+
if (/^\s*origin\s*:\s*['"]\*['"]/m.test(content)) {
|
|
3200
|
+
matches.push(...findMatches(
|
|
3201
|
+
content,
|
|
3202
|
+
/^\s*origin\s*:\s*['"]\*['"]/gim,
|
|
3203
|
+
corsServerless,
|
|
3204
|
+
filePath,
|
|
3205
|
+
() => "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."
|
|
3206
|
+
));
|
|
3207
|
+
}
|
|
2354
3208
|
return matches;
|
|
2355
3209
|
}
|
|
2356
3210
|
};
|
|
@@ -2512,27 +3366,55 @@ var xxeVulnerability = {
|
|
|
2512
3366
|
category: "Injection",
|
|
2513
3367
|
description: "XML parsers that process external entities allow attackers to read files, perform SSRF, or cause DoS via billion-laughs attacks.",
|
|
2514
3368
|
check(content, filePath) {
|
|
2515
|
-
if (!/xml|parseXML|DOMParser|SAXParser|etree|lxml/i.test(content)) return [];
|
|
3369
|
+
if (!/xml|parseXml|parseXML|DOMParser|SAXParser|etree|lxml|libxml/i.test(content)) return [];
|
|
2516
3370
|
const matches = [];
|
|
2517
3371
|
const patterns = [
|
|
2518
|
-
/\.
|
|
3372
|
+
/\.parseXm?l\s*\(/gi,
|
|
3373
|
+
// catches parseXml (libxmljs) AND parseXML
|
|
2519
3374
|
/new\s+DOMParser\s*\(\)/g,
|
|
2520
3375
|
/etree\.parse\s*\(/g,
|
|
2521
3376
|
/lxml\.etree/g,
|
|
2522
3377
|
/SAXParserFactory/g,
|
|
2523
3378
|
/XMLReaderFactory/g
|
|
2524
3379
|
];
|
|
2525
|
-
const hasProtection = /noent
|
|
2526
|
-
if (hasProtection)
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
3380
|
+
const hasProtection = /noent\s*:\s*false|resolveExternals\s*:\s*false|FEATURE_EXTERNAL.*false|defusedxml|disallow-doctype-decl|nonet\s*:\s*true/i.test(content);
|
|
3381
|
+
if (!hasProtection) {
|
|
3382
|
+
for (const p of patterns) {
|
|
3383
|
+
matches.push(...findMatches(
|
|
3384
|
+
content,
|
|
3385
|
+
p,
|
|
3386
|
+
xxeVulnerability,
|
|
3387
|
+
filePath,
|
|
3388
|
+
() => "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)."
|
|
3389
|
+
));
|
|
3390
|
+
}
|
|
2535
3391
|
}
|
|
3392
|
+
if (!/parseXml\s*\(/.test(content)) return matches;
|
|
3393
|
+
const ctx = tryParse(content, filePath);
|
|
3394
|
+
if (!ctx) return matches;
|
|
3395
|
+
visitCalls(
|
|
3396
|
+
ctx.parsed,
|
|
3397
|
+
(callee) => isCalleeNamed(callee, "parseXml") || isCalleeNamed(callee, "parseXML"),
|
|
3398
|
+
(call, line) => {
|
|
3399
|
+
const opts = call.arguments[1];
|
|
3400
|
+
if (!opts || opts.type !== "ObjectExpression") return;
|
|
3401
|
+
const noent = getObjectProperty(opts, "noent");
|
|
3402
|
+
const dtdload = getObjectProperty(opts, "dtdload");
|
|
3403
|
+
const external = getObjectProperty(opts, "resolveExternals");
|
|
3404
|
+
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;
|
|
3405
|
+
if (!dangerous) return;
|
|
3406
|
+
if (matches.some((m) => m.line === line)) return;
|
|
3407
|
+
matches.push(
|
|
3408
|
+
astMatch(
|
|
3409
|
+
content,
|
|
3410
|
+
filePath,
|
|
3411
|
+
line,
|
|
3412
|
+
xxeVulnerability,
|
|
3413
|
+
"Set noent: false, dtdload: false, and nonet: true on the parser options to disable external-entity resolution."
|
|
3414
|
+
)
|
|
3415
|
+
);
|
|
3416
|
+
}
|
|
3417
|
+
);
|
|
2536
3418
|
return matches;
|
|
2537
3419
|
}
|
|
2538
3420
|
};
|
|
@@ -2561,6 +3443,46 @@ var ssti = {
|
|
|
2561
3443
|
() => "Never render templates from user input. Use pre-defined templates and pass data as context variables: render_template('template.html', data=user_data)."
|
|
2562
3444
|
));
|
|
2563
3445
|
}
|
|
3446
|
+
if (!/(?:\.compile|\.render|renderString|render_template_string)\s*\(/.test(content)) {
|
|
3447
|
+
return matches;
|
|
3448
|
+
}
|
|
3449
|
+
const ctx = tryParse(content, filePath);
|
|
3450
|
+
if (!ctx) return matches;
|
|
3451
|
+
const { parsed, taint } = ctx;
|
|
3452
|
+
const SSTI_METHODS = /* @__PURE__ */ new Set([
|
|
3453
|
+
"compile",
|
|
3454
|
+
// Handlebars.compile, pug.compile, _.template (returns a function)
|
|
3455
|
+
"render",
|
|
3456
|
+
// ejs.render, engine.render, mustache.render
|
|
3457
|
+
"renderString",
|
|
3458
|
+
// nunjucks.renderString
|
|
3459
|
+
"render_template_string"
|
|
3460
|
+
]);
|
|
3461
|
+
visitCalls(
|
|
3462
|
+
parsed,
|
|
3463
|
+
(callee) => {
|
|
3464
|
+
if (callee.type === "Identifier" && SSTI_METHODS.has(callee.name)) return true;
|
|
3465
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
3466
|
+
return SSTI_METHODS.has(callee.property.name);
|
|
3467
|
+
}
|
|
3468
|
+
return false;
|
|
3469
|
+
},
|
|
3470
|
+
(call, line) => {
|
|
3471
|
+
const first = call.arguments[0];
|
|
3472
|
+
if (!first || first.type === "SpreadElement") return;
|
|
3473
|
+
if (!taint.isTainted(first)) return;
|
|
3474
|
+
if (matches.some((m) => m.line === line)) return;
|
|
3475
|
+
matches.push(
|
|
3476
|
+
astMatch(
|
|
3477
|
+
content,
|
|
3478
|
+
filePath,
|
|
3479
|
+
line,
|
|
3480
|
+
ssti,
|
|
3481
|
+
"Compile templates from a trusted, static source (a file path or a constant string). Pass user data only as context values."
|
|
3482
|
+
)
|
|
3483
|
+
);
|
|
3484
|
+
}
|
|
3485
|
+
);
|
|
2564
3486
|
return matches;
|
|
2565
3487
|
}
|
|
2566
3488
|
};
|
|
@@ -2630,7 +3552,7 @@ var exposedAdminRoutes = {
|
|
|
2630
3552
|
category: "Information Leakage",
|
|
2631
3553
|
description: "Routes like /admin, /debug, /phpinfo, or /actuator without authentication expose sensitive controls and information to attackers.",
|
|
2632
3554
|
check(content, filePath) {
|
|
2633
|
-
if (
|
|
3555
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2634
3556
|
const matches = [];
|
|
2635
3557
|
const patterns = [
|
|
2636
3558
|
/[.'"]\s*(?:get|use|all)\s*\(\s*["'`]\/(?:admin|debug|_debug|__debug__|phpinfo|actuator|graphiql|playground|swagger|reset|seed|test|dev|mock)["'`]/gi
|
|
@@ -2697,9 +3619,10 @@ var sensitiveURLParams = {
|
|
|
2697
3619
|
if (isTestFile(filePath)) return [];
|
|
2698
3620
|
const matches = [];
|
|
2699
3621
|
const patterns = [
|
|
2700
|
-
//
|
|
2701
|
-
/[
|
|
2702
|
-
|
|
3622
|
+
// Template literals embedding sensitive values in URL query strings
|
|
3623
|
+
/[?&](?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|session(?:[_-]?id)?|token|jwt|ssn|credit[_-]?card)\s*=\s*\$\{/gi,
|
|
3624
|
+
// String concat building the same
|
|
3625
|
+
/[?&](?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|session(?:[_-]?id)?|token|jwt|ssn|credit[_-]?card)\s*=["']\s*\+/gi
|
|
2703
3626
|
];
|
|
2704
3627
|
for (const p of patterns) {
|
|
2705
3628
|
matches.push(...findMatches(
|
|
@@ -2721,7 +3644,7 @@ var missingContentDisposition = {
|
|
|
2721
3644
|
description: "File download endpoints without Content-Disposition headers may render files inline, leading to XSS if the file contains HTML/JS.",
|
|
2722
3645
|
check(content, filePath) {
|
|
2723
3646
|
if (!/(?:download|sendFile|send_file|pipe|createReadStream)/i.test(content)) return [];
|
|
2724
|
-
if (
|
|
3647
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2725
3648
|
if (/Content-Disposition|attachment|download/i.test(content)) return [];
|
|
2726
3649
|
return findMatches(
|
|
2727
3650
|
content,
|
|
@@ -2763,9 +3686,13 @@ var raceCondition = {
|
|
|
2763
3686
|
if (filePath.includes("test") || filePath.includes("mock")) return [];
|
|
2764
3687
|
const matches = [];
|
|
2765
3688
|
const patterns = [
|
|
2766
|
-
/
|
|
2767
|
-
/
|
|
2768
|
-
|
|
3689
|
+
// Check-then-act with writes / deletes / renames
|
|
3690
|
+
/(?:existsSync|exists)\s*\([^)]+\)[\s\S]{0,80}(?:writeFileSync|writeFile|unlinkSync|unlink|renameSync|rename)\s*\(/g,
|
|
3691
|
+
// Check-then-read — also vulnerable: an attacker can swap the symlink
|
|
3692
|
+
// between the exists/stat call and the read.
|
|
3693
|
+
/(?:existsSync|statSync)\s*\([^)]+\)[\s\S]{0,80}(?:readFileSync|readFile|createReadStream)\s*\(/g,
|
|
3694
|
+
/os\.path\.exists\s*\([^)]+\)[\s\S]{0,80}open\s*\(/g,
|
|
3695
|
+
/File\.exists\?\s*\([^)]+\)[\s\S]{0,80}File\.(?:write|delete|read)/g
|
|
2769
3696
|
];
|
|
2770
3697
|
for (const p of patterns) {
|
|
2771
3698
|
matches.push(...findMatches(
|
|
@@ -2814,7 +3741,7 @@ var unprotectedDownload = {
|
|
|
2814
3741
|
description: "File download endpoints that accept user-controlled filenames without path validation allow directory traversal to read arbitrary files.",
|
|
2815
3742
|
check(content, filePath) {
|
|
2816
3743
|
if (!/(?:download|sendFile|send_file)/i.test(content)) return [];
|
|
2817
|
-
if (
|
|
3744
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2818
3745
|
const matches = [];
|
|
2819
3746
|
if (/(?:sendFile|download|send_file)\s*\([^)]*(?:req\.|params\.|query\.|body\.)/i.test(content)) {
|
|
2820
3747
|
const hasValidation = /path\.resolve|path\.normalize|path\.join.*__dirname|realpath|includes\s*\(\s*["']\.\./i.test(content);
|
|
@@ -5409,7 +6336,9 @@ export {
|
|
|
5409
6336
|
allRules,
|
|
5410
6337
|
androidDebuggable,
|
|
5411
6338
|
blockingMainThread,
|
|
6339
|
+
buildTaintMap,
|
|
5412
6340
|
calculateGrade,
|
|
6341
|
+
callSpreads,
|
|
5413
6342
|
callbackHell,
|
|
5414
6343
|
clickjacking,
|
|
5415
6344
|
clientComponentSecret,
|
|
@@ -5450,6 +6379,7 @@ export {
|
|
|
5450
6379
|
firebaseClientConfig,
|
|
5451
6380
|
flaskSecretKey,
|
|
5452
6381
|
freeRules,
|
|
6382
|
+
getObjectProperty,
|
|
5453
6383
|
getSnippet,
|
|
5454
6384
|
githubActionsInjection,
|
|
5455
6385
|
graphqlIntrospection,
|
|
@@ -5486,6 +6416,8 @@ export {
|
|
|
5486
6416
|
insecureRandomness,
|
|
5487
6417
|
insecureWebSocket,
|
|
5488
6418
|
ipcPathTraversal,
|
|
6419
|
+
isCalleeNamed,
|
|
6420
|
+
isMethodCall,
|
|
5489
6421
|
javaDeserialization,
|
|
5490
6422
|
jwtAlgConfusion,
|
|
5491
6423
|
k8sNoResourceLimits,
|
|
@@ -5523,6 +6455,7 @@ export {
|
|
|
5523
6455
|
nosqlInjection,
|
|
5524
6456
|
openRedirectParams,
|
|
5525
6457
|
overlyPermissiveIAM,
|
|
6458
|
+
parseFile,
|
|
5526
6459
|
pathTraversal,
|
|
5527
6460
|
pickleDeserialization,
|
|
5528
6461
|
piiInLogs,
|
|
@@ -5566,6 +6499,8 @@ export {
|
|
|
5566
6499
|
unvalidatedAPIParams,
|
|
5567
6500
|
unvalidatedEventData,
|
|
5568
6501
|
unvalidatedRedirect,
|
|
6502
|
+
visitBinary,
|
|
6503
|
+
visitCalls,
|
|
5569
6504
|
vulnerableDependencies,
|
|
5570
6505
|
weakHashing,
|
|
5571
6506
|
weakPasswordRequirements,
|