prodlint 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +253 -252
- package/action.yml +152 -152
- package/dist/cli.js +718 -23
- package/dist/index.js +718 -23
- package/dist/mcp.js +718 -23
- package/package.json +83 -83
package/dist/index.js
CHANGED
|
@@ -71,7 +71,10 @@ function isTestFile(relativePath) {
|
|
|
71
71
|
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || /(?:^|\/)__tests__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
|
|
72
72
|
}
|
|
73
73
|
function isScriptFile(relativePath) {
|
|
74
|
-
|
|
74
|
+
if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
|
|
75
|
+
const name = relativePath.split("/").pop() ?? "";
|
|
76
|
+
const base = name.replace(/\.[^.]+$/, "");
|
|
77
|
+
return /^(seed|migrate|setup|bootstrap|generate|codegen|sync|deploy|cleanup|reset)$/.test(base);
|
|
75
78
|
}
|
|
76
79
|
function isConfigFile(relativePath) {
|
|
77
80
|
const name = relativePath.split("/").pop() ?? "";
|
|
@@ -240,6 +243,100 @@ function findLoopsAST(ast) {
|
|
|
240
243
|
});
|
|
241
244
|
return loops;
|
|
242
245
|
}
|
|
246
|
+
function getImportSources(ast) {
|
|
247
|
+
const sources = [];
|
|
248
|
+
walkAST(ast.program, (node) => {
|
|
249
|
+
if (node.type === "ImportDeclaration") {
|
|
250
|
+
const decl = node;
|
|
251
|
+
sources.push({ source: decl.source.value, line: decl.loc?.start.line ?? 1 });
|
|
252
|
+
}
|
|
253
|
+
if (node.type === "CallExpression") {
|
|
254
|
+
const call = node;
|
|
255
|
+
if (call.callee.type === "Identifier" && call.callee.name === "require" && call.arguments.length === 1 && call.arguments[0].type === "StringLiteral") {
|
|
256
|
+
sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
|
|
257
|
+
}
|
|
258
|
+
if (call.callee.type === "Import" && call.arguments.length >= 1 && call.arguments[0].type === "StringLiteral") {
|
|
259
|
+
sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return sources;
|
|
264
|
+
}
|
|
265
|
+
function isUserInputNode(node) {
|
|
266
|
+
if (node.type === "MemberExpression") {
|
|
267
|
+
const mem = node;
|
|
268
|
+
if (mem.object.type === "MemberExpression") {
|
|
269
|
+
const inner = mem.object;
|
|
270
|
+
if (inner.object.type === "Identifier") {
|
|
271
|
+
const objName = inner.object.name;
|
|
272
|
+
if (objName === "req" || objName === "request") {
|
|
273
|
+
if (inner.property.type === "Identifier") {
|
|
274
|
+
const prop = inner.property.name;
|
|
275
|
+
if (prop === "query" || prop === "body" || prop === "params") return true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (node.type === "CallExpression") {
|
|
282
|
+
const call = node;
|
|
283
|
+
if (call.callee.type === "MemberExpression") {
|
|
284
|
+
const callee = call.callee;
|
|
285
|
+
if (callee.property.type === "Identifier" && callee.property.name === "get") {
|
|
286
|
+
if (callee.object.type === "Identifier") {
|
|
287
|
+
const name = callee.object.name;
|
|
288
|
+
if (name === "searchParams" || name === "formData") return true;
|
|
289
|
+
}
|
|
290
|
+
if (callee.object.type === "MemberExpression") {
|
|
291
|
+
const inner = callee.object;
|
|
292
|
+
if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
function isStaticString(node) {
|
|
302
|
+
if (node.type === "StringLiteral") return true;
|
|
303
|
+
if (node.type === "TemplateLiteral") {
|
|
304
|
+
return node.expressions.length === 0;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
function findUseEffectRanges(ast) {
|
|
309
|
+
const ranges = [];
|
|
310
|
+
walkAST(ast.program, (node) => {
|
|
311
|
+
if (node.type !== "CallExpression") return;
|
|
312
|
+
const call = node;
|
|
313
|
+
if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
|
|
314
|
+
const callback = call.arguments[0];
|
|
315
|
+
if (!callback || !callback.loc) return;
|
|
316
|
+
ranges.push({
|
|
317
|
+
start: callback.loc.start.line - 1,
|
|
318
|
+
end: callback.loc.end.line - 1
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
return ranges;
|
|
322
|
+
}
|
|
323
|
+
function subtreeContains(node, predicate) {
|
|
324
|
+
if (predicate(node)) return true;
|
|
325
|
+
for (const key of Object.keys(node)) {
|
|
326
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
327
|
+
const val = node[key];
|
|
328
|
+
if (Array.isArray(val)) {
|
|
329
|
+
for (const item of val) {
|
|
330
|
+
if (item && typeof item === "object" && item.type) {
|
|
331
|
+
if (subtreeContains(item, predicate)) return true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
335
|
+
if (subtreeContains(val, predicate)) return true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
243
340
|
|
|
244
341
|
// src/utils/frameworks.ts
|
|
245
342
|
var FRAMEWORK_SAFE_METHODS = {
|
|
@@ -359,6 +456,66 @@ async function readFileContext(root, relativePath) {
|
|
|
359
456
|
return null;
|
|
360
457
|
}
|
|
361
458
|
}
|
|
459
|
+
async function getWorkspacePatterns(root, packageJson) {
|
|
460
|
+
const patterns = [];
|
|
461
|
+
if (packageJson) {
|
|
462
|
+
const workspaces = packageJson.workspaces;
|
|
463
|
+
if (Array.isArray(workspaces)) {
|
|
464
|
+
patterns.push(...workspaces);
|
|
465
|
+
} else if (workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)) {
|
|
466
|
+
patterns.push(...workspaces.packages);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const raw = await readFile(resolve(root, "pnpm-workspace.yaml"), "utf-8");
|
|
471
|
+
let inPackages = false;
|
|
472
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
473
|
+
const trimmed = line.trim();
|
|
474
|
+
if (/^packages\s*:/.test(trimmed)) {
|
|
475
|
+
inPackages = true;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (inPackages) {
|
|
479
|
+
if (/^-\s+/.test(trimmed)) {
|
|
480
|
+
const glob = trimmed.replace(/^-\s+/, "").replace(/^['"]|['"]$/g, "");
|
|
481
|
+
if (glob) patterns.push(glob);
|
|
482
|
+
} else if (trimmed && !trimmed.startsWith("#")) {
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
}
|
|
489
|
+
return patterns;
|
|
490
|
+
}
|
|
491
|
+
async function collectWorkspaceDependencies(root, patterns) {
|
|
492
|
+
const deps = /* @__PURE__ */ new Set();
|
|
493
|
+
const globPatterns = patterns.map((p) => `${p}/package.json`);
|
|
494
|
+
try {
|
|
495
|
+
const pkgFiles = await fg(globPatterns, {
|
|
496
|
+
cwd: root,
|
|
497
|
+
absolute: false,
|
|
498
|
+
ignore: ["**/node_modules/**"]
|
|
499
|
+
});
|
|
500
|
+
for (const pkgFile of pkgFiles) {
|
|
501
|
+
try {
|
|
502
|
+
const raw = await readFile(resolve(root, pkgFile), "utf-8");
|
|
503
|
+
const pkg = JSON.parse(raw);
|
|
504
|
+
if (pkg.name) deps.add(pkg.name);
|
|
505
|
+
for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
506
|
+
if (pkg[key] && typeof pkg[key] === "object") {
|
|
507
|
+
for (const dep of Object.keys(pkg[key])) {
|
|
508
|
+
deps.add(dep);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
}
|
|
517
|
+
return deps;
|
|
518
|
+
}
|
|
362
519
|
async function buildProjectContext(root, files) {
|
|
363
520
|
let packageJson = null;
|
|
364
521
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
@@ -377,19 +534,26 @@ async function buildProjectContext(root, files) {
|
|
|
377
534
|
...packageJson?.peerDependencies ?? {}
|
|
378
535
|
};
|
|
379
536
|
declaredDependencies = new Set(Object.keys(deps));
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
const workspacePatterns = await getWorkspacePatterns(root, packageJson);
|
|
540
|
+
if (workspacePatterns.length > 0) {
|
|
541
|
+
const workspaceDeps = await collectWorkspaceDependencies(root, workspacePatterns);
|
|
542
|
+
for (const dep of workspaceDeps) {
|
|
543
|
+
declaredDependencies.add(dep);
|
|
385
544
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
545
|
+
}
|
|
546
|
+
for (const dep of declaredDependencies) {
|
|
547
|
+
const framework = DEPENDENCY_TO_FRAMEWORK[dep];
|
|
548
|
+
if (framework) {
|
|
549
|
+
detectedFrameworks.add(framework);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
for (const framework of detectedFrameworks) {
|
|
553
|
+
if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
|
|
554
|
+
hasRateLimiting = true;
|
|
555
|
+
break;
|
|
391
556
|
}
|
|
392
|
-
} catch {
|
|
393
557
|
}
|
|
394
558
|
try {
|
|
395
559
|
const raw = await readFile(resolve(root, "tsconfig.json"), "utf-8");
|
|
@@ -644,6 +808,32 @@ var hallucinatedImportsRule = {
|
|
|
644
808
|
const findings = [];
|
|
645
809
|
const seen = /* @__PURE__ */ new Set();
|
|
646
810
|
const isNonProd = isTestFile(file.relativePath) || isScriptFile(file.relativePath);
|
|
811
|
+
if (file.ast) {
|
|
812
|
+
try {
|
|
813
|
+
const imports = getImportSources(file.ast);
|
|
814
|
+
for (const { source: importPath, line } of imports) {
|
|
815
|
+
if (importPath.startsWith(".") || importPath.startsWith("/")) continue;
|
|
816
|
+
const pkgName = getPackageName(importPath);
|
|
817
|
+
if (seen.has(pkgName)) continue;
|
|
818
|
+
seen.add(pkgName);
|
|
819
|
+
if (isPathAlias(importPath, project.tsconfigPaths)) continue;
|
|
820
|
+
if (isNodeBuiltin(pkgName)) continue;
|
|
821
|
+
if (IMPLICIT_PACKAGES.has(importPath) || IMPLICIT_PACKAGES.has(pkgName)) continue;
|
|
822
|
+
if (project.declaredDependencies.has(pkgName)) continue;
|
|
823
|
+
findings.push({
|
|
824
|
+
ruleId: "hallucinated-imports",
|
|
825
|
+
file: file.relativePath,
|
|
826
|
+
line,
|
|
827
|
+
column: 1,
|
|
828
|
+
message: `Package "${pkgName}" is imported but not in package.json`,
|
|
829
|
+
severity: isNonProd ? "warning" : "critical",
|
|
830
|
+
category: "reliability"
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
return findings;
|
|
834
|
+
} catch {
|
|
835
|
+
}
|
|
836
|
+
}
|
|
647
837
|
for (let i = 0; i < file.lines.length; i++) {
|
|
648
838
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
649
839
|
const line = file.lines[i];
|
|
@@ -1142,6 +1332,81 @@ var unsafeHtmlRule = {
|
|
|
1142
1332
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1143
1333
|
check(file, _project) {
|
|
1144
1334
|
const findings = [];
|
|
1335
|
+
if (file.ast) {
|
|
1336
|
+
try {
|
|
1337
|
+
walkAST(file.ast.program, (node) => {
|
|
1338
|
+
if (node.type === "JSXAttribute") {
|
|
1339
|
+
const attr = node;
|
|
1340
|
+
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1341
|
+
if (attr.value && subtreeContains(attr.value, (n) => {
|
|
1342
|
+
if (n.type !== "CallExpression") return false;
|
|
1343
|
+
const call = n;
|
|
1344
|
+
if (call.callee.type === "MemberExpression") {
|
|
1345
|
+
const mem = call.callee;
|
|
1346
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1347
|
+
}
|
|
1348
|
+
return false;
|
|
1349
|
+
})) {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const line = attr.loc.start.line;
|
|
1353
|
+
findings.push({
|
|
1354
|
+
ruleId: "unsafe-html",
|
|
1355
|
+
file: file.relativePath,
|
|
1356
|
+
line,
|
|
1357
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1358
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1359
|
+
severity: "critical",
|
|
1360
|
+
category: "security"
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
if (node.type === "ObjectProperty") {
|
|
1365
|
+
const prop = node;
|
|
1366
|
+
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1367
|
+
if (prop.value && subtreeContains(prop.value, (n) => {
|
|
1368
|
+
if (n.type !== "CallExpression") return false;
|
|
1369
|
+
const call = n;
|
|
1370
|
+
if (call.callee.type === "MemberExpression") {
|
|
1371
|
+
const mem = call.callee;
|
|
1372
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1373
|
+
}
|
|
1374
|
+
return false;
|
|
1375
|
+
})) {
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const line = prop.loc.start.line;
|
|
1379
|
+
findings.push({
|
|
1380
|
+
ruleId: "unsafe-html",
|
|
1381
|
+
file: file.relativePath,
|
|
1382
|
+
line,
|
|
1383
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1384
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1385
|
+
severity: "critical",
|
|
1386
|
+
category: "security"
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (node.type === "AssignmentExpression") {
|
|
1391
|
+
const assign = node;
|
|
1392
|
+
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1393
|
+
const line = assign.loc.start.line;
|
|
1394
|
+
findings.push({
|
|
1395
|
+
ruleId: "unsafe-html",
|
|
1396
|
+
file: file.relativePath,
|
|
1397
|
+
line,
|
|
1398
|
+
column: file.lines[line - 1].indexOf(".innerHTML") + 1,
|
|
1399
|
+
message: "Direct innerHTML assignment is an XSS risk",
|
|
1400
|
+
severity: "critical",
|
|
1401
|
+
category: "security"
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
return findings;
|
|
1407
|
+
} catch {
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1145
1410
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1146
1411
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1147
1412
|
const line = file.lines[i];
|
|
@@ -1395,6 +1660,22 @@ var DIRECT_INPUT_PATTERNS = [
|
|
|
1395
1660
|
var WARNING_PATTERNS = [
|
|
1396
1661
|
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1397
1662
|
];
|
|
1663
|
+
var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
|
|
1664
|
+
"url",
|
|
1665
|
+
"returnUrl",
|
|
1666
|
+
"returnTo",
|
|
1667
|
+
"redirectUrl",
|
|
1668
|
+
"redirectTo",
|
|
1669
|
+
"next",
|
|
1670
|
+
"callbackUrl",
|
|
1671
|
+
"destination",
|
|
1672
|
+
"redirect",
|
|
1673
|
+
"goto",
|
|
1674
|
+
"to",
|
|
1675
|
+
"target",
|
|
1676
|
+
"uri",
|
|
1677
|
+
"href"
|
|
1678
|
+
]);
|
|
1398
1679
|
var openRedirectRule = {
|
|
1399
1680
|
id: "open-redirect",
|
|
1400
1681
|
name: "Open Redirect",
|
|
@@ -1404,6 +1685,62 @@ var openRedirectRule = {
|
|
|
1404
1685
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1405
1686
|
check(file, _project) {
|
|
1406
1687
|
const findings = [];
|
|
1688
|
+
if (file.ast) {
|
|
1689
|
+
try {
|
|
1690
|
+
walkAST(file.ast.program, (node) => {
|
|
1691
|
+
if (node.type !== "CallExpression") return;
|
|
1692
|
+
const call = node;
|
|
1693
|
+
if (!call.loc) return;
|
|
1694
|
+
let isRedirect = false;
|
|
1695
|
+
if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
|
|
1696
|
+
isRedirect = true;
|
|
1697
|
+
}
|
|
1698
|
+
if (call.callee.type === "MemberExpression") {
|
|
1699
|
+
const mem = call.callee;
|
|
1700
|
+
if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
|
|
1701
|
+
isRedirect = true;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
if (!isRedirect) return;
|
|
1705
|
+
const arg = call.arguments[0];
|
|
1706
|
+
if (!arg) return;
|
|
1707
|
+
if (isStaticString(arg)) return;
|
|
1708
|
+
let targetArg = arg;
|
|
1709
|
+
if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
|
|
1710
|
+
targetArg = arg.arguments[0];
|
|
1711
|
+
if (!targetArg) return;
|
|
1712
|
+
if (isStaticString(targetArg)) return;
|
|
1713
|
+
}
|
|
1714
|
+
const lineNum = call.loc.start.line;
|
|
1715
|
+
const col = call.loc.start.column + 1;
|
|
1716
|
+
if (isUserInputNode(targetArg)) {
|
|
1717
|
+
findings.push({
|
|
1718
|
+
ruleId: "open-redirect",
|
|
1719
|
+
file: file.relativePath,
|
|
1720
|
+
line: lineNum,
|
|
1721
|
+
column: col,
|
|
1722
|
+
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1723
|
+
severity: "warning",
|
|
1724
|
+
category: "security"
|
|
1725
|
+
});
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
|
|
1729
|
+
findings.push({
|
|
1730
|
+
ruleId: "open-redirect",
|
|
1731
|
+
file: file.relativePath,
|
|
1732
|
+
line: lineNum,
|
|
1733
|
+
column: col,
|
|
1734
|
+
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1735
|
+
severity: "warning",
|
|
1736
|
+
category: "security"
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
return findings;
|
|
1741
|
+
} catch {
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1407
1744
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1408
1745
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1409
1746
|
const line = file.lines[i];
|
|
@@ -2028,8 +2365,14 @@ function scoreCatchBody(bodyLines) {
|
|
|
2028
2365
|
const body = bodyLines.join("\n");
|
|
2029
2366
|
let score = 0;
|
|
2030
2367
|
if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
|
|
2031
|
-
if (/console\.(error|warn)\s*\(/.test(body) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2032
|
-
if (/\bthrow\b/.test(body) || /\breturn\b.*(?:error|err|status|Response|NextResponse|json)/.test(body) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body))
|
|
2368
|
+
if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2369
|
+
if (/\bthrow\b/.test(body) || /\breturn\b.*(?:error|err|status|Response|NextResponse|json)/.test(body) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body) || // Toast notifications (react-toastify, sonner, shadcn)
|
|
2370
|
+
/\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
|
|
2371
|
+
/\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
|
|
2372
|
+
/\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
|
|
2373
|
+
/\b(handleError|reportError|logError|notifyError|showError|onError|trackError)\s*\(/.test(body) || // Express middleware: next(err)
|
|
2374
|
+
/\bnext\s*\(\s*(err|error|e)\s*\)/.test(body) || // Next.js notFound()
|
|
2375
|
+
/\bnotFound\s*\(/.test(body)) {
|
|
2033
2376
|
score = 3;
|
|
2034
2377
|
}
|
|
2035
2378
|
const labels = {
|
|
@@ -2051,6 +2394,34 @@ var shallowCatchRule = {
|
|
|
2051
2394
|
if (isTestFile(file.relativePath)) return [];
|
|
2052
2395
|
if (isScriptFile(file.relativePath)) return [];
|
|
2053
2396
|
const findings = [];
|
|
2397
|
+
if (file.ast) {
|
|
2398
|
+
try {
|
|
2399
|
+
walkAST(file.ast.program, (node) => {
|
|
2400
|
+
if (node.type !== "CatchClause") return;
|
|
2401
|
+
if (!node.loc) return;
|
|
2402
|
+
const catchLine = node.loc.start.line - 1;
|
|
2403
|
+
const body = node.body;
|
|
2404
|
+
if (!body || !body.loc) return;
|
|
2405
|
+
const bodyStart = body.loc.start.line - 1;
|
|
2406
|
+
const bodyEnd = body.loc.end.line - 1;
|
|
2407
|
+
const bodyLines = bodyStart === bodyEnd ? [file.lines[bodyStart]] : file.lines.slice(bodyStart + 1, bodyEnd);
|
|
2408
|
+
const { score, label } = scoreCatchBody(bodyLines);
|
|
2409
|
+
if (score <= 1) {
|
|
2410
|
+
findings.push({
|
|
2411
|
+
ruleId: "shallow-catch",
|
|
2412
|
+
file: file.relativePath,
|
|
2413
|
+
line: catchLine + 1,
|
|
2414
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2415
|
+
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2416
|
+
severity: score === 0 ? "warning" : "info",
|
|
2417
|
+
category: "reliability"
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
});
|
|
2421
|
+
return findings;
|
|
2422
|
+
} catch {
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2054
2425
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2055
2426
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2056
2427
|
const trimmed = file.lines[i].trim();
|
|
@@ -2384,6 +2755,7 @@ var insecureCookieRule = {
|
|
|
2384
2755
|
|
|
2385
2756
|
// src/rules/leaked-env-in-logs.ts
|
|
2386
2757
|
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2758
|
+
var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
|
|
2387
2759
|
var leakedEnvInLogsRule = {
|
|
2388
2760
|
id: "leaked-env-in-logs",
|
|
2389
2761
|
name: "Leaked Env in Logs",
|
|
@@ -2395,6 +2767,48 @@ var leakedEnvInLogsRule = {
|
|
|
2395
2767
|
if (isTestFile(file.relativePath)) return [];
|
|
2396
2768
|
if (isScriptFile(file.relativePath)) return [];
|
|
2397
2769
|
const findings = [];
|
|
2770
|
+
if (file.ast) {
|
|
2771
|
+
try {
|
|
2772
|
+
walkAST(file.ast.program, (node) => {
|
|
2773
|
+
if (node.type !== "CallExpression") return;
|
|
2774
|
+
const call = node;
|
|
2775
|
+
if (!call.loc) return;
|
|
2776
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2777
|
+
const mem = call.callee;
|
|
2778
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
|
|
2779
|
+
if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
|
|
2780
|
+
let hasEnvAccess = false;
|
|
2781
|
+
for (const arg of call.arguments) {
|
|
2782
|
+
if (subtreeContains(arg, (n) => {
|
|
2783
|
+
if (n.type !== "MemberExpression") return false;
|
|
2784
|
+
const m = n;
|
|
2785
|
+
if (m.object.type === "MemberExpression") {
|
|
2786
|
+
const inner = m.object;
|
|
2787
|
+
return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
|
|
2788
|
+
}
|
|
2789
|
+
return false;
|
|
2790
|
+
})) {
|
|
2791
|
+
hasEnvAccess = true;
|
|
2792
|
+
break;
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
if (hasEnvAccess) {
|
|
2796
|
+
findings.push({
|
|
2797
|
+
ruleId: "leaked-env-in-logs",
|
|
2798
|
+
file: file.relativePath,
|
|
2799
|
+
line: call.loc.start.line,
|
|
2800
|
+
column: call.loc.start.column + 1,
|
|
2801
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2802
|
+
severity: "warning",
|
|
2803
|
+
category: "security",
|
|
2804
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
});
|
|
2808
|
+
return findings;
|
|
2809
|
+
} catch {
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2398
2812
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2399
2813
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2400
2814
|
const line = file.lines[i];
|
|
@@ -2499,6 +2913,15 @@ var nextServerActionValidationRule = {
|
|
|
2499
2913
|
// src/rules/missing-transaction.ts
|
|
2500
2914
|
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2501
2915
|
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2916
|
+
var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
|
|
2917
|
+
"create",
|
|
2918
|
+
"update",
|
|
2919
|
+
"delete",
|
|
2920
|
+
"upsert",
|
|
2921
|
+
"createMany",
|
|
2922
|
+
"updateMany",
|
|
2923
|
+
"deleteMany"
|
|
2924
|
+
]);
|
|
2502
2925
|
var missingTransactionRule = {
|
|
2503
2926
|
id: "missing-transaction",
|
|
2504
2927
|
name: "Missing Transaction",
|
|
@@ -2510,6 +2933,60 @@ var missingTransactionRule = {
|
|
|
2510
2933
|
if (isTestFile(file.relativePath)) return [];
|
|
2511
2934
|
if (isScriptFile(file.relativePath)) return [];
|
|
2512
2935
|
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2936
|
+
if (file.ast) {
|
|
2937
|
+
try {
|
|
2938
|
+
const parentMap = /* @__PURE__ */ new Map();
|
|
2939
|
+
walkAST(file.ast.program, (node, parent) => {
|
|
2940
|
+
if (parent) parentMap.set(node, parent);
|
|
2941
|
+
});
|
|
2942
|
+
const writesByScope = /* @__PURE__ */ new Map();
|
|
2943
|
+
const transactionScopes = /* @__PURE__ */ new Set();
|
|
2944
|
+
walkAST(file.ast.program, (node) => {
|
|
2945
|
+
if (node.type !== "CallExpression") return;
|
|
2946
|
+
const call = node;
|
|
2947
|
+
if (!call.loc) return;
|
|
2948
|
+
if (call.callee.type === "MemberExpression") {
|
|
2949
|
+
const mem = call.callee;
|
|
2950
|
+
if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
|
|
2951
|
+
const scope2 = findEnclosingFunction(node, parentMap);
|
|
2952
|
+
transactionScopes.add(scope2);
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2957
|
+
const outerMem = call.callee;
|
|
2958
|
+
if (outerMem.property.type !== "Identifier") return;
|
|
2959
|
+
if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
|
|
2960
|
+
if (outerMem.object.type !== "MemberExpression") return;
|
|
2961
|
+
const innerMem = outerMem.object;
|
|
2962
|
+
if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
|
|
2963
|
+
const scope = findEnclosingFunction(node, parentMap);
|
|
2964
|
+
const existing = writesByScope.get(scope);
|
|
2965
|
+
if (existing) {
|
|
2966
|
+
existing.count++;
|
|
2967
|
+
} else {
|
|
2968
|
+
writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
|
|
2969
|
+
}
|
|
2970
|
+
});
|
|
2971
|
+
const findings = [];
|
|
2972
|
+
for (const [scope, { count, firstLine }] of writesByScope) {
|
|
2973
|
+
if (count < 2) continue;
|
|
2974
|
+
if (transactionScopes.has(scope)) continue;
|
|
2975
|
+
findings.push({
|
|
2976
|
+
ruleId: "missing-transaction",
|
|
2977
|
+
file: file.relativePath,
|
|
2978
|
+
line: firstLine,
|
|
2979
|
+
column: 1,
|
|
2980
|
+
message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2981
|
+
severity: "warning",
|
|
2982
|
+
category: "reliability",
|
|
2983
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
return findings;
|
|
2987
|
+
} catch {
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2513
2990
|
let writeCount = 0;
|
|
2514
2991
|
let firstWriteLine = -1;
|
|
2515
2992
|
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
@@ -2533,6 +3010,16 @@ var missingTransactionRule = {
|
|
|
2533
3010
|
}];
|
|
2534
3011
|
}
|
|
2535
3012
|
};
|
|
3013
|
+
function findEnclosingFunction(node, parentMap) {
|
|
3014
|
+
let current = parentMap.get(node);
|
|
3015
|
+
while (current) {
|
|
3016
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
|
|
3017
|
+
return current;
|
|
3018
|
+
}
|
|
3019
|
+
current = parentMap.get(current);
|
|
3020
|
+
}
|
|
3021
|
+
return null;
|
|
3022
|
+
}
|
|
2536
3023
|
|
|
2537
3024
|
// src/rules/redirect-in-try-catch.ts
|
|
2538
3025
|
var redirectInTryCatchRule = {
|
|
@@ -3099,6 +3586,14 @@ var VALIDATION_PATTERNS3 = [
|
|
|
3099
3586
|
/\.startsWith\s*\(\s*['"]https?:\/\//,
|
|
3100
3587
|
/\.hostname\s*[!=]==?\s*['"`]/
|
|
3101
3588
|
];
|
|
3589
|
+
var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
|
|
3590
|
+
"url",
|
|
3591
|
+
"href",
|
|
3592
|
+
"endpoint",
|
|
3593
|
+
"target",
|
|
3594
|
+
"link",
|
|
3595
|
+
"src"
|
|
3596
|
+
]);
|
|
3102
3597
|
var ssrfRiskRule = {
|
|
3103
3598
|
id: "ssrf-risk",
|
|
3104
3599
|
name: "SSRF Risk",
|
|
@@ -3109,9 +3604,63 @@ var ssrfRiskRule = {
|
|
|
3109
3604
|
check(file, _project) {
|
|
3110
3605
|
if (isTestFile(file.relativePath)) return [];
|
|
3111
3606
|
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3607
|
+
const findings = [];
|
|
3608
|
+
if (file.ast) {
|
|
3609
|
+
try {
|
|
3610
|
+
const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3611
|
+
walkAST(file.ast.program, (node) => {
|
|
3612
|
+
if (node.type !== "CallExpression") return;
|
|
3613
|
+
const call = node;
|
|
3614
|
+
if (!call.loc) return;
|
|
3615
|
+
let isFetchLike = false;
|
|
3616
|
+
if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
|
|
3617
|
+
isFetchLike = true;
|
|
3618
|
+
}
|
|
3619
|
+
if (call.callee.type === "MemberExpression") {
|
|
3620
|
+
const mem = call.callee;
|
|
3621
|
+
if (mem.object.type === "Identifier" && mem.object.name === "axios") {
|
|
3622
|
+
isFetchLike = true;
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
if (!isFetchLike) return;
|
|
3626
|
+
const arg = call.arguments[0];
|
|
3627
|
+
if (!arg) return;
|
|
3628
|
+
if (isStaticString(arg)) return;
|
|
3629
|
+
const lineNum = call.loc.start.line;
|
|
3630
|
+
const col = call.loc.start.column + 1;
|
|
3631
|
+
if (isUserInputNode(arg)) {
|
|
3632
|
+
findings.push({
|
|
3633
|
+
ruleId: "ssrf-risk",
|
|
3634
|
+
file: file.relativePath,
|
|
3635
|
+
line: lineNum,
|
|
3636
|
+
column: col,
|
|
3637
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3638
|
+
severity: "warning",
|
|
3639
|
+
category: "security",
|
|
3640
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3641
|
+
});
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3644
|
+
if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
|
|
3645
|
+
if (hasValidationInCode) return;
|
|
3646
|
+
findings.push({
|
|
3647
|
+
ruleId: "ssrf-risk",
|
|
3648
|
+
file: file.relativePath,
|
|
3649
|
+
line: lineNum,
|
|
3650
|
+
column: col,
|
|
3651
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3652
|
+
severity: "warning",
|
|
3653
|
+
category: "security",
|
|
3654
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3655
|
+
});
|
|
3656
|
+
}
|
|
3657
|
+
});
|
|
3658
|
+
return findings;
|
|
3659
|
+
} catch {
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3112
3662
|
const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3113
3663
|
if (hasValidation) return [];
|
|
3114
|
-
const findings = [];
|
|
3115
3664
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3116
3665
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3117
3666
|
const line = file.lines[i];
|
|
@@ -3153,6 +3702,25 @@ var SANITIZATION_PATTERNS = [
|
|
|
3153
3702
|
/sanitize/i,
|
|
3154
3703
|
/realpath/
|
|
3155
3704
|
];
|
|
3705
|
+
var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
|
|
3706
|
+
"readFile",
|
|
3707
|
+
"readFileSync",
|
|
3708
|
+
"createReadStream",
|
|
3709
|
+
"writeFile",
|
|
3710
|
+
"writeFileSync",
|
|
3711
|
+
"createWriteStream",
|
|
3712
|
+
"unlink",
|
|
3713
|
+
"unlinkSync",
|
|
3714
|
+
"rm",
|
|
3715
|
+
"rmSync"
|
|
3716
|
+
]);
|
|
3717
|
+
var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
|
|
3718
|
+
"filePath",
|
|
3719
|
+
"fileName",
|
|
3720
|
+
"path",
|
|
3721
|
+
"file",
|
|
3722
|
+
"name"
|
|
3723
|
+
]);
|
|
3156
3724
|
var pathTraversalRule = {
|
|
3157
3725
|
id: "path-traversal",
|
|
3158
3726
|
name: "Path Traversal",
|
|
@@ -3162,9 +3730,73 @@ var pathTraversalRule = {
|
|
|
3162
3730
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3163
3731
|
check(file, _project) {
|
|
3164
3732
|
if (isTestFile(file.relativePath)) return [];
|
|
3733
|
+
const findings = [];
|
|
3734
|
+
if (file.ast) {
|
|
3735
|
+
try {
|
|
3736
|
+
walkAST(file.ast.program, (node) => {
|
|
3737
|
+
if (node.type !== "CallExpression") return;
|
|
3738
|
+
const call = node;
|
|
3739
|
+
if (!call.loc) return;
|
|
3740
|
+
let fnName = null;
|
|
3741
|
+
if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
|
|
3742
|
+
fnName = call.callee.name;
|
|
3743
|
+
}
|
|
3744
|
+
if (call.callee.type === "MemberExpression") {
|
|
3745
|
+
const mem = call.callee;
|
|
3746
|
+
if (mem.property.type === "Identifier") {
|
|
3747
|
+
if (FS_FUNCTION_NAMES.has(mem.property.name)) {
|
|
3748
|
+
fnName = mem.property.name;
|
|
3749
|
+
}
|
|
3750
|
+
if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
|
|
3751
|
+
fnName = "path.join";
|
|
3752
|
+
}
|
|
3753
|
+
if (mem.property.name === "sendFile") {
|
|
3754
|
+
fnName = "sendFile";
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
if (!fnName) return;
|
|
3759
|
+
const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
|
|
3760
|
+
for (const arg of argsToCheck) {
|
|
3761
|
+
if (!arg) continue;
|
|
3762
|
+
if (isStaticString(arg)) continue;
|
|
3763
|
+
const lineNum = call.loc.start.line;
|
|
3764
|
+
const col = call.loc.start.column + 1;
|
|
3765
|
+
if (isUserInputNode(arg)) {
|
|
3766
|
+
const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
|
|
3767
|
+
findings.push({
|
|
3768
|
+
ruleId: "path-traversal",
|
|
3769
|
+
file: file.relativePath,
|
|
3770
|
+
line: lineNum,
|
|
3771
|
+
column: col,
|
|
3772
|
+
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`,
|
|
3773
|
+
severity: "critical",
|
|
3774
|
+
category: "security",
|
|
3775
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3776
|
+
});
|
|
3777
|
+
return;
|
|
3778
|
+
}
|
|
3779
|
+
if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
|
|
3780
|
+
findings.push({
|
|
3781
|
+
ruleId: "path-traversal",
|
|
3782
|
+
file: file.relativePath,
|
|
3783
|
+
line: lineNum,
|
|
3784
|
+
column: col,
|
|
3785
|
+
message: `File operation with potentially user-controlled path \u2014 validate before use`,
|
|
3786
|
+
severity: "warning",
|
|
3787
|
+
category: "security",
|
|
3788
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3789
|
+
});
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
});
|
|
3794
|
+
return findings;
|
|
3795
|
+
} catch {
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3165
3798
|
const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
|
|
3166
3799
|
if (hasSanitization) return [];
|
|
3167
|
-
const findings = [];
|
|
3168
3800
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3169
3801
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3170
3802
|
const line = file.lines[i];
|
|
@@ -3216,18 +3848,31 @@ var hydrationMismatchRule = {
|
|
|
3216
3848
|
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3217
3849
|
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3218
3850
|
const findings = [];
|
|
3851
|
+
let useEffectRanges = [];
|
|
3852
|
+
if (file.ast) {
|
|
3853
|
+
try {
|
|
3854
|
+
useEffectRanges = findUseEffectRanges(file.ast);
|
|
3855
|
+
} catch {
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
|
|
3219
3859
|
let insideUseEffect = false;
|
|
3220
3860
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3221
3861
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3222
3862
|
const line = file.lines[i];
|
|
3223
|
-
if (
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
if (
|
|
3228
|
-
insideUseEffect =
|
|
3863
|
+
if (hasAstRanges) {
|
|
3864
|
+
const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
|
|
3865
|
+
if (inEffect) continue;
|
|
3866
|
+
} else {
|
|
3867
|
+
if (/\buseEffect\s*\(/.test(line)) {
|
|
3868
|
+
insideUseEffect = true;
|
|
3869
|
+
}
|
|
3870
|
+
if (insideUseEffect) {
|
|
3871
|
+
if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
|
|
3872
|
+
insideUseEffect = false;
|
|
3873
|
+
}
|
|
3874
|
+
continue;
|
|
3229
3875
|
}
|
|
3230
|
-
continue;
|
|
3231
3876
|
}
|
|
3232
3877
|
for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
|
|
3233
3878
|
const match = pattern.exec(line);
|
|
@@ -3452,6 +4097,7 @@ var HAS_EXPIRY = [
|
|
|
3452
4097
|
/expirationTime/,
|
|
3453
4098
|
/maxAge/
|
|
3454
4099
|
];
|
|
4100
|
+
var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
|
|
3455
4101
|
var jwtNoExpiryRule = {
|
|
3456
4102
|
id: "jwt-no-expiry",
|
|
3457
4103
|
name: "JWT Without Expiration",
|
|
@@ -3463,6 +4109,55 @@ var jwtNoExpiryRule = {
|
|
|
3463
4109
|
if (isTestFile(file.relativePath)) return [];
|
|
3464
4110
|
if (!JWT_SIGN.test(file.content)) return [];
|
|
3465
4111
|
const findings = [];
|
|
4112
|
+
if (file.ast) {
|
|
4113
|
+
try {
|
|
4114
|
+
walkAST(file.ast.program, (node) => {
|
|
4115
|
+
if (node.type !== "CallExpression") return;
|
|
4116
|
+
const call = node;
|
|
4117
|
+
if (!call.loc) return;
|
|
4118
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
4119
|
+
const mem = call.callee;
|
|
4120
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
|
|
4121
|
+
if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
|
|
4122
|
+
let hasExpiry = false;
|
|
4123
|
+
for (const arg of call.arguments) {
|
|
4124
|
+
if (arg.type === "ObjectExpression") {
|
|
4125
|
+
const obj = arg;
|
|
4126
|
+
for (const prop of obj.properties) {
|
|
4127
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
|
|
4128
|
+
hasExpiry = true;
|
|
4129
|
+
break;
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
if (hasExpiry) break;
|
|
4134
|
+
}
|
|
4135
|
+
if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
|
|
4136
|
+
const payload = call.arguments[0];
|
|
4137
|
+
for (const prop of payload.properties) {
|
|
4138
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
|
|
4139
|
+
hasExpiry = true;
|
|
4140
|
+
break;
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
if (!hasExpiry) {
|
|
4145
|
+
findings.push({
|
|
4146
|
+
ruleId: "jwt-no-expiry",
|
|
4147
|
+
file: file.relativePath,
|
|
4148
|
+
line: call.loc.start.line,
|
|
4149
|
+
column: call.loc.start.column + 1,
|
|
4150
|
+
message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
|
|
4151
|
+
severity: "warning",
|
|
4152
|
+
category: "security",
|
|
4153
|
+
fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
|
|
4154
|
+
});
|
|
4155
|
+
}
|
|
4156
|
+
});
|
|
4157
|
+
return findings;
|
|
4158
|
+
} catch {
|
|
4159
|
+
}
|
|
4160
|
+
}
|
|
3466
4161
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3467
4162
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3468
4163
|
const line = file.lines[i];
|