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/cli.js
CHANGED
|
@@ -245,6 +245,81 @@ function findLoopsAST(ast) {
|
|
|
245
245
|
});
|
|
246
246
|
return loops;
|
|
247
247
|
}
|
|
248
|
+
function isUserInputNode(node) {
|
|
249
|
+
if (node.type === "MemberExpression") {
|
|
250
|
+
const mem = node;
|
|
251
|
+
if (mem.object.type === "MemberExpression") {
|
|
252
|
+
const inner = mem.object;
|
|
253
|
+
if (inner.object.type === "Identifier") {
|
|
254
|
+
const objName = inner.object.name;
|
|
255
|
+
if (objName === "req" || objName === "request") {
|
|
256
|
+
if (inner.property.type === "Identifier") {
|
|
257
|
+
const prop = inner.property.name;
|
|
258
|
+
if (prop === "query" || prop === "body" || prop === "params") return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (node.type === "CallExpression") {
|
|
265
|
+
const call = node;
|
|
266
|
+
if (call.callee.type === "MemberExpression") {
|
|
267
|
+
const callee = call.callee;
|
|
268
|
+
if (callee.property.type === "Identifier" && callee.property.name === "get") {
|
|
269
|
+
if (callee.object.type === "Identifier") {
|
|
270
|
+
const name = callee.object.name;
|
|
271
|
+
if (name === "searchParams" || name === "formData") return true;
|
|
272
|
+
}
|
|
273
|
+
if (callee.object.type === "MemberExpression") {
|
|
274
|
+
const inner = callee.object;
|
|
275
|
+
if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
function isStaticString(node) {
|
|
285
|
+
if (node.type === "StringLiteral") return true;
|
|
286
|
+
if (node.type === "TemplateLiteral") {
|
|
287
|
+
return node.expressions.length === 0;
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
function findUseEffectRanges(ast) {
|
|
292
|
+
const ranges = [];
|
|
293
|
+
walkAST(ast.program, (node) => {
|
|
294
|
+
if (node.type !== "CallExpression") return;
|
|
295
|
+
const call = node;
|
|
296
|
+
if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
|
|
297
|
+
const callback = call.arguments[0];
|
|
298
|
+
if (!callback || !callback.loc) return;
|
|
299
|
+
ranges.push({
|
|
300
|
+
start: callback.loc.start.line - 1,
|
|
301
|
+
end: callback.loc.end.line - 1
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
return ranges;
|
|
305
|
+
}
|
|
306
|
+
function subtreeContains(node, predicate) {
|
|
307
|
+
if (predicate(node)) return true;
|
|
308
|
+
for (const key of Object.keys(node)) {
|
|
309
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
310
|
+
const val = node[key];
|
|
311
|
+
if (Array.isArray(val)) {
|
|
312
|
+
for (const item of val) {
|
|
313
|
+
if (item && typeof item === "object" && item.type) {
|
|
314
|
+
if (subtreeContains(item, predicate)) return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
318
|
+
if (subtreeContains(val, predicate)) return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
248
323
|
|
|
249
324
|
// src/utils/frameworks.ts
|
|
250
325
|
var FRAMEWORK_SAFE_METHODS = {
|
|
@@ -1147,6 +1222,81 @@ var unsafeHtmlRule = {
|
|
|
1147
1222
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1148
1223
|
check(file, _project) {
|
|
1149
1224
|
const findings = [];
|
|
1225
|
+
if (file.ast) {
|
|
1226
|
+
try {
|
|
1227
|
+
walkAST(file.ast.program, (node) => {
|
|
1228
|
+
if (node.type === "JSXAttribute") {
|
|
1229
|
+
const attr = node;
|
|
1230
|
+
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1231
|
+
if (attr.value && subtreeContains(attr.value, (n) => {
|
|
1232
|
+
if (n.type !== "CallExpression") return false;
|
|
1233
|
+
const call = n;
|
|
1234
|
+
if (call.callee.type === "MemberExpression") {
|
|
1235
|
+
const mem = call.callee;
|
|
1236
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1237
|
+
}
|
|
1238
|
+
return false;
|
|
1239
|
+
})) {
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const line = attr.loc.start.line;
|
|
1243
|
+
findings.push({
|
|
1244
|
+
ruleId: "unsafe-html",
|
|
1245
|
+
file: file.relativePath,
|
|
1246
|
+
line,
|
|
1247
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1248
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1249
|
+
severity: "critical",
|
|
1250
|
+
category: "security"
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (node.type === "ObjectProperty") {
|
|
1255
|
+
const prop = node;
|
|
1256
|
+
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1257
|
+
if (prop.value && subtreeContains(prop.value, (n) => {
|
|
1258
|
+
if (n.type !== "CallExpression") return false;
|
|
1259
|
+
const call = n;
|
|
1260
|
+
if (call.callee.type === "MemberExpression") {
|
|
1261
|
+
const mem = call.callee;
|
|
1262
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1263
|
+
}
|
|
1264
|
+
return false;
|
|
1265
|
+
})) {
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const line = prop.loc.start.line;
|
|
1269
|
+
findings.push({
|
|
1270
|
+
ruleId: "unsafe-html",
|
|
1271
|
+
file: file.relativePath,
|
|
1272
|
+
line,
|
|
1273
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1274
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1275
|
+
severity: "critical",
|
|
1276
|
+
category: "security"
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (node.type === "AssignmentExpression") {
|
|
1281
|
+
const assign = node;
|
|
1282
|
+
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1283
|
+
const line = assign.loc.start.line;
|
|
1284
|
+
findings.push({
|
|
1285
|
+
ruleId: "unsafe-html",
|
|
1286
|
+
file: file.relativePath,
|
|
1287
|
+
line,
|
|
1288
|
+
column: file.lines[line - 1].indexOf(".innerHTML") + 1,
|
|
1289
|
+
message: "Direct innerHTML assignment is an XSS risk",
|
|
1290
|
+
severity: "critical",
|
|
1291
|
+
category: "security"
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
return findings;
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1150
1300
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1151
1301
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1152
1302
|
const line = file.lines[i];
|
|
@@ -1400,6 +1550,22 @@ var DIRECT_INPUT_PATTERNS = [
|
|
|
1400
1550
|
var WARNING_PATTERNS = [
|
|
1401
1551
|
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1402
1552
|
];
|
|
1553
|
+
var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
|
|
1554
|
+
"url",
|
|
1555
|
+
"returnUrl",
|
|
1556
|
+
"returnTo",
|
|
1557
|
+
"redirectUrl",
|
|
1558
|
+
"redirectTo",
|
|
1559
|
+
"next",
|
|
1560
|
+
"callbackUrl",
|
|
1561
|
+
"destination",
|
|
1562
|
+
"redirect",
|
|
1563
|
+
"goto",
|
|
1564
|
+
"to",
|
|
1565
|
+
"target",
|
|
1566
|
+
"uri",
|
|
1567
|
+
"href"
|
|
1568
|
+
]);
|
|
1403
1569
|
var openRedirectRule = {
|
|
1404
1570
|
id: "open-redirect",
|
|
1405
1571
|
name: "Open Redirect",
|
|
@@ -1409,6 +1575,62 @@ var openRedirectRule = {
|
|
|
1409
1575
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1410
1576
|
check(file, _project) {
|
|
1411
1577
|
const findings = [];
|
|
1578
|
+
if (file.ast) {
|
|
1579
|
+
try {
|
|
1580
|
+
walkAST(file.ast.program, (node) => {
|
|
1581
|
+
if (node.type !== "CallExpression") return;
|
|
1582
|
+
const call = node;
|
|
1583
|
+
if (!call.loc) return;
|
|
1584
|
+
let isRedirect = false;
|
|
1585
|
+
if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
|
|
1586
|
+
isRedirect = true;
|
|
1587
|
+
}
|
|
1588
|
+
if (call.callee.type === "MemberExpression") {
|
|
1589
|
+
const mem = call.callee;
|
|
1590
|
+
if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
|
|
1591
|
+
isRedirect = true;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
if (!isRedirect) return;
|
|
1595
|
+
const arg = call.arguments[0];
|
|
1596
|
+
if (!arg) return;
|
|
1597
|
+
if (isStaticString(arg)) return;
|
|
1598
|
+
let targetArg = arg;
|
|
1599
|
+
if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
|
|
1600
|
+
targetArg = arg.arguments[0];
|
|
1601
|
+
if (!targetArg) return;
|
|
1602
|
+
if (isStaticString(targetArg)) return;
|
|
1603
|
+
}
|
|
1604
|
+
const lineNum = call.loc.start.line;
|
|
1605
|
+
const col = call.loc.start.column + 1;
|
|
1606
|
+
if (isUserInputNode(targetArg)) {
|
|
1607
|
+
findings.push({
|
|
1608
|
+
ruleId: "open-redirect",
|
|
1609
|
+
file: file.relativePath,
|
|
1610
|
+
line: lineNum,
|
|
1611
|
+
column: col,
|
|
1612
|
+
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1613
|
+
severity: "warning",
|
|
1614
|
+
category: "security"
|
|
1615
|
+
});
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
|
|
1619
|
+
findings.push({
|
|
1620
|
+
ruleId: "open-redirect",
|
|
1621
|
+
file: file.relativePath,
|
|
1622
|
+
line: lineNum,
|
|
1623
|
+
column: col,
|
|
1624
|
+
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1625
|
+
severity: "warning",
|
|
1626
|
+
category: "security"
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
return findings;
|
|
1631
|
+
} catch {
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1412
1634
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1413
1635
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1414
1636
|
const line = file.lines[i];
|
|
@@ -2056,6 +2278,34 @@ var shallowCatchRule = {
|
|
|
2056
2278
|
if (isTestFile(file.relativePath)) return [];
|
|
2057
2279
|
if (isScriptFile(file.relativePath)) return [];
|
|
2058
2280
|
const findings = [];
|
|
2281
|
+
if (file.ast) {
|
|
2282
|
+
try {
|
|
2283
|
+
walkAST(file.ast.program, (node) => {
|
|
2284
|
+
if (node.type !== "CatchClause") return;
|
|
2285
|
+
if (!node.loc) return;
|
|
2286
|
+
const catchLine = node.loc.start.line - 1;
|
|
2287
|
+
const body = node.body;
|
|
2288
|
+
if (!body || !body.loc) return;
|
|
2289
|
+
const bodyStart = body.loc.start.line - 1;
|
|
2290
|
+
const bodyEnd = body.loc.end.line - 1;
|
|
2291
|
+
const bodyLines = file.lines.slice(bodyStart + 1, bodyEnd);
|
|
2292
|
+
const { score, label } = scoreCatchBody(bodyLines);
|
|
2293
|
+
if (score <= 1) {
|
|
2294
|
+
findings.push({
|
|
2295
|
+
ruleId: "shallow-catch",
|
|
2296
|
+
file: file.relativePath,
|
|
2297
|
+
line: catchLine + 1,
|
|
2298
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2299
|
+
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2300
|
+
severity: score === 0 ? "warning" : "info",
|
|
2301
|
+
category: "reliability"
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
return findings;
|
|
2306
|
+
} catch {
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2059
2309
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2060
2310
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2061
2311
|
const trimmed = file.lines[i].trim();
|
|
@@ -2389,6 +2639,7 @@ var insecureCookieRule = {
|
|
|
2389
2639
|
|
|
2390
2640
|
// src/rules/leaked-env-in-logs.ts
|
|
2391
2641
|
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2642
|
+
var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
|
|
2392
2643
|
var leakedEnvInLogsRule = {
|
|
2393
2644
|
id: "leaked-env-in-logs",
|
|
2394
2645
|
name: "Leaked Env in Logs",
|
|
@@ -2400,6 +2651,48 @@ var leakedEnvInLogsRule = {
|
|
|
2400
2651
|
if (isTestFile(file.relativePath)) return [];
|
|
2401
2652
|
if (isScriptFile(file.relativePath)) return [];
|
|
2402
2653
|
const findings = [];
|
|
2654
|
+
if (file.ast) {
|
|
2655
|
+
try {
|
|
2656
|
+
walkAST(file.ast.program, (node) => {
|
|
2657
|
+
if (node.type !== "CallExpression") return;
|
|
2658
|
+
const call = node;
|
|
2659
|
+
if (!call.loc) return;
|
|
2660
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2661
|
+
const mem = call.callee;
|
|
2662
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
|
|
2663
|
+
if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
|
|
2664
|
+
let hasEnvAccess = false;
|
|
2665
|
+
for (const arg of call.arguments) {
|
|
2666
|
+
if (subtreeContains(arg, (n) => {
|
|
2667
|
+
if (n.type !== "MemberExpression") return false;
|
|
2668
|
+
const m = n;
|
|
2669
|
+
if (m.object.type === "MemberExpression") {
|
|
2670
|
+
const inner = m.object;
|
|
2671
|
+
return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
|
|
2672
|
+
}
|
|
2673
|
+
return false;
|
|
2674
|
+
})) {
|
|
2675
|
+
hasEnvAccess = true;
|
|
2676
|
+
break;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
if (hasEnvAccess) {
|
|
2680
|
+
findings.push({
|
|
2681
|
+
ruleId: "leaked-env-in-logs",
|
|
2682
|
+
file: file.relativePath,
|
|
2683
|
+
line: call.loc.start.line,
|
|
2684
|
+
column: call.loc.start.column + 1,
|
|
2685
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2686
|
+
severity: "warning",
|
|
2687
|
+
category: "security",
|
|
2688
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
});
|
|
2692
|
+
return findings;
|
|
2693
|
+
} catch {
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2403
2696
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2404
2697
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2405
2698
|
const line = file.lines[i];
|
|
@@ -2504,6 +2797,15 @@ var nextServerActionValidationRule = {
|
|
|
2504
2797
|
// src/rules/missing-transaction.ts
|
|
2505
2798
|
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2506
2799
|
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2800
|
+
var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
|
|
2801
|
+
"create",
|
|
2802
|
+
"update",
|
|
2803
|
+
"delete",
|
|
2804
|
+
"upsert",
|
|
2805
|
+
"createMany",
|
|
2806
|
+
"updateMany",
|
|
2807
|
+
"deleteMany"
|
|
2808
|
+
]);
|
|
2507
2809
|
var missingTransactionRule = {
|
|
2508
2810
|
id: "missing-transaction",
|
|
2509
2811
|
name: "Missing Transaction",
|
|
@@ -2515,6 +2817,60 @@ var missingTransactionRule = {
|
|
|
2515
2817
|
if (isTestFile(file.relativePath)) return [];
|
|
2516
2818
|
if (isScriptFile(file.relativePath)) return [];
|
|
2517
2819
|
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2820
|
+
if (file.ast) {
|
|
2821
|
+
try {
|
|
2822
|
+
const parentMap = /* @__PURE__ */ new Map();
|
|
2823
|
+
walkAST(file.ast.program, (node, parent) => {
|
|
2824
|
+
if (parent) parentMap.set(node, parent);
|
|
2825
|
+
});
|
|
2826
|
+
const writesByScope = /* @__PURE__ */ new Map();
|
|
2827
|
+
const transactionScopes = /* @__PURE__ */ new Set();
|
|
2828
|
+
walkAST(file.ast.program, (node) => {
|
|
2829
|
+
if (node.type !== "CallExpression") return;
|
|
2830
|
+
const call = node;
|
|
2831
|
+
if (!call.loc) return;
|
|
2832
|
+
if (call.callee.type === "MemberExpression") {
|
|
2833
|
+
const mem = call.callee;
|
|
2834
|
+
if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
|
|
2835
|
+
const scope2 = findEnclosingFunction(node, parentMap);
|
|
2836
|
+
transactionScopes.add(scope2);
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2841
|
+
const outerMem = call.callee;
|
|
2842
|
+
if (outerMem.property.type !== "Identifier") return;
|
|
2843
|
+
if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
|
|
2844
|
+
if (outerMem.object.type !== "MemberExpression") return;
|
|
2845
|
+
const innerMem = outerMem.object;
|
|
2846
|
+
if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
|
|
2847
|
+
const scope = findEnclosingFunction(node, parentMap);
|
|
2848
|
+
const existing = writesByScope.get(scope);
|
|
2849
|
+
if (existing) {
|
|
2850
|
+
existing.count++;
|
|
2851
|
+
} else {
|
|
2852
|
+
writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
|
|
2853
|
+
}
|
|
2854
|
+
});
|
|
2855
|
+
const findings = [];
|
|
2856
|
+
for (const [scope, { count, firstLine }] of writesByScope) {
|
|
2857
|
+
if (count < 2) continue;
|
|
2858
|
+
if (transactionScopes.has(scope)) continue;
|
|
2859
|
+
findings.push({
|
|
2860
|
+
ruleId: "missing-transaction",
|
|
2861
|
+
file: file.relativePath,
|
|
2862
|
+
line: firstLine,
|
|
2863
|
+
column: 1,
|
|
2864
|
+
message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2865
|
+
severity: "warning",
|
|
2866
|
+
category: "reliability",
|
|
2867
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
return findings;
|
|
2871
|
+
} catch {
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2518
2874
|
let writeCount = 0;
|
|
2519
2875
|
let firstWriteLine = -1;
|
|
2520
2876
|
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
@@ -2538,6 +2894,16 @@ var missingTransactionRule = {
|
|
|
2538
2894
|
}];
|
|
2539
2895
|
}
|
|
2540
2896
|
};
|
|
2897
|
+
function findEnclosingFunction(node, parentMap) {
|
|
2898
|
+
let current = parentMap.get(node);
|
|
2899
|
+
while (current) {
|
|
2900
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
|
|
2901
|
+
return current;
|
|
2902
|
+
}
|
|
2903
|
+
current = parentMap.get(current);
|
|
2904
|
+
}
|
|
2905
|
+
return null;
|
|
2906
|
+
}
|
|
2541
2907
|
|
|
2542
2908
|
// src/rules/redirect-in-try-catch.ts
|
|
2543
2909
|
var redirectInTryCatchRule = {
|
|
@@ -3104,6 +3470,14 @@ var VALIDATION_PATTERNS3 = [
|
|
|
3104
3470
|
/\.startsWith\s*\(\s*['"]https?:\/\//,
|
|
3105
3471
|
/\.hostname\s*[!=]==?\s*['"`]/
|
|
3106
3472
|
];
|
|
3473
|
+
var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
|
|
3474
|
+
"url",
|
|
3475
|
+
"href",
|
|
3476
|
+
"endpoint",
|
|
3477
|
+
"target",
|
|
3478
|
+
"link",
|
|
3479
|
+
"src"
|
|
3480
|
+
]);
|
|
3107
3481
|
var ssrfRiskRule = {
|
|
3108
3482
|
id: "ssrf-risk",
|
|
3109
3483
|
name: "SSRF Risk",
|
|
@@ -3114,9 +3488,63 @@ var ssrfRiskRule = {
|
|
|
3114
3488
|
check(file, _project) {
|
|
3115
3489
|
if (isTestFile(file.relativePath)) return [];
|
|
3116
3490
|
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3491
|
+
const findings = [];
|
|
3492
|
+
if (file.ast) {
|
|
3493
|
+
try {
|
|
3494
|
+
const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3495
|
+
walkAST(file.ast.program, (node) => {
|
|
3496
|
+
if (node.type !== "CallExpression") return;
|
|
3497
|
+
const call = node;
|
|
3498
|
+
if (!call.loc) return;
|
|
3499
|
+
let isFetchLike = false;
|
|
3500
|
+
if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
|
|
3501
|
+
isFetchLike = true;
|
|
3502
|
+
}
|
|
3503
|
+
if (call.callee.type === "MemberExpression") {
|
|
3504
|
+
const mem = call.callee;
|
|
3505
|
+
if (mem.object.type === "Identifier" && mem.object.name === "axios") {
|
|
3506
|
+
isFetchLike = true;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
if (!isFetchLike) return;
|
|
3510
|
+
const arg = call.arguments[0];
|
|
3511
|
+
if (!arg) return;
|
|
3512
|
+
if (isStaticString(arg)) return;
|
|
3513
|
+
const lineNum = call.loc.start.line;
|
|
3514
|
+
const col = call.loc.start.column + 1;
|
|
3515
|
+
if (isUserInputNode(arg)) {
|
|
3516
|
+
findings.push({
|
|
3517
|
+
ruleId: "ssrf-risk",
|
|
3518
|
+
file: file.relativePath,
|
|
3519
|
+
line: lineNum,
|
|
3520
|
+
column: col,
|
|
3521
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3522
|
+
severity: "warning",
|
|
3523
|
+
category: "security",
|
|
3524
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3525
|
+
});
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
|
|
3529
|
+
if (hasValidationInCode) return;
|
|
3530
|
+
findings.push({
|
|
3531
|
+
ruleId: "ssrf-risk",
|
|
3532
|
+
file: file.relativePath,
|
|
3533
|
+
line: lineNum,
|
|
3534
|
+
column: col,
|
|
3535
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3536
|
+
severity: "warning",
|
|
3537
|
+
category: "security",
|
|
3538
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
});
|
|
3542
|
+
return findings;
|
|
3543
|
+
} catch {
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3117
3546
|
const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3118
3547
|
if (hasValidation) return [];
|
|
3119
|
-
const findings = [];
|
|
3120
3548
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3121
3549
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3122
3550
|
const line = file.lines[i];
|
|
@@ -3158,6 +3586,25 @@ var SANITIZATION_PATTERNS = [
|
|
|
3158
3586
|
/sanitize/i,
|
|
3159
3587
|
/realpath/
|
|
3160
3588
|
];
|
|
3589
|
+
var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
|
|
3590
|
+
"readFile",
|
|
3591
|
+
"readFileSync",
|
|
3592
|
+
"createReadStream",
|
|
3593
|
+
"writeFile",
|
|
3594
|
+
"writeFileSync",
|
|
3595
|
+
"createWriteStream",
|
|
3596
|
+
"unlink",
|
|
3597
|
+
"unlinkSync",
|
|
3598
|
+
"rm",
|
|
3599
|
+
"rmSync"
|
|
3600
|
+
]);
|
|
3601
|
+
var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
|
|
3602
|
+
"filePath",
|
|
3603
|
+
"fileName",
|
|
3604
|
+
"path",
|
|
3605
|
+
"file",
|
|
3606
|
+
"name"
|
|
3607
|
+
]);
|
|
3161
3608
|
var pathTraversalRule = {
|
|
3162
3609
|
id: "path-traversal",
|
|
3163
3610
|
name: "Path Traversal",
|
|
@@ -3167,9 +3614,73 @@ var pathTraversalRule = {
|
|
|
3167
3614
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3168
3615
|
check(file, _project) {
|
|
3169
3616
|
if (isTestFile(file.relativePath)) return [];
|
|
3617
|
+
const findings = [];
|
|
3618
|
+
if (file.ast) {
|
|
3619
|
+
try {
|
|
3620
|
+
walkAST(file.ast.program, (node) => {
|
|
3621
|
+
if (node.type !== "CallExpression") return;
|
|
3622
|
+
const call = node;
|
|
3623
|
+
if (!call.loc) return;
|
|
3624
|
+
let fnName = null;
|
|
3625
|
+
if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
|
|
3626
|
+
fnName = call.callee.name;
|
|
3627
|
+
}
|
|
3628
|
+
if (call.callee.type === "MemberExpression") {
|
|
3629
|
+
const mem = call.callee;
|
|
3630
|
+
if (mem.property.type === "Identifier") {
|
|
3631
|
+
if (FS_FUNCTION_NAMES.has(mem.property.name)) {
|
|
3632
|
+
fnName = mem.property.name;
|
|
3633
|
+
}
|
|
3634
|
+
if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
|
|
3635
|
+
fnName = "path.join";
|
|
3636
|
+
}
|
|
3637
|
+
if (mem.property.name === "sendFile") {
|
|
3638
|
+
fnName = "sendFile";
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
if (!fnName) return;
|
|
3643
|
+
const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
|
|
3644
|
+
for (const arg of argsToCheck) {
|
|
3645
|
+
if (!arg) continue;
|
|
3646
|
+
if (isStaticString(arg)) continue;
|
|
3647
|
+
const lineNum = call.loc.start.line;
|
|
3648
|
+
const col = call.loc.start.column + 1;
|
|
3649
|
+
if (isUserInputNode(arg)) {
|
|
3650
|
+
const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
|
|
3651
|
+
findings.push({
|
|
3652
|
+
ruleId: "path-traversal",
|
|
3653
|
+
file: file.relativePath,
|
|
3654
|
+
line: lineNum,
|
|
3655
|
+
column: col,
|
|
3656
|
+
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`,
|
|
3657
|
+
severity: "critical",
|
|
3658
|
+
category: "security",
|
|
3659
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3660
|
+
});
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
|
|
3664
|
+
findings.push({
|
|
3665
|
+
ruleId: "path-traversal",
|
|
3666
|
+
file: file.relativePath,
|
|
3667
|
+
line: lineNum,
|
|
3668
|
+
column: col,
|
|
3669
|
+
message: `File operation with potentially user-controlled path \u2014 validate before use`,
|
|
3670
|
+
severity: "warning",
|
|
3671
|
+
category: "security",
|
|
3672
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3673
|
+
});
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
});
|
|
3678
|
+
return findings;
|
|
3679
|
+
} catch {
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3170
3682
|
const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
|
|
3171
3683
|
if (hasSanitization) return [];
|
|
3172
|
-
const findings = [];
|
|
3173
3684
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3174
3685
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3175
3686
|
const line = file.lines[i];
|
|
@@ -3221,18 +3732,31 @@ var hydrationMismatchRule = {
|
|
|
3221
3732
|
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3222
3733
|
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3223
3734
|
const findings = [];
|
|
3735
|
+
let useEffectRanges = [];
|
|
3736
|
+
if (file.ast) {
|
|
3737
|
+
try {
|
|
3738
|
+
useEffectRanges = findUseEffectRanges(file.ast);
|
|
3739
|
+
} catch {
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
|
|
3224
3743
|
let insideUseEffect = false;
|
|
3225
3744
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3226
3745
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3227
3746
|
const line = file.lines[i];
|
|
3228
|
-
if (
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
if (
|
|
3233
|
-
insideUseEffect =
|
|
3747
|
+
if (hasAstRanges) {
|
|
3748
|
+
const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
|
|
3749
|
+
if (inEffect) continue;
|
|
3750
|
+
} else {
|
|
3751
|
+
if (/\buseEffect\s*\(/.test(line)) {
|
|
3752
|
+
insideUseEffect = true;
|
|
3753
|
+
}
|
|
3754
|
+
if (insideUseEffect) {
|
|
3755
|
+
if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
|
|
3756
|
+
insideUseEffect = false;
|
|
3757
|
+
}
|
|
3758
|
+
continue;
|
|
3234
3759
|
}
|
|
3235
|
-
continue;
|
|
3236
3760
|
}
|
|
3237
3761
|
for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
|
|
3238
3762
|
const match = pattern.exec(line);
|
|
@@ -3457,6 +3981,7 @@ var HAS_EXPIRY = [
|
|
|
3457
3981
|
/expirationTime/,
|
|
3458
3982
|
/maxAge/
|
|
3459
3983
|
];
|
|
3984
|
+
var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
|
|
3460
3985
|
var jwtNoExpiryRule = {
|
|
3461
3986
|
id: "jwt-no-expiry",
|
|
3462
3987
|
name: "JWT Without Expiration",
|
|
@@ -3468,6 +3993,55 @@ var jwtNoExpiryRule = {
|
|
|
3468
3993
|
if (isTestFile(file.relativePath)) return [];
|
|
3469
3994
|
if (!JWT_SIGN.test(file.content)) return [];
|
|
3470
3995
|
const findings = [];
|
|
3996
|
+
if (file.ast) {
|
|
3997
|
+
try {
|
|
3998
|
+
walkAST(file.ast.program, (node) => {
|
|
3999
|
+
if (node.type !== "CallExpression") return;
|
|
4000
|
+
const call = node;
|
|
4001
|
+
if (!call.loc) return;
|
|
4002
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
4003
|
+
const mem = call.callee;
|
|
4004
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
|
|
4005
|
+
if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
|
|
4006
|
+
let hasExpiry = false;
|
|
4007
|
+
for (const arg of call.arguments) {
|
|
4008
|
+
if (arg.type === "ObjectExpression") {
|
|
4009
|
+
const obj = arg;
|
|
4010
|
+
for (const prop of obj.properties) {
|
|
4011
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
|
|
4012
|
+
hasExpiry = true;
|
|
4013
|
+
break;
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
if (hasExpiry) break;
|
|
4018
|
+
}
|
|
4019
|
+
if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
|
|
4020
|
+
const payload = call.arguments[0];
|
|
4021
|
+
for (const prop of payload.properties) {
|
|
4022
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
|
|
4023
|
+
hasExpiry = true;
|
|
4024
|
+
break;
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
if (!hasExpiry) {
|
|
4029
|
+
findings.push({
|
|
4030
|
+
ruleId: "jwt-no-expiry",
|
|
4031
|
+
file: file.relativePath,
|
|
4032
|
+
line: call.loc.start.line,
|
|
4033
|
+
column: call.loc.start.column + 1,
|
|
4034
|
+
message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
|
|
4035
|
+
severity: "warning",
|
|
4036
|
+
category: "security",
|
|
4037
|
+
fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
|
|
4038
|
+
});
|
|
4039
|
+
}
|
|
4040
|
+
});
|
|
4041
|
+
return findings;
|
|
4042
|
+
} catch {
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
3471
4045
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3472
4046
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3473
4047
|
const line = file.lines[i];
|