prodlint 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +583 -9
- package/dist/index.js +583 -9
- package/dist/mcp.js +583 -9
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -240,6 +240,81 @@ function findLoopsAST(ast) {
|
|
|
240
240
|
});
|
|
241
241
|
return loops;
|
|
242
242
|
}
|
|
243
|
+
function isUserInputNode(node) {
|
|
244
|
+
if (node.type === "MemberExpression") {
|
|
245
|
+
const mem = node;
|
|
246
|
+
if (mem.object.type === "MemberExpression") {
|
|
247
|
+
const inner = mem.object;
|
|
248
|
+
if (inner.object.type === "Identifier") {
|
|
249
|
+
const objName = inner.object.name;
|
|
250
|
+
if (objName === "req" || objName === "request") {
|
|
251
|
+
if (inner.property.type === "Identifier") {
|
|
252
|
+
const prop = inner.property.name;
|
|
253
|
+
if (prop === "query" || prop === "body" || prop === "params") return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (node.type === "CallExpression") {
|
|
260
|
+
const call = node;
|
|
261
|
+
if (call.callee.type === "MemberExpression") {
|
|
262
|
+
const callee = call.callee;
|
|
263
|
+
if (callee.property.type === "Identifier" && callee.property.name === "get") {
|
|
264
|
+
if (callee.object.type === "Identifier") {
|
|
265
|
+
const name = callee.object.name;
|
|
266
|
+
if (name === "searchParams" || name === "formData") return true;
|
|
267
|
+
}
|
|
268
|
+
if (callee.object.type === "MemberExpression") {
|
|
269
|
+
const inner = callee.object;
|
|
270
|
+
if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
function isStaticString(node) {
|
|
280
|
+
if (node.type === "StringLiteral") return true;
|
|
281
|
+
if (node.type === "TemplateLiteral") {
|
|
282
|
+
return node.expressions.length === 0;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
function findUseEffectRanges(ast) {
|
|
287
|
+
const ranges = [];
|
|
288
|
+
walkAST(ast.program, (node) => {
|
|
289
|
+
if (node.type !== "CallExpression") return;
|
|
290
|
+
const call = node;
|
|
291
|
+
if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
|
|
292
|
+
const callback = call.arguments[0];
|
|
293
|
+
if (!callback || !callback.loc) return;
|
|
294
|
+
ranges.push({
|
|
295
|
+
start: callback.loc.start.line - 1,
|
|
296
|
+
end: callback.loc.end.line - 1
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
return ranges;
|
|
300
|
+
}
|
|
301
|
+
function subtreeContains(node, predicate) {
|
|
302
|
+
if (predicate(node)) return true;
|
|
303
|
+
for (const key of Object.keys(node)) {
|
|
304
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
305
|
+
const val = node[key];
|
|
306
|
+
if (Array.isArray(val)) {
|
|
307
|
+
for (const item of val) {
|
|
308
|
+
if (item && typeof item === "object" && item.type) {
|
|
309
|
+
if (subtreeContains(item, predicate)) return true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
313
|
+
if (subtreeContains(val, predicate)) return true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
243
318
|
|
|
244
319
|
// src/utils/frameworks.ts
|
|
245
320
|
var FRAMEWORK_SAFE_METHODS = {
|
|
@@ -1142,6 +1217,81 @@ var unsafeHtmlRule = {
|
|
|
1142
1217
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1143
1218
|
check(file, _project) {
|
|
1144
1219
|
const findings = [];
|
|
1220
|
+
if (file.ast) {
|
|
1221
|
+
try {
|
|
1222
|
+
walkAST(file.ast.program, (node) => {
|
|
1223
|
+
if (node.type === "JSXAttribute") {
|
|
1224
|
+
const attr = node;
|
|
1225
|
+
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1226
|
+
if (attr.value && subtreeContains(attr.value, (n) => {
|
|
1227
|
+
if (n.type !== "CallExpression") return false;
|
|
1228
|
+
const call = n;
|
|
1229
|
+
if (call.callee.type === "MemberExpression") {
|
|
1230
|
+
const mem = call.callee;
|
|
1231
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1232
|
+
}
|
|
1233
|
+
return false;
|
|
1234
|
+
})) {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const line = attr.loc.start.line;
|
|
1238
|
+
findings.push({
|
|
1239
|
+
ruleId: "unsafe-html",
|
|
1240
|
+
file: file.relativePath,
|
|
1241
|
+
line,
|
|
1242
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1243
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1244
|
+
severity: "critical",
|
|
1245
|
+
category: "security"
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (node.type === "ObjectProperty") {
|
|
1250
|
+
const prop = node;
|
|
1251
|
+
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1252
|
+
if (prop.value && subtreeContains(prop.value, (n) => {
|
|
1253
|
+
if (n.type !== "CallExpression") return false;
|
|
1254
|
+
const call = n;
|
|
1255
|
+
if (call.callee.type === "MemberExpression") {
|
|
1256
|
+
const mem = call.callee;
|
|
1257
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1258
|
+
}
|
|
1259
|
+
return false;
|
|
1260
|
+
})) {
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
const line = prop.loc.start.line;
|
|
1264
|
+
findings.push({
|
|
1265
|
+
ruleId: "unsafe-html",
|
|
1266
|
+
file: file.relativePath,
|
|
1267
|
+
line,
|
|
1268
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1269
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1270
|
+
severity: "critical",
|
|
1271
|
+
category: "security"
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (node.type === "AssignmentExpression") {
|
|
1276
|
+
const assign = node;
|
|
1277
|
+
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1278
|
+
const line = assign.loc.start.line;
|
|
1279
|
+
findings.push({
|
|
1280
|
+
ruleId: "unsafe-html",
|
|
1281
|
+
file: file.relativePath,
|
|
1282
|
+
line,
|
|
1283
|
+
column: file.lines[line - 1].indexOf(".innerHTML") + 1,
|
|
1284
|
+
message: "Direct innerHTML assignment is an XSS risk",
|
|
1285
|
+
severity: "critical",
|
|
1286
|
+
category: "security"
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
return findings;
|
|
1292
|
+
} catch {
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1145
1295
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1146
1296
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1147
1297
|
const line = file.lines[i];
|
|
@@ -1395,6 +1545,22 @@ var DIRECT_INPUT_PATTERNS = [
|
|
|
1395
1545
|
var WARNING_PATTERNS = [
|
|
1396
1546
|
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1397
1547
|
];
|
|
1548
|
+
var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
|
|
1549
|
+
"url",
|
|
1550
|
+
"returnUrl",
|
|
1551
|
+
"returnTo",
|
|
1552
|
+
"redirectUrl",
|
|
1553
|
+
"redirectTo",
|
|
1554
|
+
"next",
|
|
1555
|
+
"callbackUrl",
|
|
1556
|
+
"destination",
|
|
1557
|
+
"redirect",
|
|
1558
|
+
"goto",
|
|
1559
|
+
"to",
|
|
1560
|
+
"target",
|
|
1561
|
+
"uri",
|
|
1562
|
+
"href"
|
|
1563
|
+
]);
|
|
1398
1564
|
var openRedirectRule = {
|
|
1399
1565
|
id: "open-redirect",
|
|
1400
1566
|
name: "Open Redirect",
|
|
@@ -1404,6 +1570,62 @@ var openRedirectRule = {
|
|
|
1404
1570
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1405
1571
|
check(file, _project) {
|
|
1406
1572
|
const findings = [];
|
|
1573
|
+
if (file.ast) {
|
|
1574
|
+
try {
|
|
1575
|
+
walkAST(file.ast.program, (node) => {
|
|
1576
|
+
if (node.type !== "CallExpression") return;
|
|
1577
|
+
const call = node;
|
|
1578
|
+
if (!call.loc) return;
|
|
1579
|
+
let isRedirect = false;
|
|
1580
|
+
if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
|
|
1581
|
+
isRedirect = true;
|
|
1582
|
+
}
|
|
1583
|
+
if (call.callee.type === "MemberExpression") {
|
|
1584
|
+
const mem = call.callee;
|
|
1585
|
+
if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
|
|
1586
|
+
isRedirect = true;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
if (!isRedirect) return;
|
|
1590
|
+
const arg = call.arguments[0];
|
|
1591
|
+
if (!arg) return;
|
|
1592
|
+
if (isStaticString(arg)) return;
|
|
1593
|
+
let targetArg = arg;
|
|
1594
|
+
if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
|
|
1595
|
+
targetArg = arg.arguments[0];
|
|
1596
|
+
if (!targetArg) return;
|
|
1597
|
+
if (isStaticString(targetArg)) return;
|
|
1598
|
+
}
|
|
1599
|
+
const lineNum = call.loc.start.line;
|
|
1600
|
+
const col = call.loc.start.column + 1;
|
|
1601
|
+
if (isUserInputNode(targetArg)) {
|
|
1602
|
+
findings.push({
|
|
1603
|
+
ruleId: "open-redirect",
|
|
1604
|
+
file: file.relativePath,
|
|
1605
|
+
line: lineNum,
|
|
1606
|
+
column: col,
|
|
1607
|
+
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1608
|
+
severity: "warning",
|
|
1609
|
+
category: "security"
|
|
1610
|
+
});
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
|
|
1614
|
+
findings.push({
|
|
1615
|
+
ruleId: "open-redirect",
|
|
1616
|
+
file: file.relativePath,
|
|
1617
|
+
line: lineNum,
|
|
1618
|
+
column: col,
|
|
1619
|
+
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1620
|
+
severity: "warning",
|
|
1621
|
+
category: "security"
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
return findings;
|
|
1626
|
+
} catch {
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1407
1629
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1408
1630
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1409
1631
|
const line = file.lines[i];
|
|
@@ -2051,6 +2273,34 @@ var shallowCatchRule = {
|
|
|
2051
2273
|
if (isTestFile(file.relativePath)) return [];
|
|
2052
2274
|
if (isScriptFile(file.relativePath)) return [];
|
|
2053
2275
|
const findings = [];
|
|
2276
|
+
if (file.ast) {
|
|
2277
|
+
try {
|
|
2278
|
+
walkAST(file.ast.program, (node) => {
|
|
2279
|
+
if (node.type !== "CatchClause") return;
|
|
2280
|
+
if (!node.loc) return;
|
|
2281
|
+
const catchLine = node.loc.start.line - 1;
|
|
2282
|
+
const body = node.body;
|
|
2283
|
+
if (!body || !body.loc) return;
|
|
2284
|
+
const bodyStart = body.loc.start.line - 1;
|
|
2285
|
+
const bodyEnd = body.loc.end.line - 1;
|
|
2286
|
+
const bodyLines = file.lines.slice(bodyStart + 1, bodyEnd);
|
|
2287
|
+
const { score, label } = scoreCatchBody(bodyLines);
|
|
2288
|
+
if (score <= 1) {
|
|
2289
|
+
findings.push({
|
|
2290
|
+
ruleId: "shallow-catch",
|
|
2291
|
+
file: file.relativePath,
|
|
2292
|
+
line: catchLine + 1,
|
|
2293
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2294
|
+
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2295
|
+
severity: score === 0 ? "warning" : "info",
|
|
2296
|
+
category: "reliability"
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2300
|
+
return findings;
|
|
2301
|
+
} catch {
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2054
2304
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2055
2305
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2056
2306
|
const trimmed = file.lines[i].trim();
|
|
@@ -2384,6 +2634,7 @@ var insecureCookieRule = {
|
|
|
2384
2634
|
|
|
2385
2635
|
// src/rules/leaked-env-in-logs.ts
|
|
2386
2636
|
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2637
|
+
var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
|
|
2387
2638
|
var leakedEnvInLogsRule = {
|
|
2388
2639
|
id: "leaked-env-in-logs",
|
|
2389
2640
|
name: "Leaked Env in Logs",
|
|
@@ -2395,6 +2646,48 @@ var leakedEnvInLogsRule = {
|
|
|
2395
2646
|
if (isTestFile(file.relativePath)) return [];
|
|
2396
2647
|
if (isScriptFile(file.relativePath)) return [];
|
|
2397
2648
|
const findings = [];
|
|
2649
|
+
if (file.ast) {
|
|
2650
|
+
try {
|
|
2651
|
+
walkAST(file.ast.program, (node) => {
|
|
2652
|
+
if (node.type !== "CallExpression") return;
|
|
2653
|
+
const call = node;
|
|
2654
|
+
if (!call.loc) return;
|
|
2655
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2656
|
+
const mem = call.callee;
|
|
2657
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
|
|
2658
|
+
if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
|
|
2659
|
+
let hasEnvAccess = false;
|
|
2660
|
+
for (const arg of call.arguments) {
|
|
2661
|
+
if (subtreeContains(arg, (n) => {
|
|
2662
|
+
if (n.type !== "MemberExpression") return false;
|
|
2663
|
+
const m = n;
|
|
2664
|
+
if (m.object.type === "MemberExpression") {
|
|
2665
|
+
const inner = m.object;
|
|
2666
|
+
return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
|
|
2667
|
+
}
|
|
2668
|
+
return false;
|
|
2669
|
+
})) {
|
|
2670
|
+
hasEnvAccess = true;
|
|
2671
|
+
break;
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
if (hasEnvAccess) {
|
|
2675
|
+
findings.push({
|
|
2676
|
+
ruleId: "leaked-env-in-logs",
|
|
2677
|
+
file: file.relativePath,
|
|
2678
|
+
line: call.loc.start.line,
|
|
2679
|
+
column: call.loc.start.column + 1,
|
|
2680
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2681
|
+
severity: "warning",
|
|
2682
|
+
category: "security",
|
|
2683
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
});
|
|
2687
|
+
return findings;
|
|
2688
|
+
} catch {
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2398
2691
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2399
2692
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2400
2693
|
const line = file.lines[i];
|
|
@@ -2499,6 +2792,15 @@ var nextServerActionValidationRule = {
|
|
|
2499
2792
|
// src/rules/missing-transaction.ts
|
|
2500
2793
|
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2501
2794
|
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2795
|
+
var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
|
|
2796
|
+
"create",
|
|
2797
|
+
"update",
|
|
2798
|
+
"delete",
|
|
2799
|
+
"upsert",
|
|
2800
|
+
"createMany",
|
|
2801
|
+
"updateMany",
|
|
2802
|
+
"deleteMany"
|
|
2803
|
+
]);
|
|
2502
2804
|
var missingTransactionRule = {
|
|
2503
2805
|
id: "missing-transaction",
|
|
2504
2806
|
name: "Missing Transaction",
|
|
@@ -2510,6 +2812,60 @@ var missingTransactionRule = {
|
|
|
2510
2812
|
if (isTestFile(file.relativePath)) return [];
|
|
2511
2813
|
if (isScriptFile(file.relativePath)) return [];
|
|
2512
2814
|
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2815
|
+
if (file.ast) {
|
|
2816
|
+
try {
|
|
2817
|
+
const parentMap = /* @__PURE__ */ new Map();
|
|
2818
|
+
walkAST(file.ast.program, (node, parent) => {
|
|
2819
|
+
if (parent) parentMap.set(node, parent);
|
|
2820
|
+
});
|
|
2821
|
+
const writesByScope = /* @__PURE__ */ new Map();
|
|
2822
|
+
const transactionScopes = /* @__PURE__ */ new Set();
|
|
2823
|
+
walkAST(file.ast.program, (node) => {
|
|
2824
|
+
if (node.type !== "CallExpression") return;
|
|
2825
|
+
const call = node;
|
|
2826
|
+
if (!call.loc) return;
|
|
2827
|
+
if (call.callee.type === "MemberExpression") {
|
|
2828
|
+
const mem = call.callee;
|
|
2829
|
+
if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
|
|
2830
|
+
const scope2 = findEnclosingFunction(node, parentMap);
|
|
2831
|
+
transactionScopes.add(scope2);
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2836
|
+
const outerMem = call.callee;
|
|
2837
|
+
if (outerMem.property.type !== "Identifier") return;
|
|
2838
|
+
if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
|
|
2839
|
+
if (outerMem.object.type !== "MemberExpression") return;
|
|
2840
|
+
const innerMem = outerMem.object;
|
|
2841
|
+
if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
|
|
2842
|
+
const scope = findEnclosingFunction(node, parentMap);
|
|
2843
|
+
const existing = writesByScope.get(scope);
|
|
2844
|
+
if (existing) {
|
|
2845
|
+
existing.count++;
|
|
2846
|
+
} else {
|
|
2847
|
+
writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
const findings = [];
|
|
2851
|
+
for (const [scope, { count, firstLine }] of writesByScope) {
|
|
2852
|
+
if (count < 2) continue;
|
|
2853
|
+
if (transactionScopes.has(scope)) continue;
|
|
2854
|
+
findings.push({
|
|
2855
|
+
ruleId: "missing-transaction",
|
|
2856
|
+
file: file.relativePath,
|
|
2857
|
+
line: firstLine,
|
|
2858
|
+
column: 1,
|
|
2859
|
+
message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2860
|
+
severity: "warning",
|
|
2861
|
+
category: "reliability",
|
|
2862
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
return findings;
|
|
2866
|
+
} catch {
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2513
2869
|
let writeCount = 0;
|
|
2514
2870
|
let firstWriteLine = -1;
|
|
2515
2871
|
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
@@ -2533,6 +2889,16 @@ var missingTransactionRule = {
|
|
|
2533
2889
|
}];
|
|
2534
2890
|
}
|
|
2535
2891
|
};
|
|
2892
|
+
function findEnclosingFunction(node, parentMap) {
|
|
2893
|
+
let current = parentMap.get(node);
|
|
2894
|
+
while (current) {
|
|
2895
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
|
|
2896
|
+
return current;
|
|
2897
|
+
}
|
|
2898
|
+
current = parentMap.get(current);
|
|
2899
|
+
}
|
|
2900
|
+
return null;
|
|
2901
|
+
}
|
|
2536
2902
|
|
|
2537
2903
|
// src/rules/redirect-in-try-catch.ts
|
|
2538
2904
|
var redirectInTryCatchRule = {
|
|
@@ -3099,6 +3465,14 @@ var VALIDATION_PATTERNS3 = [
|
|
|
3099
3465
|
/\.startsWith\s*\(\s*['"]https?:\/\//,
|
|
3100
3466
|
/\.hostname\s*[!=]==?\s*['"`]/
|
|
3101
3467
|
];
|
|
3468
|
+
var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
|
|
3469
|
+
"url",
|
|
3470
|
+
"href",
|
|
3471
|
+
"endpoint",
|
|
3472
|
+
"target",
|
|
3473
|
+
"link",
|
|
3474
|
+
"src"
|
|
3475
|
+
]);
|
|
3102
3476
|
var ssrfRiskRule = {
|
|
3103
3477
|
id: "ssrf-risk",
|
|
3104
3478
|
name: "SSRF Risk",
|
|
@@ -3109,9 +3483,63 @@ var ssrfRiskRule = {
|
|
|
3109
3483
|
check(file, _project) {
|
|
3110
3484
|
if (isTestFile(file.relativePath)) return [];
|
|
3111
3485
|
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3486
|
+
const findings = [];
|
|
3487
|
+
if (file.ast) {
|
|
3488
|
+
try {
|
|
3489
|
+
const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3490
|
+
walkAST(file.ast.program, (node) => {
|
|
3491
|
+
if (node.type !== "CallExpression") return;
|
|
3492
|
+
const call = node;
|
|
3493
|
+
if (!call.loc) return;
|
|
3494
|
+
let isFetchLike = false;
|
|
3495
|
+
if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
|
|
3496
|
+
isFetchLike = true;
|
|
3497
|
+
}
|
|
3498
|
+
if (call.callee.type === "MemberExpression") {
|
|
3499
|
+
const mem = call.callee;
|
|
3500
|
+
if (mem.object.type === "Identifier" && mem.object.name === "axios") {
|
|
3501
|
+
isFetchLike = true;
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
if (!isFetchLike) return;
|
|
3505
|
+
const arg = call.arguments[0];
|
|
3506
|
+
if (!arg) return;
|
|
3507
|
+
if (isStaticString(arg)) return;
|
|
3508
|
+
const lineNum = call.loc.start.line;
|
|
3509
|
+
const col = call.loc.start.column + 1;
|
|
3510
|
+
if (isUserInputNode(arg)) {
|
|
3511
|
+
findings.push({
|
|
3512
|
+
ruleId: "ssrf-risk",
|
|
3513
|
+
file: file.relativePath,
|
|
3514
|
+
line: lineNum,
|
|
3515
|
+
column: col,
|
|
3516
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3517
|
+
severity: "warning",
|
|
3518
|
+
category: "security",
|
|
3519
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3520
|
+
});
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
|
|
3524
|
+
if (hasValidationInCode) return;
|
|
3525
|
+
findings.push({
|
|
3526
|
+
ruleId: "ssrf-risk",
|
|
3527
|
+
file: file.relativePath,
|
|
3528
|
+
line: lineNum,
|
|
3529
|
+
column: col,
|
|
3530
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3531
|
+
severity: "warning",
|
|
3532
|
+
category: "security",
|
|
3533
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3534
|
+
});
|
|
3535
|
+
}
|
|
3536
|
+
});
|
|
3537
|
+
return findings;
|
|
3538
|
+
} catch {
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3112
3541
|
const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3113
3542
|
if (hasValidation) return [];
|
|
3114
|
-
const findings = [];
|
|
3115
3543
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3116
3544
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3117
3545
|
const line = file.lines[i];
|
|
@@ -3153,6 +3581,25 @@ var SANITIZATION_PATTERNS = [
|
|
|
3153
3581
|
/sanitize/i,
|
|
3154
3582
|
/realpath/
|
|
3155
3583
|
];
|
|
3584
|
+
var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
|
|
3585
|
+
"readFile",
|
|
3586
|
+
"readFileSync",
|
|
3587
|
+
"createReadStream",
|
|
3588
|
+
"writeFile",
|
|
3589
|
+
"writeFileSync",
|
|
3590
|
+
"createWriteStream",
|
|
3591
|
+
"unlink",
|
|
3592
|
+
"unlinkSync",
|
|
3593
|
+
"rm",
|
|
3594
|
+
"rmSync"
|
|
3595
|
+
]);
|
|
3596
|
+
var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
|
|
3597
|
+
"filePath",
|
|
3598
|
+
"fileName",
|
|
3599
|
+
"path",
|
|
3600
|
+
"file",
|
|
3601
|
+
"name"
|
|
3602
|
+
]);
|
|
3156
3603
|
var pathTraversalRule = {
|
|
3157
3604
|
id: "path-traversal",
|
|
3158
3605
|
name: "Path Traversal",
|
|
@@ -3162,9 +3609,73 @@ var pathTraversalRule = {
|
|
|
3162
3609
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3163
3610
|
check(file, _project) {
|
|
3164
3611
|
if (isTestFile(file.relativePath)) return [];
|
|
3612
|
+
const findings = [];
|
|
3613
|
+
if (file.ast) {
|
|
3614
|
+
try {
|
|
3615
|
+
walkAST(file.ast.program, (node) => {
|
|
3616
|
+
if (node.type !== "CallExpression") return;
|
|
3617
|
+
const call = node;
|
|
3618
|
+
if (!call.loc) return;
|
|
3619
|
+
let fnName = null;
|
|
3620
|
+
if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
|
|
3621
|
+
fnName = call.callee.name;
|
|
3622
|
+
}
|
|
3623
|
+
if (call.callee.type === "MemberExpression") {
|
|
3624
|
+
const mem = call.callee;
|
|
3625
|
+
if (mem.property.type === "Identifier") {
|
|
3626
|
+
if (FS_FUNCTION_NAMES.has(mem.property.name)) {
|
|
3627
|
+
fnName = mem.property.name;
|
|
3628
|
+
}
|
|
3629
|
+
if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
|
|
3630
|
+
fnName = "path.join";
|
|
3631
|
+
}
|
|
3632
|
+
if (mem.property.name === "sendFile") {
|
|
3633
|
+
fnName = "sendFile";
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
if (!fnName) return;
|
|
3638
|
+
const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
|
|
3639
|
+
for (const arg of argsToCheck) {
|
|
3640
|
+
if (!arg) continue;
|
|
3641
|
+
if (isStaticString(arg)) continue;
|
|
3642
|
+
const lineNum = call.loc.start.line;
|
|
3643
|
+
const col = call.loc.start.column + 1;
|
|
3644
|
+
if (isUserInputNode(arg)) {
|
|
3645
|
+
const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
|
|
3646
|
+
findings.push({
|
|
3647
|
+
ruleId: "path-traversal",
|
|
3648
|
+
file: file.relativePath,
|
|
3649
|
+
line: lineNum,
|
|
3650
|
+
column: col,
|
|
3651
|
+
message: fnName === "path.join" ? "path.join with user input \u2014 still vulnerable to traversal with ../" : `File ${action} with user-controlled path \u2014 allows ${action === "send" ? "sending" : action + "ing"} arbitrary files`,
|
|
3652
|
+
severity: "critical",
|
|
3653
|
+
category: "security",
|
|
3654
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3655
|
+
});
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
|
|
3659
|
+
findings.push({
|
|
3660
|
+
ruleId: "path-traversal",
|
|
3661
|
+
file: file.relativePath,
|
|
3662
|
+
line: lineNum,
|
|
3663
|
+
column: col,
|
|
3664
|
+
message: `File operation with potentially user-controlled path \u2014 validate before use`,
|
|
3665
|
+
severity: "warning",
|
|
3666
|
+
category: "security",
|
|
3667
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3668
|
+
});
|
|
3669
|
+
return;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
return findings;
|
|
3674
|
+
} catch {
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3165
3677
|
const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
|
|
3166
3678
|
if (hasSanitization) return [];
|
|
3167
|
-
const findings = [];
|
|
3168
3679
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3169
3680
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3170
3681
|
const line = file.lines[i];
|
|
@@ -3216,18 +3727,31 @@ var hydrationMismatchRule = {
|
|
|
3216
3727
|
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3217
3728
|
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3218
3729
|
const findings = [];
|
|
3730
|
+
let useEffectRanges = [];
|
|
3731
|
+
if (file.ast) {
|
|
3732
|
+
try {
|
|
3733
|
+
useEffectRanges = findUseEffectRanges(file.ast);
|
|
3734
|
+
} catch {
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
|
|
3219
3738
|
let insideUseEffect = false;
|
|
3220
3739
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3221
3740
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3222
3741
|
const line = file.lines[i];
|
|
3223
|
-
if (
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
if (
|
|
3228
|
-
insideUseEffect =
|
|
3742
|
+
if (hasAstRanges) {
|
|
3743
|
+
const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
|
|
3744
|
+
if (inEffect) continue;
|
|
3745
|
+
} else {
|
|
3746
|
+
if (/\buseEffect\s*\(/.test(line)) {
|
|
3747
|
+
insideUseEffect = true;
|
|
3748
|
+
}
|
|
3749
|
+
if (insideUseEffect) {
|
|
3750
|
+
if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
|
|
3751
|
+
insideUseEffect = false;
|
|
3752
|
+
}
|
|
3753
|
+
continue;
|
|
3229
3754
|
}
|
|
3230
|
-
continue;
|
|
3231
3755
|
}
|
|
3232
3756
|
for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
|
|
3233
3757
|
const match = pattern.exec(line);
|
|
@@ -3452,6 +3976,7 @@ var HAS_EXPIRY = [
|
|
|
3452
3976
|
/expirationTime/,
|
|
3453
3977
|
/maxAge/
|
|
3454
3978
|
];
|
|
3979
|
+
var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
|
|
3455
3980
|
var jwtNoExpiryRule = {
|
|
3456
3981
|
id: "jwt-no-expiry",
|
|
3457
3982
|
name: "JWT Without Expiration",
|
|
@@ -3463,6 +3988,55 @@ var jwtNoExpiryRule = {
|
|
|
3463
3988
|
if (isTestFile(file.relativePath)) return [];
|
|
3464
3989
|
if (!JWT_SIGN.test(file.content)) return [];
|
|
3465
3990
|
const findings = [];
|
|
3991
|
+
if (file.ast) {
|
|
3992
|
+
try {
|
|
3993
|
+
walkAST(file.ast.program, (node) => {
|
|
3994
|
+
if (node.type !== "CallExpression") return;
|
|
3995
|
+
const call = node;
|
|
3996
|
+
if (!call.loc) return;
|
|
3997
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
3998
|
+
const mem = call.callee;
|
|
3999
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
|
|
4000
|
+
if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
|
|
4001
|
+
let hasExpiry = false;
|
|
4002
|
+
for (const arg of call.arguments) {
|
|
4003
|
+
if (arg.type === "ObjectExpression") {
|
|
4004
|
+
const obj = arg;
|
|
4005
|
+
for (const prop of obj.properties) {
|
|
4006
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
|
|
4007
|
+
hasExpiry = true;
|
|
4008
|
+
break;
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
if (hasExpiry) break;
|
|
4013
|
+
}
|
|
4014
|
+
if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
|
|
4015
|
+
const payload = call.arguments[0];
|
|
4016
|
+
for (const prop of payload.properties) {
|
|
4017
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
|
|
4018
|
+
hasExpiry = true;
|
|
4019
|
+
break;
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
if (!hasExpiry) {
|
|
4024
|
+
findings.push({
|
|
4025
|
+
ruleId: "jwt-no-expiry",
|
|
4026
|
+
file: file.relativePath,
|
|
4027
|
+
line: call.loc.start.line,
|
|
4028
|
+
column: call.loc.start.column + 1,
|
|
4029
|
+
message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
|
|
4030
|
+
severity: "warning",
|
|
4031
|
+
category: "security",
|
|
4032
|
+
fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
});
|
|
4036
|
+
return findings;
|
|
4037
|
+
} catch {
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
3466
4040
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3467
4041
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3468
4042
|
const line = file.lines[i];
|