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