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