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/cli.js
CHANGED
|
@@ -76,7 +76,10 @@ function isTestFile(relativePath) {
|
|
|
76
76
|
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || /(?:^|\/)__tests__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
|
|
77
77
|
}
|
|
78
78
|
function isScriptFile(relativePath) {
|
|
79
|
-
|
|
79
|
+
if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
|
|
80
|
+
const name = relativePath.split("/").pop() ?? "";
|
|
81
|
+
const base = name.replace(/\.[^.]+$/, "");
|
|
82
|
+
return /^(seed|migrate|setup|bootstrap|generate|codegen|sync|deploy|cleanup|reset)$/.test(base);
|
|
80
83
|
}
|
|
81
84
|
function isConfigFile(relativePath) {
|
|
82
85
|
const name = relativePath.split("/").pop() ?? "";
|
|
@@ -245,6 +248,100 @@ function findLoopsAST(ast) {
|
|
|
245
248
|
});
|
|
246
249
|
return loops;
|
|
247
250
|
}
|
|
251
|
+
function getImportSources(ast) {
|
|
252
|
+
const sources = [];
|
|
253
|
+
walkAST(ast.program, (node) => {
|
|
254
|
+
if (node.type === "ImportDeclaration") {
|
|
255
|
+
const decl = node;
|
|
256
|
+
sources.push({ source: decl.source.value, line: decl.loc?.start.line ?? 1 });
|
|
257
|
+
}
|
|
258
|
+
if (node.type === "CallExpression") {
|
|
259
|
+
const call = node;
|
|
260
|
+
if (call.callee.type === "Identifier" && call.callee.name === "require" && call.arguments.length === 1 && call.arguments[0].type === "StringLiteral") {
|
|
261
|
+
sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
|
|
262
|
+
}
|
|
263
|
+
if (call.callee.type === "Import" && call.arguments.length >= 1 && call.arguments[0].type === "StringLiteral") {
|
|
264
|
+
sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return sources;
|
|
269
|
+
}
|
|
270
|
+
function isUserInputNode(node) {
|
|
271
|
+
if (node.type === "MemberExpression") {
|
|
272
|
+
const mem = node;
|
|
273
|
+
if (mem.object.type === "MemberExpression") {
|
|
274
|
+
const inner = mem.object;
|
|
275
|
+
if (inner.object.type === "Identifier") {
|
|
276
|
+
const objName = inner.object.name;
|
|
277
|
+
if (objName === "req" || objName === "request") {
|
|
278
|
+
if (inner.property.type === "Identifier") {
|
|
279
|
+
const prop = inner.property.name;
|
|
280
|
+
if (prop === "query" || prop === "body" || prop === "params") return true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (node.type === "CallExpression") {
|
|
287
|
+
const call = node;
|
|
288
|
+
if (call.callee.type === "MemberExpression") {
|
|
289
|
+
const callee = call.callee;
|
|
290
|
+
if (callee.property.type === "Identifier" && callee.property.name === "get") {
|
|
291
|
+
if (callee.object.type === "Identifier") {
|
|
292
|
+
const name = callee.object.name;
|
|
293
|
+
if (name === "searchParams" || name === "formData") return true;
|
|
294
|
+
}
|
|
295
|
+
if (callee.object.type === "MemberExpression") {
|
|
296
|
+
const inner = callee.object;
|
|
297
|
+
if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
function isStaticString(node) {
|
|
307
|
+
if (node.type === "StringLiteral") return true;
|
|
308
|
+
if (node.type === "TemplateLiteral") {
|
|
309
|
+
return node.expressions.length === 0;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
function findUseEffectRanges(ast) {
|
|
314
|
+
const ranges = [];
|
|
315
|
+
walkAST(ast.program, (node) => {
|
|
316
|
+
if (node.type !== "CallExpression") return;
|
|
317
|
+
const call = node;
|
|
318
|
+
if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
|
|
319
|
+
const callback = call.arguments[0];
|
|
320
|
+
if (!callback || !callback.loc) return;
|
|
321
|
+
ranges.push({
|
|
322
|
+
start: callback.loc.start.line - 1,
|
|
323
|
+
end: callback.loc.end.line - 1
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
return ranges;
|
|
327
|
+
}
|
|
328
|
+
function subtreeContains(node, predicate) {
|
|
329
|
+
if (predicate(node)) return true;
|
|
330
|
+
for (const key of Object.keys(node)) {
|
|
331
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
332
|
+
const val = node[key];
|
|
333
|
+
if (Array.isArray(val)) {
|
|
334
|
+
for (const item of val) {
|
|
335
|
+
if (item && typeof item === "object" && item.type) {
|
|
336
|
+
if (subtreeContains(item, predicate)) return true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
340
|
+
if (subtreeContains(val, predicate)) return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
248
345
|
|
|
249
346
|
// src/utils/frameworks.ts
|
|
250
347
|
var FRAMEWORK_SAFE_METHODS = {
|
|
@@ -364,6 +461,66 @@ async function readFileContext(root, relativePath) {
|
|
|
364
461
|
return null;
|
|
365
462
|
}
|
|
366
463
|
}
|
|
464
|
+
async function getWorkspacePatterns(root, packageJson) {
|
|
465
|
+
const patterns = [];
|
|
466
|
+
if (packageJson) {
|
|
467
|
+
const workspaces = packageJson.workspaces;
|
|
468
|
+
if (Array.isArray(workspaces)) {
|
|
469
|
+
patterns.push(...workspaces);
|
|
470
|
+
} else if (workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)) {
|
|
471
|
+
patterns.push(...workspaces.packages);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const raw = await readFile(resolve(root, "pnpm-workspace.yaml"), "utf-8");
|
|
476
|
+
let inPackages = false;
|
|
477
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
478
|
+
const trimmed = line.trim();
|
|
479
|
+
if (/^packages\s*:/.test(trimmed)) {
|
|
480
|
+
inPackages = true;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (inPackages) {
|
|
484
|
+
if (/^-\s+/.test(trimmed)) {
|
|
485
|
+
const glob = trimmed.replace(/^-\s+/, "").replace(/^['"]|['"]$/g, "");
|
|
486
|
+
if (glob) patterns.push(glob);
|
|
487
|
+
} else if (trimmed && !trimmed.startsWith("#")) {
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
return patterns;
|
|
495
|
+
}
|
|
496
|
+
async function collectWorkspaceDependencies(root, patterns) {
|
|
497
|
+
const deps = /* @__PURE__ */ new Set();
|
|
498
|
+
const globPatterns = patterns.map((p) => `${p}/package.json`);
|
|
499
|
+
try {
|
|
500
|
+
const pkgFiles = await fg(globPatterns, {
|
|
501
|
+
cwd: root,
|
|
502
|
+
absolute: false,
|
|
503
|
+
ignore: ["**/node_modules/**"]
|
|
504
|
+
});
|
|
505
|
+
for (const pkgFile of pkgFiles) {
|
|
506
|
+
try {
|
|
507
|
+
const raw = await readFile(resolve(root, pkgFile), "utf-8");
|
|
508
|
+
const pkg = JSON.parse(raw);
|
|
509
|
+
if (pkg.name) deps.add(pkg.name);
|
|
510
|
+
for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
511
|
+
if (pkg[key] && typeof pkg[key] === "object") {
|
|
512
|
+
for (const dep of Object.keys(pkg[key])) {
|
|
513
|
+
deps.add(dep);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
return deps;
|
|
523
|
+
}
|
|
367
524
|
async function buildProjectContext(root, files) {
|
|
368
525
|
let packageJson = null;
|
|
369
526
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
@@ -382,19 +539,26 @@ async function buildProjectContext(root, files) {
|
|
|
382
539
|
...packageJson?.peerDependencies ?? {}
|
|
383
540
|
};
|
|
384
541
|
declaredDependencies = new Set(Object.keys(deps));
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
const workspacePatterns = await getWorkspacePatterns(root, packageJson);
|
|
545
|
+
if (workspacePatterns.length > 0) {
|
|
546
|
+
const workspaceDeps = await collectWorkspaceDependencies(root, workspacePatterns);
|
|
547
|
+
for (const dep of workspaceDeps) {
|
|
548
|
+
declaredDependencies.add(dep);
|
|
390
549
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
550
|
+
}
|
|
551
|
+
for (const dep of declaredDependencies) {
|
|
552
|
+
const framework = DEPENDENCY_TO_FRAMEWORK[dep];
|
|
553
|
+
if (framework) {
|
|
554
|
+
detectedFrameworks.add(framework);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
for (const framework of detectedFrameworks) {
|
|
558
|
+
if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
|
|
559
|
+
hasRateLimiting = true;
|
|
560
|
+
break;
|
|
396
561
|
}
|
|
397
|
-
} catch {
|
|
398
562
|
}
|
|
399
563
|
try {
|
|
400
564
|
const raw = await readFile(resolve(root, "tsconfig.json"), "utf-8");
|
|
@@ -649,6 +813,32 @@ var hallucinatedImportsRule = {
|
|
|
649
813
|
const findings = [];
|
|
650
814
|
const seen = /* @__PURE__ */ new Set();
|
|
651
815
|
const isNonProd = isTestFile(file.relativePath) || isScriptFile(file.relativePath);
|
|
816
|
+
if (file.ast) {
|
|
817
|
+
try {
|
|
818
|
+
const imports = getImportSources(file.ast);
|
|
819
|
+
for (const { source: importPath, line } of imports) {
|
|
820
|
+
if (importPath.startsWith(".") || importPath.startsWith("/")) continue;
|
|
821
|
+
const pkgName = getPackageName(importPath);
|
|
822
|
+
if (seen.has(pkgName)) continue;
|
|
823
|
+
seen.add(pkgName);
|
|
824
|
+
if (isPathAlias(importPath, project.tsconfigPaths)) continue;
|
|
825
|
+
if (isNodeBuiltin(pkgName)) continue;
|
|
826
|
+
if (IMPLICIT_PACKAGES.has(importPath) || IMPLICIT_PACKAGES.has(pkgName)) continue;
|
|
827
|
+
if (project.declaredDependencies.has(pkgName)) continue;
|
|
828
|
+
findings.push({
|
|
829
|
+
ruleId: "hallucinated-imports",
|
|
830
|
+
file: file.relativePath,
|
|
831
|
+
line,
|
|
832
|
+
column: 1,
|
|
833
|
+
message: `Package "${pkgName}" is imported but not in package.json`,
|
|
834
|
+
severity: isNonProd ? "warning" : "critical",
|
|
835
|
+
category: "reliability"
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
return findings;
|
|
839
|
+
} catch {
|
|
840
|
+
}
|
|
841
|
+
}
|
|
652
842
|
for (let i = 0; i < file.lines.length; i++) {
|
|
653
843
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
654
844
|
const line = file.lines[i];
|
|
@@ -1147,6 +1337,81 @@ var unsafeHtmlRule = {
|
|
|
1147
1337
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1148
1338
|
check(file, _project) {
|
|
1149
1339
|
const findings = [];
|
|
1340
|
+
if (file.ast) {
|
|
1341
|
+
try {
|
|
1342
|
+
walkAST(file.ast.program, (node) => {
|
|
1343
|
+
if (node.type === "JSXAttribute") {
|
|
1344
|
+
const attr = node;
|
|
1345
|
+
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1346
|
+
if (attr.value && subtreeContains(attr.value, (n) => {
|
|
1347
|
+
if (n.type !== "CallExpression") return false;
|
|
1348
|
+
const call = n;
|
|
1349
|
+
if (call.callee.type === "MemberExpression") {
|
|
1350
|
+
const mem = call.callee;
|
|
1351
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1352
|
+
}
|
|
1353
|
+
return false;
|
|
1354
|
+
})) {
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
const line = attr.loc.start.line;
|
|
1358
|
+
findings.push({
|
|
1359
|
+
ruleId: "unsafe-html",
|
|
1360
|
+
file: file.relativePath,
|
|
1361
|
+
line,
|
|
1362
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1363
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1364
|
+
severity: "critical",
|
|
1365
|
+
category: "security"
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (node.type === "ObjectProperty") {
|
|
1370
|
+
const prop = node;
|
|
1371
|
+
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1372
|
+
if (prop.value && subtreeContains(prop.value, (n) => {
|
|
1373
|
+
if (n.type !== "CallExpression") return false;
|
|
1374
|
+
const call = n;
|
|
1375
|
+
if (call.callee.type === "MemberExpression") {
|
|
1376
|
+
const mem = call.callee;
|
|
1377
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1378
|
+
}
|
|
1379
|
+
return false;
|
|
1380
|
+
})) {
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const line = prop.loc.start.line;
|
|
1384
|
+
findings.push({
|
|
1385
|
+
ruleId: "unsafe-html",
|
|
1386
|
+
file: file.relativePath,
|
|
1387
|
+
line,
|
|
1388
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1389
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1390
|
+
severity: "critical",
|
|
1391
|
+
category: "security"
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (node.type === "AssignmentExpression") {
|
|
1396
|
+
const assign = node;
|
|
1397
|
+
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1398
|
+
const line = assign.loc.start.line;
|
|
1399
|
+
findings.push({
|
|
1400
|
+
ruleId: "unsafe-html",
|
|
1401
|
+
file: file.relativePath,
|
|
1402
|
+
line,
|
|
1403
|
+
column: file.lines[line - 1].indexOf(".innerHTML") + 1,
|
|
1404
|
+
message: "Direct innerHTML assignment is an XSS risk",
|
|
1405
|
+
severity: "critical",
|
|
1406
|
+
category: "security"
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
return findings;
|
|
1412
|
+
} catch {
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1150
1415
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1151
1416
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1152
1417
|
const line = file.lines[i];
|
|
@@ -1400,6 +1665,22 @@ var DIRECT_INPUT_PATTERNS = [
|
|
|
1400
1665
|
var WARNING_PATTERNS = [
|
|
1401
1666
|
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1402
1667
|
];
|
|
1668
|
+
var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
|
|
1669
|
+
"url",
|
|
1670
|
+
"returnUrl",
|
|
1671
|
+
"returnTo",
|
|
1672
|
+
"redirectUrl",
|
|
1673
|
+
"redirectTo",
|
|
1674
|
+
"next",
|
|
1675
|
+
"callbackUrl",
|
|
1676
|
+
"destination",
|
|
1677
|
+
"redirect",
|
|
1678
|
+
"goto",
|
|
1679
|
+
"to",
|
|
1680
|
+
"target",
|
|
1681
|
+
"uri",
|
|
1682
|
+
"href"
|
|
1683
|
+
]);
|
|
1403
1684
|
var openRedirectRule = {
|
|
1404
1685
|
id: "open-redirect",
|
|
1405
1686
|
name: "Open Redirect",
|
|
@@ -1409,6 +1690,62 @@ var openRedirectRule = {
|
|
|
1409
1690
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1410
1691
|
check(file, _project) {
|
|
1411
1692
|
const findings = [];
|
|
1693
|
+
if (file.ast) {
|
|
1694
|
+
try {
|
|
1695
|
+
walkAST(file.ast.program, (node) => {
|
|
1696
|
+
if (node.type !== "CallExpression") return;
|
|
1697
|
+
const call = node;
|
|
1698
|
+
if (!call.loc) return;
|
|
1699
|
+
let isRedirect = false;
|
|
1700
|
+
if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
|
|
1701
|
+
isRedirect = true;
|
|
1702
|
+
}
|
|
1703
|
+
if (call.callee.type === "MemberExpression") {
|
|
1704
|
+
const mem = call.callee;
|
|
1705
|
+
if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
|
|
1706
|
+
isRedirect = true;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
if (!isRedirect) return;
|
|
1710
|
+
const arg = call.arguments[0];
|
|
1711
|
+
if (!arg) return;
|
|
1712
|
+
if (isStaticString(arg)) return;
|
|
1713
|
+
let targetArg = arg;
|
|
1714
|
+
if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
|
|
1715
|
+
targetArg = arg.arguments[0];
|
|
1716
|
+
if (!targetArg) return;
|
|
1717
|
+
if (isStaticString(targetArg)) return;
|
|
1718
|
+
}
|
|
1719
|
+
const lineNum = call.loc.start.line;
|
|
1720
|
+
const col = call.loc.start.column + 1;
|
|
1721
|
+
if (isUserInputNode(targetArg)) {
|
|
1722
|
+
findings.push({
|
|
1723
|
+
ruleId: "open-redirect",
|
|
1724
|
+
file: file.relativePath,
|
|
1725
|
+
line: lineNum,
|
|
1726
|
+
column: col,
|
|
1727
|
+
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1728
|
+
severity: "warning",
|
|
1729
|
+
category: "security"
|
|
1730
|
+
});
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
|
|
1734
|
+
findings.push({
|
|
1735
|
+
ruleId: "open-redirect",
|
|
1736
|
+
file: file.relativePath,
|
|
1737
|
+
line: lineNum,
|
|
1738
|
+
column: col,
|
|
1739
|
+
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1740
|
+
severity: "warning",
|
|
1741
|
+
category: "security"
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
return findings;
|
|
1746
|
+
} catch {
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1412
1749
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1413
1750
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1414
1751
|
const line = file.lines[i];
|
|
@@ -2033,8 +2370,14 @@ function scoreCatchBody(bodyLines) {
|
|
|
2033
2370
|
const body = bodyLines.join("\n");
|
|
2034
2371
|
let score = 0;
|
|
2035
2372
|
if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
|
|
2036
|
-
if (/console\.(error|warn)\s*\(/.test(body) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2037
|
-
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))
|
|
2373
|
+
if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2374
|
+
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)
|
|
2375
|
+
/\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
|
|
2376
|
+
/\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
|
|
2377
|
+
/\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
|
|
2378
|
+
/\b(handleError|reportError|logError|notifyError|showError|onError|trackError)\s*\(/.test(body) || // Express middleware: next(err)
|
|
2379
|
+
/\bnext\s*\(\s*(err|error|e)\s*\)/.test(body) || // Next.js notFound()
|
|
2380
|
+
/\bnotFound\s*\(/.test(body)) {
|
|
2038
2381
|
score = 3;
|
|
2039
2382
|
}
|
|
2040
2383
|
const labels = {
|
|
@@ -2056,6 +2399,34 @@ var shallowCatchRule = {
|
|
|
2056
2399
|
if (isTestFile(file.relativePath)) return [];
|
|
2057
2400
|
if (isScriptFile(file.relativePath)) return [];
|
|
2058
2401
|
const findings = [];
|
|
2402
|
+
if (file.ast) {
|
|
2403
|
+
try {
|
|
2404
|
+
walkAST(file.ast.program, (node) => {
|
|
2405
|
+
if (node.type !== "CatchClause") return;
|
|
2406
|
+
if (!node.loc) return;
|
|
2407
|
+
const catchLine = node.loc.start.line - 1;
|
|
2408
|
+
const body = node.body;
|
|
2409
|
+
if (!body || !body.loc) return;
|
|
2410
|
+
const bodyStart = body.loc.start.line - 1;
|
|
2411
|
+
const bodyEnd = body.loc.end.line - 1;
|
|
2412
|
+
const bodyLines = bodyStart === bodyEnd ? [file.lines[bodyStart]] : file.lines.slice(bodyStart + 1, bodyEnd);
|
|
2413
|
+
const { score, label } = scoreCatchBody(bodyLines);
|
|
2414
|
+
if (score <= 1) {
|
|
2415
|
+
findings.push({
|
|
2416
|
+
ruleId: "shallow-catch",
|
|
2417
|
+
file: file.relativePath,
|
|
2418
|
+
line: catchLine + 1,
|
|
2419
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2420
|
+
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2421
|
+
severity: score === 0 ? "warning" : "info",
|
|
2422
|
+
category: "reliability"
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
return findings;
|
|
2427
|
+
} catch {
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2059
2430
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2060
2431
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2061
2432
|
const trimmed = file.lines[i].trim();
|
|
@@ -2389,6 +2760,7 @@ var insecureCookieRule = {
|
|
|
2389
2760
|
|
|
2390
2761
|
// src/rules/leaked-env-in-logs.ts
|
|
2391
2762
|
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2763
|
+
var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
|
|
2392
2764
|
var leakedEnvInLogsRule = {
|
|
2393
2765
|
id: "leaked-env-in-logs",
|
|
2394
2766
|
name: "Leaked Env in Logs",
|
|
@@ -2400,6 +2772,48 @@ var leakedEnvInLogsRule = {
|
|
|
2400
2772
|
if (isTestFile(file.relativePath)) return [];
|
|
2401
2773
|
if (isScriptFile(file.relativePath)) return [];
|
|
2402
2774
|
const findings = [];
|
|
2775
|
+
if (file.ast) {
|
|
2776
|
+
try {
|
|
2777
|
+
walkAST(file.ast.program, (node) => {
|
|
2778
|
+
if (node.type !== "CallExpression") return;
|
|
2779
|
+
const call = node;
|
|
2780
|
+
if (!call.loc) return;
|
|
2781
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2782
|
+
const mem = call.callee;
|
|
2783
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
|
|
2784
|
+
if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
|
|
2785
|
+
let hasEnvAccess = false;
|
|
2786
|
+
for (const arg of call.arguments) {
|
|
2787
|
+
if (subtreeContains(arg, (n) => {
|
|
2788
|
+
if (n.type !== "MemberExpression") return false;
|
|
2789
|
+
const m = n;
|
|
2790
|
+
if (m.object.type === "MemberExpression") {
|
|
2791
|
+
const inner = m.object;
|
|
2792
|
+
return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
|
|
2793
|
+
}
|
|
2794
|
+
return false;
|
|
2795
|
+
})) {
|
|
2796
|
+
hasEnvAccess = true;
|
|
2797
|
+
break;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
if (hasEnvAccess) {
|
|
2801
|
+
findings.push({
|
|
2802
|
+
ruleId: "leaked-env-in-logs",
|
|
2803
|
+
file: file.relativePath,
|
|
2804
|
+
line: call.loc.start.line,
|
|
2805
|
+
column: call.loc.start.column + 1,
|
|
2806
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2807
|
+
severity: "warning",
|
|
2808
|
+
category: "security",
|
|
2809
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
});
|
|
2813
|
+
return findings;
|
|
2814
|
+
} catch {
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2403
2817
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2404
2818
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2405
2819
|
const line = file.lines[i];
|
|
@@ -2504,6 +2918,15 @@ var nextServerActionValidationRule = {
|
|
|
2504
2918
|
// src/rules/missing-transaction.ts
|
|
2505
2919
|
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2506
2920
|
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2921
|
+
var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
|
|
2922
|
+
"create",
|
|
2923
|
+
"update",
|
|
2924
|
+
"delete",
|
|
2925
|
+
"upsert",
|
|
2926
|
+
"createMany",
|
|
2927
|
+
"updateMany",
|
|
2928
|
+
"deleteMany"
|
|
2929
|
+
]);
|
|
2507
2930
|
var missingTransactionRule = {
|
|
2508
2931
|
id: "missing-transaction",
|
|
2509
2932
|
name: "Missing Transaction",
|
|
@@ -2515,6 +2938,60 @@ var missingTransactionRule = {
|
|
|
2515
2938
|
if (isTestFile(file.relativePath)) return [];
|
|
2516
2939
|
if (isScriptFile(file.relativePath)) return [];
|
|
2517
2940
|
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2941
|
+
if (file.ast) {
|
|
2942
|
+
try {
|
|
2943
|
+
const parentMap = /* @__PURE__ */ new Map();
|
|
2944
|
+
walkAST(file.ast.program, (node, parent) => {
|
|
2945
|
+
if (parent) parentMap.set(node, parent);
|
|
2946
|
+
});
|
|
2947
|
+
const writesByScope = /* @__PURE__ */ new Map();
|
|
2948
|
+
const transactionScopes = /* @__PURE__ */ new Set();
|
|
2949
|
+
walkAST(file.ast.program, (node) => {
|
|
2950
|
+
if (node.type !== "CallExpression") return;
|
|
2951
|
+
const call = node;
|
|
2952
|
+
if (!call.loc) return;
|
|
2953
|
+
if (call.callee.type === "MemberExpression") {
|
|
2954
|
+
const mem = call.callee;
|
|
2955
|
+
if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
|
|
2956
|
+
const scope2 = findEnclosingFunction(node, parentMap);
|
|
2957
|
+
transactionScopes.add(scope2);
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2962
|
+
const outerMem = call.callee;
|
|
2963
|
+
if (outerMem.property.type !== "Identifier") return;
|
|
2964
|
+
if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
|
|
2965
|
+
if (outerMem.object.type !== "MemberExpression") return;
|
|
2966
|
+
const innerMem = outerMem.object;
|
|
2967
|
+
if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
|
|
2968
|
+
const scope = findEnclosingFunction(node, parentMap);
|
|
2969
|
+
const existing = writesByScope.get(scope);
|
|
2970
|
+
if (existing) {
|
|
2971
|
+
existing.count++;
|
|
2972
|
+
} else {
|
|
2973
|
+
writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
|
|
2974
|
+
}
|
|
2975
|
+
});
|
|
2976
|
+
const findings = [];
|
|
2977
|
+
for (const [scope, { count, firstLine }] of writesByScope) {
|
|
2978
|
+
if (count < 2) continue;
|
|
2979
|
+
if (transactionScopes.has(scope)) continue;
|
|
2980
|
+
findings.push({
|
|
2981
|
+
ruleId: "missing-transaction",
|
|
2982
|
+
file: file.relativePath,
|
|
2983
|
+
line: firstLine,
|
|
2984
|
+
column: 1,
|
|
2985
|
+
message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2986
|
+
severity: "warning",
|
|
2987
|
+
category: "reliability",
|
|
2988
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2989
|
+
});
|
|
2990
|
+
}
|
|
2991
|
+
return findings;
|
|
2992
|
+
} catch {
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2518
2995
|
let writeCount = 0;
|
|
2519
2996
|
let firstWriteLine = -1;
|
|
2520
2997
|
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
@@ -2538,6 +3015,16 @@ var missingTransactionRule = {
|
|
|
2538
3015
|
}];
|
|
2539
3016
|
}
|
|
2540
3017
|
};
|
|
3018
|
+
function findEnclosingFunction(node, parentMap) {
|
|
3019
|
+
let current = parentMap.get(node);
|
|
3020
|
+
while (current) {
|
|
3021
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
|
|
3022
|
+
return current;
|
|
3023
|
+
}
|
|
3024
|
+
current = parentMap.get(current);
|
|
3025
|
+
}
|
|
3026
|
+
return null;
|
|
3027
|
+
}
|
|
2541
3028
|
|
|
2542
3029
|
// src/rules/redirect-in-try-catch.ts
|
|
2543
3030
|
var redirectInTryCatchRule = {
|
|
@@ -3104,6 +3591,14 @@ var VALIDATION_PATTERNS3 = [
|
|
|
3104
3591
|
/\.startsWith\s*\(\s*['"]https?:\/\//,
|
|
3105
3592
|
/\.hostname\s*[!=]==?\s*['"`]/
|
|
3106
3593
|
];
|
|
3594
|
+
var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
|
|
3595
|
+
"url",
|
|
3596
|
+
"href",
|
|
3597
|
+
"endpoint",
|
|
3598
|
+
"target",
|
|
3599
|
+
"link",
|
|
3600
|
+
"src"
|
|
3601
|
+
]);
|
|
3107
3602
|
var ssrfRiskRule = {
|
|
3108
3603
|
id: "ssrf-risk",
|
|
3109
3604
|
name: "SSRF Risk",
|
|
@@ -3114,9 +3609,63 @@ var ssrfRiskRule = {
|
|
|
3114
3609
|
check(file, _project) {
|
|
3115
3610
|
if (isTestFile(file.relativePath)) return [];
|
|
3116
3611
|
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3612
|
+
const findings = [];
|
|
3613
|
+
if (file.ast) {
|
|
3614
|
+
try {
|
|
3615
|
+
const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3616
|
+
walkAST(file.ast.program, (node) => {
|
|
3617
|
+
if (node.type !== "CallExpression") return;
|
|
3618
|
+
const call = node;
|
|
3619
|
+
if (!call.loc) return;
|
|
3620
|
+
let isFetchLike = false;
|
|
3621
|
+
if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
|
|
3622
|
+
isFetchLike = true;
|
|
3623
|
+
}
|
|
3624
|
+
if (call.callee.type === "MemberExpression") {
|
|
3625
|
+
const mem = call.callee;
|
|
3626
|
+
if (mem.object.type === "Identifier" && mem.object.name === "axios") {
|
|
3627
|
+
isFetchLike = true;
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
if (!isFetchLike) return;
|
|
3631
|
+
const arg = call.arguments[0];
|
|
3632
|
+
if (!arg) return;
|
|
3633
|
+
if (isStaticString(arg)) return;
|
|
3634
|
+
const lineNum = call.loc.start.line;
|
|
3635
|
+
const col = call.loc.start.column + 1;
|
|
3636
|
+
if (isUserInputNode(arg)) {
|
|
3637
|
+
findings.push({
|
|
3638
|
+
ruleId: "ssrf-risk",
|
|
3639
|
+
file: file.relativePath,
|
|
3640
|
+
line: lineNum,
|
|
3641
|
+
column: col,
|
|
3642
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3643
|
+
severity: "warning",
|
|
3644
|
+
category: "security",
|
|
3645
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3646
|
+
});
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
|
|
3650
|
+
if (hasValidationInCode) return;
|
|
3651
|
+
findings.push({
|
|
3652
|
+
ruleId: "ssrf-risk",
|
|
3653
|
+
file: file.relativePath,
|
|
3654
|
+
line: lineNum,
|
|
3655
|
+
column: col,
|
|
3656
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3657
|
+
severity: "warning",
|
|
3658
|
+
category: "security",
|
|
3659
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3662
|
+
});
|
|
3663
|
+
return findings;
|
|
3664
|
+
} catch {
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3117
3667
|
const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3118
3668
|
if (hasValidation) return [];
|
|
3119
|
-
const findings = [];
|
|
3120
3669
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3121
3670
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3122
3671
|
const line = file.lines[i];
|
|
@@ -3158,6 +3707,25 @@ var SANITIZATION_PATTERNS = [
|
|
|
3158
3707
|
/sanitize/i,
|
|
3159
3708
|
/realpath/
|
|
3160
3709
|
];
|
|
3710
|
+
var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
|
|
3711
|
+
"readFile",
|
|
3712
|
+
"readFileSync",
|
|
3713
|
+
"createReadStream",
|
|
3714
|
+
"writeFile",
|
|
3715
|
+
"writeFileSync",
|
|
3716
|
+
"createWriteStream",
|
|
3717
|
+
"unlink",
|
|
3718
|
+
"unlinkSync",
|
|
3719
|
+
"rm",
|
|
3720
|
+
"rmSync"
|
|
3721
|
+
]);
|
|
3722
|
+
var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
|
|
3723
|
+
"filePath",
|
|
3724
|
+
"fileName",
|
|
3725
|
+
"path",
|
|
3726
|
+
"file",
|
|
3727
|
+
"name"
|
|
3728
|
+
]);
|
|
3161
3729
|
var pathTraversalRule = {
|
|
3162
3730
|
id: "path-traversal",
|
|
3163
3731
|
name: "Path Traversal",
|
|
@@ -3167,9 +3735,73 @@ var pathTraversalRule = {
|
|
|
3167
3735
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3168
3736
|
check(file, _project) {
|
|
3169
3737
|
if (isTestFile(file.relativePath)) return [];
|
|
3738
|
+
const findings = [];
|
|
3739
|
+
if (file.ast) {
|
|
3740
|
+
try {
|
|
3741
|
+
walkAST(file.ast.program, (node) => {
|
|
3742
|
+
if (node.type !== "CallExpression") return;
|
|
3743
|
+
const call = node;
|
|
3744
|
+
if (!call.loc) return;
|
|
3745
|
+
let fnName = null;
|
|
3746
|
+
if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
|
|
3747
|
+
fnName = call.callee.name;
|
|
3748
|
+
}
|
|
3749
|
+
if (call.callee.type === "MemberExpression") {
|
|
3750
|
+
const mem = call.callee;
|
|
3751
|
+
if (mem.property.type === "Identifier") {
|
|
3752
|
+
if (FS_FUNCTION_NAMES.has(mem.property.name)) {
|
|
3753
|
+
fnName = mem.property.name;
|
|
3754
|
+
}
|
|
3755
|
+
if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
|
|
3756
|
+
fnName = "path.join";
|
|
3757
|
+
}
|
|
3758
|
+
if (mem.property.name === "sendFile") {
|
|
3759
|
+
fnName = "sendFile";
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
if (!fnName) return;
|
|
3764
|
+
const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
|
|
3765
|
+
for (const arg of argsToCheck) {
|
|
3766
|
+
if (!arg) continue;
|
|
3767
|
+
if (isStaticString(arg)) continue;
|
|
3768
|
+
const lineNum = call.loc.start.line;
|
|
3769
|
+
const col = call.loc.start.column + 1;
|
|
3770
|
+
if (isUserInputNode(arg)) {
|
|
3771
|
+
const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
|
|
3772
|
+
findings.push({
|
|
3773
|
+
ruleId: "path-traversal",
|
|
3774
|
+
file: file.relativePath,
|
|
3775
|
+
line: lineNum,
|
|
3776
|
+
column: col,
|
|
3777
|
+
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`,
|
|
3778
|
+
severity: "critical",
|
|
3779
|
+
category: "security",
|
|
3780
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3781
|
+
});
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
3784
|
+
if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
|
|
3785
|
+
findings.push({
|
|
3786
|
+
ruleId: "path-traversal",
|
|
3787
|
+
file: file.relativePath,
|
|
3788
|
+
line: lineNum,
|
|
3789
|
+
column: col,
|
|
3790
|
+
message: `File operation with potentially user-controlled path \u2014 validate before use`,
|
|
3791
|
+
severity: "warning",
|
|
3792
|
+
category: "security",
|
|
3793
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3794
|
+
});
|
|
3795
|
+
return;
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
});
|
|
3799
|
+
return findings;
|
|
3800
|
+
} catch {
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3170
3803
|
const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
|
|
3171
3804
|
if (hasSanitization) return [];
|
|
3172
|
-
const findings = [];
|
|
3173
3805
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3174
3806
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3175
3807
|
const line = file.lines[i];
|
|
@@ -3221,18 +3853,31 @@ var hydrationMismatchRule = {
|
|
|
3221
3853
|
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3222
3854
|
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3223
3855
|
const findings = [];
|
|
3856
|
+
let useEffectRanges = [];
|
|
3857
|
+
if (file.ast) {
|
|
3858
|
+
try {
|
|
3859
|
+
useEffectRanges = findUseEffectRanges(file.ast);
|
|
3860
|
+
} catch {
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
|
|
3224
3864
|
let insideUseEffect = false;
|
|
3225
3865
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3226
3866
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3227
3867
|
const line = file.lines[i];
|
|
3228
|
-
if (
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
if (
|
|
3233
|
-
insideUseEffect =
|
|
3868
|
+
if (hasAstRanges) {
|
|
3869
|
+
const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
|
|
3870
|
+
if (inEffect) continue;
|
|
3871
|
+
} else {
|
|
3872
|
+
if (/\buseEffect\s*\(/.test(line)) {
|
|
3873
|
+
insideUseEffect = true;
|
|
3874
|
+
}
|
|
3875
|
+
if (insideUseEffect) {
|
|
3876
|
+
if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
|
|
3877
|
+
insideUseEffect = false;
|
|
3878
|
+
}
|
|
3879
|
+
continue;
|
|
3234
3880
|
}
|
|
3235
|
-
continue;
|
|
3236
3881
|
}
|
|
3237
3882
|
for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
|
|
3238
3883
|
const match = pattern.exec(line);
|
|
@@ -3457,6 +4102,7 @@ var HAS_EXPIRY = [
|
|
|
3457
4102
|
/expirationTime/,
|
|
3458
4103
|
/maxAge/
|
|
3459
4104
|
];
|
|
4105
|
+
var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
|
|
3460
4106
|
var jwtNoExpiryRule = {
|
|
3461
4107
|
id: "jwt-no-expiry",
|
|
3462
4108
|
name: "JWT Without Expiration",
|
|
@@ -3468,6 +4114,55 @@ var jwtNoExpiryRule = {
|
|
|
3468
4114
|
if (isTestFile(file.relativePath)) return [];
|
|
3469
4115
|
if (!JWT_SIGN.test(file.content)) return [];
|
|
3470
4116
|
const findings = [];
|
|
4117
|
+
if (file.ast) {
|
|
4118
|
+
try {
|
|
4119
|
+
walkAST(file.ast.program, (node) => {
|
|
4120
|
+
if (node.type !== "CallExpression") return;
|
|
4121
|
+
const call = node;
|
|
4122
|
+
if (!call.loc) return;
|
|
4123
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
4124
|
+
const mem = call.callee;
|
|
4125
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
|
|
4126
|
+
if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
|
|
4127
|
+
let hasExpiry = false;
|
|
4128
|
+
for (const arg of call.arguments) {
|
|
4129
|
+
if (arg.type === "ObjectExpression") {
|
|
4130
|
+
const obj = arg;
|
|
4131
|
+
for (const prop of obj.properties) {
|
|
4132
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
|
|
4133
|
+
hasExpiry = true;
|
|
4134
|
+
break;
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
if (hasExpiry) break;
|
|
4139
|
+
}
|
|
4140
|
+
if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
|
|
4141
|
+
const payload = call.arguments[0];
|
|
4142
|
+
for (const prop of payload.properties) {
|
|
4143
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
|
|
4144
|
+
hasExpiry = true;
|
|
4145
|
+
break;
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
if (!hasExpiry) {
|
|
4150
|
+
findings.push({
|
|
4151
|
+
ruleId: "jwt-no-expiry",
|
|
4152
|
+
file: file.relativePath,
|
|
4153
|
+
line: call.loc.start.line,
|
|
4154
|
+
column: call.loc.start.column + 1,
|
|
4155
|
+
message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
|
|
4156
|
+
severity: "warning",
|
|
4157
|
+
category: "security",
|
|
4158
|
+
fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
4161
|
+
});
|
|
4162
|
+
return findings;
|
|
4163
|
+
} catch {
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
3471
4166
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3472
4167
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3473
4168
|
const line = file.lines[i];
|