prodlint 0.3.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -26
- package/action.yml +2 -2
- package/dist/cli.js +1800 -169
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1668 -56
- package/dist/mcp.js +1670 -58
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -158,6 +158,133 @@ var NODE_BUILTINS = /* @__PURE__ */ new Set([
|
|
|
158
158
|
// node: prefixed are handled separately
|
|
159
159
|
]);
|
|
160
160
|
|
|
161
|
+
// src/utils/ast.ts
|
|
162
|
+
import { parse } from "@babel/parser";
|
|
163
|
+
function parseFile(content, fileName) {
|
|
164
|
+
const plugins = ["decorators"];
|
|
165
|
+
if (/\.tsx?$/.test(fileName)) {
|
|
166
|
+
plugins.push("typescript");
|
|
167
|
+
}
|
|
168
|
+
if (/\.[jt]sx$/.test(fileName)) {
|
|
169
|
+
plugins.push("jsx");
|
|
170
|
+
}
|
|
171
|
+
if (/\.(js|mjs|cjs)$/.test(fileName)) {
|
|
172
|
+
plugins.push("jsx");
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return parse(content, {
|
|
176
|
+
sourceType: "module",
|
|
177
|
+
allowImportExportEverywhere: true,
|
|
178
|
+
allowReturnOutsideFunction: true,
|
|
179
|
+
plugins
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function walkAST(node, visitor, parent = null) {
|
|
186
|
+
if (!node || typeof node !== "object") return;
|
|
187
|
+
visitor(node, parent);
|
|
188
|
+
for (const key of Object.keys(node)) {
|
|
189
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
190
|
+
const val = node[key];
|
|
191
|
+
if (Array.isArray(val)) {
|
|
192
|
+
for (const item of val) {
|
|
193
|
+
if (item && typeof item === "object" && item.type) {
|
|
194
|
+
walkAST(item, visitor, node);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
198
|
+
walkAST(val, visitor, node);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function isTaggedTemplateSql(node) {
|
|
203
|
+
const tag = node.tag;
|
|
204
|
+
if (tag.type === "Identifier" && tag.name === "sql") return true;
|
|
205
|
+
if (tag.type === "MemberExpression") {
|
|
206
|
+
const prop = tag.property;
|
|
207
|
+
if (prop.type === "Identifier" && (prop.name === "sql" || prop.name === "query" || prop.name === "raw")) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
function findLoopsAST(ast) {
|
|
214
|
+
const loops = [];
|
|
215
|
+
walkAST(ast.program, (node) => {
|
|
216
|
+
if (node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement" || node.type === "WhileStatement" || node.type === "DoWhileStatement") {
|
|
217
|
+
const loop = node;
|
|
218
|
+
const body = loop.body;
|
|
219
|
+
if (body.loc && node.loc) {
|
|
220
|
+
loops.push({
|
|
221
|
+
loopLine: node.loc.start.line - 1,
|
|
222
|
+
bodyStart: body.loc.start.line - 1,
|
|
223
|
+
bodyEnd: body.loc.end.line - 1
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (node.type === "CallExpression") {
|
|
228
|
+
const call = node;
|
|
229
|
+
if (call.callee.type === "MemberExpression" && call.callee.property.type === "Identifier" && (call.callee.property.name === "forEach" || call.callee.property.name === "map")) {
|
|
230
|
+
const callback = call.arguments[0];
|
|
231
|
+
if (callback && callback.loc && node.loc) {
|
|
232
|
+
loops.push({
|
|
233
|
+
loopLine: node.loc.start.line - 1,
|
|
234
|
+
bodyStart: callback.loc.start.line - 1,
|
|
235
|
+
bodyEnd: callback.loc.end.line - 1
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return loops;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/utils/frameworks.ts
|
|
245
|
+
var FRAMEWORK_SAFE_METHODS = {
|
|
246
|
+
prisma: ["contains", "startsWith", "endsWith", "has", "hasEvery", "hasSome", "isEmpty"],
|
|
247
|
+
supabase: ["contains", "containedBy", "overlaps", "eq", "neq", "gt", "gte", "lt", "lte"],
|
|
248
|
+
drizzle: ["arrayContains", "arrayContainedIn", "arrayOverlaps"],
|
|
249
|
+
lodash: ["flatten", "flattenDeep", "contains", "includes", "has"],
|
|
250
|
+
mongoose: ["contains"]
|
|
251
|
+
};
|
|
252
|
+
function isFrameworkSafeMethod(methodName, frameworks) {
|
|
253
|
+
for (const framework of frameworks) {
|
|
254
|
+
const safeMethods = FRAMEWORK_SAFE_METHODS[framework];
|
|
255
|
+
if (safeMethods && safeMethods.includes(methodName)) {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
var DEPENDENCY_TO_FRAMEWORK = {
|
|
262
|
+
"@prisma/client": "prisma",
|
|
263
|
+
"prisma": "prisma",
|
|
264
|
+
"@supabase/supabase-js": "supabase",
|
|
265
|
+
"@supabase/ssr": "supabase",
|
|
266
|
+
"drizzle-orm": "drizzle",
|
|
267
|
+
"@trpc/server": "trpc",
|
|
268
|
+
"next-auth": "next-auth",
|
|
269
|
+
"@auth/nextjs": "next-auth",
|
|
270
|
+
"@auth/core": "next-auth",
|
|
271
|
+
"express": "express",
|
|
272
|
+
"fastify": "fastify",
|
|
273
|
+
"hono": "hono",
|
|
274
|
+
"lodash": "lodash",
|
|
275
|
+
"lodash-es": "lodash",
|
|
276
|
+
"underscore": "lodash",
|
|
277
|
+
"mongoose": "mongoose",
|
|
278
|
+
"typeorm": "typeorm",
|
|
279
|
+
"sequelize": "sequelize",
|
|
280
|
+
"knex": "knex",
|
|
281
|
+
"@upstash/ratelimit": "upstash-ratelimit",
|
|
282
|
+
"express-rate-limit": "express-rate-limit",
|
|
283
|
+
"rate-limiter-flexible": "rate-limiter-flexible"
|
|
284
|
+
};
|
|
285
|
+
var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
|
|
286
|
+
var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
|
|
287
|
+
|
|
161
288
|
// src/utils/file-walker.ts
|
|
162
289
|
var DEFAULT_IGNORES = [
|
|
163
290
|
"**/node_modules/**",
|
|
@@ -182,8 +309,10 @@ var SCAN_EXTENSIONS = [
|
|
|
182
309
|
"jsx",
|
|
183
310
|
"mjs",
|
|
184
311
|
"cjs",
|
|
185
|
-
"json"
|
|
312
|
+
"json",
|
|
313
|
+
"sql"
|
|
186
314
|
];
|
|
315
|
+
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
187
316
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
188
317
|
async function walkFiles(root, extraIgnores = []) {
|
|
189
318
|
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
@@ -208,14 +337,23 @@ async function readFileContext(root, relativePath) {
|
|
|
208
337
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
209
338
|
const content = await readFile(absolutePath, "utf-8");
|
|
210
339
|
const lines = content.split(/\r?\n|\r/);
|
|
340
|
+
const ext = extname(relativePath).slice(1);
|
|
341
|
+
let ast = void 0;
|
|
342
|
+
if (AST_EXTENSIONS.has(ext)) {
|
|
343
|
+
try {
|
|
344
|
+
ast = parseFile(content, relativePath);
|
|
345
|
+
} catch {
|
|
346
|
+
ast = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
211
349
|
return {
|
|
212
350
|
absolutePath,
|
|
213
351
|
relativePath,
|
|
214
352
|
content,
|
|
215
353
|
lines,
|
|
216
|
-
ext
|
|
217
|
-
|
|
218
|
-
|
|
354
|
+
ext,
|
|
355
|
+
commentMap: buildCommentMap(lines),
|
|
356
|
+
ast
|
|
219
357
|
};
|
|
220
358
|
} catch {
|
|
221
359
|
return null;
|
|
@@ -226,6 +364,8 @@ async function buildProjectContext(root, files) {
|
|
|
226
364
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
227
365
|
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
228
366
|
let hasAuthMiddleware = false;
|
|
367
|
+
let hasRateLimiting = false;
|
|
368
|
+
const detectedFrameworks = /* @__PURE__ */ new Set();
|
|
229
369
|
let gitignoreContent = null;
|
|
230
370
|
let envInGitignore = false;
|
|
231
371
|
try {
|
|
@@ -237,6 +377,18 @@ async function buildProjectContext(root, files) {
|
|
|
237
377
|
...packageJson?.peerDependencies ?? {}
|
|
238
378
|
};
|
|
239
379
|
declaredDependencies = new Set(Object.keys(deps));
|
|
380
|
+
for (const dep of declaredDependencies) {
|
|
381
|
+
const framework = DEPENDENCY_TO_FRAMEWORK[dep];
|
|
382
|
+
if (framework) {
|
|
383
|
+
detectedFrameworks.add(framework);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const framework of detectedFrameworks) {
|
|
387
|
+
if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
|
|
388
|
+
hasRateLimiting = true;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
240
392
|
} catch {
|
|
241
393
|
}
|
|
242
394
|
try {
|
|
@@ -280,6 +432,18 @@ async function buildProjectContext(root, files) {
|
|
|
280
432
|
}
|
|
281
433
|
} catch {
|
|
282
434
|
}
|
|
435
|
+
if (!hasRateLimiting) {
|
|
436
|
+
for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
|
|
437
|
+
try {
|
|
438
|
+
const content = await readFile(resolve(root, name), "utf-8");
|
|
439
|
+
if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
|
|
440
|
+
hasRateLimiting = true;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
283
447
|
try {
|
|
284
448
|
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
285
449
|
envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
|
|
@@ -291,6 +455,8 @@ async function buildProjectContext(root, files) {
|
|
|
291
455
|
declaredDependencies,
|
|
292
456
|
tsconfigPaths,
|
|
293
457
|
hasAuthMiddleware,
|
|
458
|
+
hasRateLimiting,
|
|
459
|
+
detectedFrameworks,
|
|
294
460
|
gitignoreContent,
|
|
295
461
|
envInGitignore,
|
|
296
462
|
allFiles: files
|
|
@@ -315,26 +481,58 @@ function getVersion() {
|
|
|
315
481
|
|
|
316
482
|
// src/scorer.ts
|
|
317
483
|
var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
|
|
484
|
+
var CATEGORY_WEIGHTS = {
|
|
485
|
+
"security": 0.4,
|
|
486
|
+
"reliability": 0.3,
|
|
487
|
+
"performance": 0.15,
|
|
488
|
+
"ai-quality": 0.15
|
|
489
|
+
};
|
|
318
490
|
var DEDUCTIONS = {
|
|
319
|
-
critical:
|
|
320
|
-
warning:
|
|
321
|
-
info:
|
|
491
|
+
critical: 8,
|
|
492
|
+
warning: 2,
|
|
493
|
+
info: 0.5
|
|
494
|
+
};
|
|
495
|
+
var PER_RULE_CAP = {
|
|
496
|
+
critical: 1,
|
|
497
|
+
warning: 2,
|
|
498
|
+
info: 3
|
|
322
499
|
};
|
|
323
500
|
function calculateScores(findings) {
|
|
324
501
|
const categoryScores = CATEGORIES.map((category) => {
|
|
325
502
|
const categoryFindings = findings.filter((f) => f.category === category);
|
|
326
|
-
|
|
503
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
327
504
|
for (const f of categoryFindings) {
|
|
328
|
-
|
|
505
|
+
const arr = byRule.get(f.ruleId) ?? [];
|
|
506
|
+
arr.push(f);
|
|
507
|
+
byRule.set(f.ruleId, arr);
|
|
508
|
+
}
|
|
509
|
+
let totalDeduction = 0;
|
|
510
|
+
for (const [, ruleFindings] of byRule) {
|
|
511
|
+
const bySeverity = { critical: 0, warning: 0, info: 0 };
|
|
512
|
+
for (const f of ruleFindings) {
|
|
513
|
+
bySeverity[f.severity]++;
|
|
514
|
+
}
|
|
515
|
+
for (const sev of ["critical", "warning", "info"]) {
|
|
516
|
+
const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
|
|
517
|
+
totalDeduction += count * DEDUCTIONS[sev];
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
let effectiveDeduction;
|
|
521
|
+
if (totalDeduction <= 30) {
|
|
522
|
+
effectiveDeduction = totalDeduction;
|
|
523
|
+
} else if (totalDeduction <= 50) {
|
|
524
|
+
effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
|
|
525
|
+
} else {
|
|
526
|
+
effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
|
|
329
527
|
}
|
|
330
528
|
return {
|
|
331
529
|
category,
|
|
332
|
-
score: Math.max(0,
|
|
530
|
+
score: Math.max(0, Math.round(100 - effectiveDeduction)),
|
|
333
531
|
findingCount: categoryFindings.length
|
|
334
532
|
};
|
|
335
533
|
});
|
|
336
534
|
const overallScore = Math.round(
|
|
337
|
-
categoryScores.reduce((sum, c) => sum + c.score, 0)
|
|
535
|
+
categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
|
|
338
536
|
);
|
|
339
537
|
return { overallScore, categoryScores };
|
|
340
538
|
}
|
|
@@ -505,25 +703,36 @@ var AUTH_PATTERNS = [
|
|
|
505
703
|
/jwt\.verify\s*\(/,
|
|
506
704
|
/createRouteHandlerClient/,
|
|
507
705
|
/createServerComponentClient/,
|
|
706
|
+
/createMiddlewareClient/,
|
|
508
707
|
/authorization/i,
|
|
509
|
-
/
|
|
708
|
+
/getAuth\s*\(/,
|
|
709
|
+
/withPageAuth/,
|
|
710
|
+
/cookies\(\).*auth/s
|
|
510
711
|
];
|
|
712
|
+
var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
|
|
511
713
|
var authChecksRule = {
|
|
512
714
|
id: "auth-checks",
|
|
513
715
|
name: "Missing Auth Checks",
|
|
514
716
|
description: "Detects API routes that lack authentication checks",
|
|
515
717
|
category: "security",
|
|
516
|
-
severity: "
|
|
718
|
+
severity: "warning",
|
|
517
719
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
518
720
|
check(file, project) {
|
|
519
721
|
if (!isApiRoute(file.relativePath)) return [];
|
|
520
722
|
for (const pattern of AUTH_EXEMPT_PATTERNS) {
|
|
521
723
|
if (pattern.test(file.relativePath)) return [];
|
|
522
724
|
}
|
|
523
|
-
const severity = project.hasAuthMiddleware ? "info" : "critical";
|
|
524
725
|
for (const pattern of AUTH_PATTERNS) {
|
|
525
726
|
if (pattern.test(file.content)) return [];
|
|
526
727
|
}
|
|
728
|
+
let severity;
|
|
729
|
+
if (project.hasAuthMiddleware) {
|
|
730
|
+
severity = "info";
|
|
731
|
+
} else if (MUTATION_EXPORT.test(file.content)) {
|
|
732
|
+
severity = "critical";
|
|
733
|
+
} else {
|
|
734
|
+
severity = "info";
|
|
735
|
+
}
|
|
527
736
|
let handlerLine = 1;
|
|
528
737
|
for (let i = 0; i < file.lines.length; i++) {
|
|
529
738
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -620,7 +829,10 @@ var errorHandlingRule = {
|
|
|
620
829
|
if (!isApiRoute(file.relativePath)) return [];
|
|
621
830
|
const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
|
|
622
831
|
const hasTryCatch = /try\s*\{/.test(file.content);
|
|
623
|
-
|
|
832
|
+
const hasCatchChain = /\.catch\s*\(/.test(file.content);
|
|
833
|
+
const hasOnError = /onError\s*[:(]/.test(file.content);
|
|
834
|
+
const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
|
|
835
|
+
if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
|
|
624
836
|
let handlerLine = 1;
|
|
625
837
|
for (let i = 0; i < file.lines.length; i++) {
|
|
626
838
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -725,10 +937,11 @@ var rateLimitingRule = {
|
|
|
725
937
|
name: "Missing Rate Limiting",
|
|
726
938
|
description: "Detects API routes without rate limiting",
|
|
727
939
|
category: "security",
|
|
728
|
-
severity: "
|
|
940
|
+
severity: "info",
|
|
729
941
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
730
|
-
check(file,
|
|
942
|
+
check(file, project) {
|
|
731
943
|
if (!isApiRoute(file.relativePath)) return [];
|
|
944
|
+
if (project.hasRateLimiting) return [];
|
|
732
945
|
for (const pattern of EXEMPT_PATTERNS) {
|
|
733
946
|
if (pattern.test(file.relativePath)) return [];
|
|
734
947
|
}
|
|
@@ -748,7 +961,7 @@ var rateLimitingRule = {
|
|
|
748
961
|
line: handlerLine,
|
|
749
962
|
column: 1,
|
|
750
963
|
message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
|
|
751
|
-
severity: "
|
|
964
|
+
severity: "info",
|
|
752
965
|
category: "security"
|
|
753
966
|
}];
|
|
754
967
|
}
|
|
@@ -976,6 +1189,8 @@ var SQL_INJECTION_PATTERNS = [
|
|
|
976
1189
|
// .query() or .execute() with template literal
|
|
977
1190
|
{ pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
|
|
978
1191
|
];
|
|
1192
|
+
var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
|
|
1193
|
+
var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
|
|
979
1194
|
var sqlInjectionRule = {
|
|
980
1195
|
id: "sql-injection",
|
|
981
1196
|
name: "SQL Injection Risk",
|
|
@@ -983,20 +1198,42 @@ var sqlInjectionRule = {
|
|
|
983
1198
|
category: "security",
|
|
984
1199
|
severity: "critical",
|
|
985
1200
|
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
986
|
-
check(file,
|
|
1201
|
+
check(file, project) {
|
|
987
1202
|
const findings = [];
|
|
1203
|
+
const safeTaggedLines = /* @__PURE__ */ new Set();
|
|
1204
|
+
if (file.ast) {
|
|
1205
|
+
try {
|
|
1206
|
+
walkAST(file.ast.program, (node) => {
|
|
1207
|
+
if (node.type === "TaggedTemplateExpression") {
|
|
1208
|
+
const tagged = node;
|
|
1209
|
+
if (isTaggedTemplateSql(tagged) && tagged.loc) {
|
|
1210
|
+
for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
|
|
1211
|
+
safeTaggedLines.add(l);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
} catch {
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
|
|
988
1220
|
for (let i = 0; i < file.lines.length; i++) {
|
|
989
1221
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
990
1222
|
const line = file.lines[i];
|
|
1223
|
+
const lineNum = i + 1;
|
|
1224
|
+
if (safeTaggedLines.has(lineNum)) continue;
|
|
1225
|
+
if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
|
|
1226
|
+
if (PARAMETERIZED_QUERY.test(line)) continue;
|
|
991
1227
|
for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
|
|
992
1228
|
if (pattern.test(line)) {
|
|
1229
|
+
const severity = usesORM ? "warning" : "critical";
|
|
993
1230
|
findings.push({
|
|
994
1231
|
ruleId: "sql-injection",
|
|
995
1232
|
file: file.relativePath,
|
|
996
|
-
line:
|
|
1233
|
+
line: lineNum,
|
|
997
1234
|
column: 1,
|
|
998
1235
|
message,
|
|
999
|
-
severity
|
|
1236
|
+
severity,
|
|
1000
1237
|
category: "security"
|
|
1001
1238
|
});
|
|
1002
1239
|
break;
|
|
@@ -1018,7 +1255,7 @@ var PLACEHOLDERS = [
|
|
|
1018
1255
|
{ pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
|
|
1019
1256
|
{ pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
|
|
1020
1257
|
{ pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
|
|
1021
|
-
{ pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1258
|
+
{ pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1022
1259
|
{ pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
|
|
1023
1260
|
{ pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
|
|
1024
1261
|
];
|
|
@@ -1058,13 +1295,13 @@ var placeholderContentRule = {
|
|
|
1058
1295
|
// src/rules/stale-fallback.ts
|
|
1059
1296
|
var STALE_PATTERNS = [
|
|
1060
1297
|
{ pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
|
|
1061
|
-
{ pattern: /['"]https?:\/\/127\.0\.0\.1[
|
|
1062
|
-
{ pattern: /['"]redis:\/\/localhost['"]/, label: "Hardcoded Redis localhost URL" },
|
|
1063
|
-
{ pattern: /['"]mongodb:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1064
|
-
{ pattern: /['"]mongodb\+srv:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1065
|
-
{ pattern: /['"]postgres:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1066
|
-
{ pattern: /['"]postgresql:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1067
|
-
{ pattern: /['"]amqp:\/\/localhost['"]/, label: "Hardcoded AMQP localhost URL" }
|
|
1298
|
+
{ pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
|
|
1299
|
+
{ pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
|
|
1300
|
+
{ pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1301
|
+
{ pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1302
|
+
{ pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1303
|
+
{ pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1304
|
+
{ pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
|
|
1068
1305
|
];
|
|
1069
1306
|
var staleFallbackRule = {
|
|
1070
1307
|
id: "stale-fallback",
|
|
@@ -1103,15 +1340,15 @@ var staleFallbackRule = {
|
|
|
1103
1340
|
|
|
1104
1341
|
// src/rules/hallucinated-api.ts
|
|
1105
1342
|
var HALLUCINATED_APIS = [
|
|
1106
|
-
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()" },
|
|
1107
|
-
{ pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()" },
|
|
1108
|
-
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()" },
|
|
1109
|
-
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()" },
|
|
1110
|
-
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()" },
|
|
1111
|
-
{ pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()" },
|
|
1112
|
-
{ pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()" },
|
|
1113
|
-
{ pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)" },
|
|
1114
|
-
{ pattern:
|
|
1343
|
+
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1344
|
+
{ pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
|
|
1345
|
+
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1346
|
+
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1347
|
+
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
1348
|
+
{ pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
|
|
1349
|
+
{ pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
|
|
1350
|
+
{ pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
|
|
1351
|
+
{ pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
|
|
1115
1352
|
];
|
|
1116
1353
|
var hallucinatedApiRule = {
|
|
1117
1354
|
id: "hallucinated-api",
|
|
@@ -1120,14 +1357,16 @@ var hallucinatedApiRule = {
|
|
|
1120
1357
|
category: "ai-quality",
|
|
1121
1358
|
severity: "warning",
|
|
1122
1359
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1123
|
-
check(file,
|
|
1360
|
+
check(file, project) {
|
|
1124
1361
|
const findings = [];
|
|
1362
|
+
const frameworks = project.detectedFrameworks;
|
|
1125
1363
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1126
1364
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1127
1365
|
const line = file.lines[i];
|
|
1128
|
-
for (const { pattern, fix } of HALLUCINATED_APIS) {
|
|
1366
|
+
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
1129
1367
|
const match = pattern.exec(line);
|
|
1130
1368
|
if (match) {
|
|
1369
|
+
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
1131
1370
|
findings.push({
|
|
1132
1371
|
ruleId: "hallucinated-api",
|
|
1133
1372
|
file: file.relativePath,
|
|
@@ -1145,7 +1384,7 @@ var hallucinatedApiRule = {
|
|
|
1145
1384
|
};
|
|
1146
1385
|
|
|
1147
1386
|
// src/rules/open-redirect.ts
|
|
1148
|
-
var
|
|
1387
|
+
var DIRECT_INPUT_PATTERNS = [
|
|
1149
1388
|
// redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
|
|
1150
1389
|
/redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
|
|
1151
1390
|
// redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
|
|
@@ -1154,21 +1393,21 @@ var CRITICAL_PATTERNS = [
|
|
|
1154
1393
|
/NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
|
|
1155
1394
|
];
|
|
1156
1395
|
var WARNING_PATTERNS = [
|
|
1157
|
-
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
|
|
1396
|
+
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1158
1397
|
];
|
|
1159
1398
|
var openRedirectRule = {
|
|
1160
1399
|
id: "open-redirect",
|
|
1161
1400
|
name: "Open Redirect",
|
|
1162
1401
|
description: "Detects user-controlled input passed directly to redirect functions",
|
|
1163
1402
|
category: "security",
|
|
1164
|
-
severity: "
|
|
1403
|
+
severity: "warning",
|
|
1165
1404
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1166
1405
|
check(file, _project) {
|
|
1167
1406
|
const findings = [];
|
|
1168
1407
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1169
1408
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1170
1409
|
const line = file.lines[i];
|
|
1171
|
-
for (const pattern of
|
|
1410
|
+
for (const pattern of DIRECT_INPUT_PATTERNS) {
|
|
1172
1411
|
const match = pattern.exec(line);
|
|
1173
1412
|
if (match) {
|
|
1174
1413
|
findings.push({
|
|
@@ -1177,7 +1416,7 @@ var openRedirectRule = {
|
|
|
1177
1416
|
line: i + 1,
|
|
1178
1417
|
column: match.index + 1,
|
|
1179
1418
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1180
|
-
severity: "
|
|
1419
|
+
severity: "warning",
|
|
1181
1420
|
category: "security"
|
|
1182
1421
|
});
|
|
1183
1422
|
break;
|
|
@@ -1242,6 +1481,7 @@ var noSyncFsRule = {
|
|
|
1242
1481
|
|
|
1243
1482
|
// src/rules/no-n-plus-one.ts
|
|
1244
1483
|
var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
|
|
1484
|
+
var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
|
|
1245
1485
|
var noNPlusOneRule = {
|
|
1246
1486
|
id: "no-n-plus-one",
|
|
1247
1487
|
name: "No N+1 Queries",
|
|
@@ -1252,14 +1492,33 @@ var noNPlusOneRule = {
|
|
|
1252
1492
|
check(file, _project) {
|
|
1253
1493
|
if (isTestFile(file.relativePath)) return [];
|
|
1254
1494
|
if (isScriptFile(file.relativePath)) return [];
|
|
1495
|
+
const promiseAllMapLines = /* @__PURE__ */ new Set();
|
|
1496
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1497
|
+
if (PROMISE_ALL_MAP.test(file.lines[i])) {
|
|
1498
|
+
for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
|
|
1499
|
+
promiseAllMapLines.add(j);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1255
1503
|
const findings = [];
|
|
1256
|
-
|
|
1504
|
+
let loops;
|
|
1505
|
+
if (file.ast) {
|
|
1506
|
+
try {
|
|
1507
|
+
loops = findLoopsAST(file.ast);
|
|
1508
|
+
} catch {
|
|
1509
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1510
|
+
}
|
|
1511
|
+
} else {
|
|
1512
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1513
|
+
}
|
|
1257
1514
|
const reported = /* @__PURE__ */ new Set();
|
|
1258
1515
|
for (const loop of loops) {
|
|
1259
1516
|
if (reported.has(loop.loopLine)) continue;
|
|
1517
|
+
if (promiseAllMapLines.has(loop.loopLine)) continue;
|
|
1260
1518
|
for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
|
|
1261
1519
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1262
1520
|
const line = file.lines[i];
|
|
1521
|
+
if (promiseAllMapLines.has(i)) continue;
|
|
1263
1522
|
const match = DB_CALL_PATTERN.exec(line);
|
|
1264
1523
|
if (match) {
|
|
1265
1524
|
reported.add(loop.loopLine);
|
|
@@ -1393,6 +1652,10 @@ var HANDLED_PATTERNS = [
|
|
|
1393
1652
|
/Promise\.allSettled/,
|
|
1394
1653
|
/Promise\.race/
|
|
1395
1654
|
];
|
|
1655
|
+
var CHAIN_START_PATTERNS = [
|
|
1656
|
+
/\.from\s*\(/,
|
|
1657
|
+
/\.rpc\s*\(/
|
|
1658
|
+
];
|
|
1396
1659
|
var unhandledPromiseRule = {
|
|
1397
1660
|
id: "unhandled-promise",
|
|
1398
1661
|
name: "Unhandled Promise",
|
|
@@ -1412,6 +1675,19 @@ var unhandledPromiseRule = {
|
|
|
1412
1675
|
if (!asyncMatch) continue;
|
|
1413
1676
|
const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
|
|
1414
1677
|
if (isHandled) continue;
|
|
1678
|
+
const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
|
|
1679
|
+
if (isChainContinuation) {
|
|
1680
|
+
let chainHandled = false;
|
|
1681
|
+
for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
|
|
1682
|
+
const prevTrimmed = file.lines[j].trim();
|
|
1683
|
+
if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
|
|
1684
|
+
if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
|
|
1685
|
+
chainHandled = true;
|
|
1686
|
+
break;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (chainHandled) continue;
|
|
1690
|
+
}
|
|
1415
1691
|
let handledAbove = false;
|
|
1416
1692
|
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
1417
1693
|
const prevTrimmed = file.lines[j].trim();
|
|
@@ -1479,9 +1755,9 @@ var missingErrorBoundaryRule = {
|
|
|
1479
1755
|
severity: "info",
|
|
1480
1756
|
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
1481
1757
|
check(file, project) {
|
|
1482
|
-
const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1758
|
+
const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1483
1759
|
if (!match) return [];
|
|
1484
|
-
const dir =
|
|
1760
|
+
const dir = match[1] + match[2];
|
|
1485
1761
|
const hasErrorBoundary = project.allFiles.some(
|
|
1486
1762
|
(f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
|
|
1487
1763
|
);
|
|
@@ -1659,7 +1935,8 @@ var deadExportsRule = {
|
|
|
1659
1935
|
(f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
|
|
1660
1936
|
);
|
|
1661
1937
|
const exports = /* @__PURE__ */ new Map();
|
|
1662
|
-
const imports = /* @__PURE__ */ new
|
|
1938
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1939
|
+
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
1663
1940
|
const importedFiles = /* @__PURE__ */ new Set();
|
|
1664
1941
|
for (const file of sourceFiles) {
|
|
1665
1942
|
if (isEntryPoint(file.relativePath)) continue;
|
|
@@ -1683,14 +1960,28 @@ var deadExportsRule = {
|
|
|
1683
1960
|
for (const file of files) {
|
|
1684
1961
|
for (const line of file.lines) {
|
|
1685
1962
|
let match;
|
|
1963
|
+
const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
|
|
1964
|
+
const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
|
|
1686
1965
|
const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
|
|
1687
1966
|
while ((match = bracesRe.exec(line)) !== null) {
|
|
1688
1967
|
const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
|
|
1689
|
-
for (const sym of symbols)
|
|
1968
|
+
for (const sym of symbols) {
|
|
1969
|
+
allImportedSymbols.add(sym);
|
|
1970
|
+
if (fromBasename) {
|
|
1971
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1972
|
+
set.add(sym);
|
|
1973
|
+
imports.set(fromBasename, set);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1690
1976
|
}
|
|
1691
1977
|
const defaultRe = /import\s+(\w+)\s+from/g;
|
|
1692
1978
|
while ((match = defaultRe.exec(line)) !== null) {
|
|
1693
|
-
|
|
1979
|
+
allImportedSymbols.add(match[1]);
|
|
1980
|
+
if (fromBasename) {
|
|
1981
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1982
|
+
set.add(match[1]);
|
|
1983
|
+
imports.set(fromBasename, set);
|
|
1984
|
+
}
|
|
1694
1985
|
}
|
|
1695
1986
|
const fromRe = /from\s+['"]([^'"]+)['"]/g;
|
|
1696
1987
|
while ((match = fromRe.exec(line)) !== null) {
|
|
@@ -1701,7 +1992,10 @@ var deadExportsRule = {
|
|
|
1701
1992
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
1702
1993
|
for (const [key, loc] of exports) {
|
|
1703
1994
|
const symbolName = key.split("::")[1];
|
|
1704
|
-
|
|
1995
|
+
const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
|
|
1996
|
+
const importSet = imports.get(exportFileBasename);
|
|
1997
|
+
const isImported = importSet?.has(symbolName) ?? false;
|
|
1998
|
+
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
1705
1999
|
deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
|
|
1706
2000
|
}
|
|
1707
2001
|
}
|
|
@@ -1771,12 +2065,33 @@ var shallowCatchRule = {
|
|
|
1771
2065
|
if (braceStart === -1) continue;
|
|
1772
2066
|
let depth = 0;
|
|
1773
2067
|
let bodyEnd = braceStart;
|
|
2068
|
+
let inSingle = false;
|
|
2069
|
+
let inDouble = false;
|
|
2070
|
+
let inTemplate = false;
|
|
1774
2071
|
for (let j = braceStart; j < file.lines.length; j++) {
|
|
1775
2072
|
const line = file.lines[j];
|
|
1776
2073
|
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
1777
2074
|
for (let k = startPos; k < line.length; k++) {
|
|
1778
|
-
|
|
1779
|
-
|
|
2075
|
+
const ch = line[k];
|
|
2076
|
+
const prev = k > 0 ? line[k - 1] : "";
|
|
2077
|
+
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2078
|
+
if (!escaped) {
|
|
2079
|
+
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2080
|
+
inSingle = !inSingle;
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2084
|
+
inDouble = !inDouble;
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
if (ch === "`" && !inSingle && !inDouble) {
|
|
2088
|
+
inTemplate = !inTemplate;
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
2093
|
+
if (ch === "{") depth++;
|
|
2094
|
+
if (ch === "}") {
|
|
1780
2095
|
depth--;
|
|
1781
2096
|
if (depth === 0) {
|
|
1782
2097
|
bodyEnd = j;
|
|
@@ -1943,6 +2258,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
1943
2258
|
"gpt-tokenizer"
|
|
1944
2259
|
// exists but often confused
|
|
1945
2260
|
]);
|
|
2261
|
+
var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
2262
|
+
"pg",
|
|
2263
|
+
"ws",
|
|
2264
|
+
"ms",
|
|
2265
|
+
"qs",
|
|
2266
|
+
"ip",
|
|
2267
|
+
"is",
|
|
2268
|
+
"he",
|
|
2269
|
+
"ky",
|
|
2270
|
+
"bl",
|
|
2271
|
+
"rc",
|
|
2272
|
+
"io",
|
|
2273
|
+
"db",
|
|
2274
|
+
"fp",
|
|
2275
|
+
"rx"
|
|
2276
|
+
]);
|
|
1946
2277
|
var SUSPICIOUS_PATTERNS = [
|
|
1947
2278
|
/^[a-z]{1,2}$/,
|
|
1948
2279
|
// 1-2 char names
|
|
@@ -1981,7 +2312,7 @@ var phantomDependencyRule = {
|
|
|
1981
2312
|
});
|
|
1982
2313
|
}
|
|
1983
2314
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
1984
|
-
if (pattern.test(name) && !name.startsWith("@")) {
|
|
2315
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
|
|
1985
2316
|
findings.push({
|
|
1986
2317
|
ruleId: "phantom-dependency",
|
|
1987
2318
|
file: "package.json",
|
|
@@ -1999,6 +2330,1262 @@ var phantomDependencyRule = {
|
|
|
1999
2330
|
}
|
|
2000
2331
|
};
|
|
2001
2332
|
|
|
2333
|
+
// src/rules/insecure-cookie.ts
|
|
2334
|
+
var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
|
|
2335
|
+
var COOKIE_SET_PATTERNS = [
|
|
2336
|
+
/cookies\(\)\s*\.set\s*\(/,
|
|
2337
|
+
/res\.cookie\s*\(/,
|
|
2338
|
+
/response\.cookies\.set\s*\(/
|
|
2339
|
+
];
|
|
2340
|
+
var SECURE_OPTIONS = [
|
|
2341
|
+
/httpOnly\s*:\s*true/,
|
|
2342
|
+
/secure\s*:\s*true/,
|
|
2343
|
+
/sameSite\s*:/
|
|
2344
|
+
];
|
|
2345
|
+
var insecureCookieRule = {
|
|
2346
|
+
id: "insecure-cookie",
|
|
2347
|
+
name: "Insecure Cookie",
|
|
2348
|
+
description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
|
|
2349
|
+
category: "security",
|
|
2350
|
+
severity: "warning",
|
|
2351
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2352
|
+
check(file, _project) {
|
|
2353
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2354
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2355
|
+
const findings = [];
|
|
2356
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2357
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2358
|
+
const line = file.lines[i];
|
|
2359
|
+
const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
|
|
2360
|
+
if (!isCookieSet) continue;
|
|
2361
|
+
if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
|
|
2362
|
+
const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
|
|
2363
|
+
const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
|
|
2364
|
+
if (missingOptions.length > 0) {
|
|
2365
|
+
const missing = [];
|
|
2366
|
+
if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
|
|
2367
|
+
if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
|
|
2368
|
+
if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
|
|
2369
|
+
findings.push({
|
|
2370
|
+
ruleId: "insecure-cookie",
|
|
2371
|
+
file: file.relativePath,
|
|
2372
|
+
line: i + 1,
|
|
2373
|
+
column: 1,
|
|
2374
|
+
message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
|
|
2375
|
+
severity: "warning",
|
|
2376
|
+
category: "security",
|
|
2377
|
+
fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
return findings;
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
// src/rules/leaked-env-in-logs.ts
|
|
2386
|
+
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2387
|
+
var leakedEnvInLogsRule = {
|
|
2388
|
+
id: "leaked-env-in-logs",
|
|
2389
|
+
name: "Leaked Env in Logs",
|
|
2390
|
+
description: "Detects process.env values logged to console \u2014 potential secret exposure",
|
|
2391
|
+
category: "security",
|
|
2392
|
+
severity: "warning",
|
|
2393
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2394
|
+
check(file, _project) {
|
|
2395
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2396
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2397
|
+
const findings = [];
|
|
2398
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2399
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2400
|
+
const line = file.lines[i];
|
|
2401
|
+
const match = CONSOLE_WITH_ENV.exec(line);
|
|
2402
|
+
if (match) {
|
|
2403
|
+
findings.push({
|
|
2404
|
+
ruleId: "leaked-env-in-logs",
|
|
2405
|
+
file: file.relativePath,
|
|
2406
|
+
line: i + 1,
|
|
2407
|
+
column: match.index + 1,
|
|
2408
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2409
|
+
severity: "warning",
|
|
2410
|
+
category: "security",
|
|
2411
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
return findings;
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
// src/rules/insecure-random.ts
|
|
2420
|
+
var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
|
|
2421
|
+
var MATH_RANDOM = /Math\.random\s*\(\)/;
|
|
2422
|
+
var insecureRandomRule = {
|
|
2423
|
+
id: "insecure-random",
|
|
2424
|
+
name: "Insecure Random",
|
|
2425
|
+
description: "Detects Math.random() used near security-sensitive variable names",
|
|
2426
|
+
category: "security",
|
|
2427
|
+
severity: "warning",
|
|
2428
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2429
|
+
check(file, _project) {
|
|
2430
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2431
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2432
|
+
const findings = [];
|
|
2433
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2434
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2435
|
+
const line = file.lines[i];
|
|
2436
|
+
const match = MATH_RANDOM.exec(line);
|
|
2437
|
+
if (!match) continue;
|
|
2438
|
+
const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
|
|
2439
|
+
if (SECURITY_VAR_NAMES.test(context)) {
|
|
2440
|
+
findings.push({
|
|
2441
|
+
ruleId: "insecure-random",
|
|
2442
|
+
file: file.relativePath,
|
|
2443
|
+
line: i + 1,
|
|
2444
|
+
column: match.index + 1,
|
|
2445
|
+
message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
|
|
2446
|
+
severity: "warning",
|
|
2447
|
+
category: "security",
|
|
2448
|
+
fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
return findings;
|
|
2453
|
+
}
|
|
2454
|
+
};
|
|
2455
|
+
|
|
2456
|
+
// src/rules/next-server-action-validation.ts
|
|
2457
|
+
var USE_SERVER = /['"]use server['"]/;
|
|
2458
|
+
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
2459
|
+
var VALIDATION_PATTERNS2 = [
|
|
2460
|
+
/\.parse\s*\(/,
|
|
2461
|
+
/\.safeParse\s*\(/,
|
|
2462
|
+
/\bvalidate\s*\(/,
|
|
2463
|
+
/\.parseAsync\s*\(/,
|
|
2464
|
+
/\.safeParseAsync\s*\(/
|
|
2465
|
+
];
|
|
2466
|
+
var nextServerActionValidationRule = {
|
|
2467
|
+
id: "next-server-action-validation",
|
|
2468
|
+
name: "Next.js Server Action Validation",
|
|
2469
|
+
description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
|
|
2470
|
+
category: "security",
|
|
2471
|
+
severity: "critical",
|
|
2472
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2473
|
+
check(file, _project) {
|
|
2474
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2475
|
+
if (!USE_SERVER.test(file.content)) return [];
|
|
2476
|
+
if (!FORM_DATA_GET.test(file.content)) return [];
|
|
2477
|
+
const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
|
|
2478
|
+
if (hasValidation) return [];
|
|
2479
|
+
let reportLine = 1;
|
|
2480
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2481
|
+
if (FORM_DATA_GET.test(file.lines[i])) {
|
|
2482
|
+
reportLine = i + 1;
|
|
2483
|
+
break;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return [{
|
|
2487
|
+
ruleId: "next-server-action-validation",
|
|
2488
|
+
file: file.relativePath,
|
|
2489
|
+
line: reportLine,
|
|
2490
|
+
column: 1,
|
|
2491
|
+
message: "Server action reads formData without schema validation \u2014 unvalidated user input",
|
|
2492
|
+
severity: "critical",
|
|
2493
|
+
category: "security",
|
|
2494
|
+
fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
|
|
2495
|
+
}];
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
|
|
2499
|
+
// src/rules/missing-transaction.ts
|
|
2500
|
+
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2501
|
+
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2502
|
+
var missingTransactionRule = {
|
|
2503
|
+
id: "missing-transaction",
|
|
2504
|
+
name: "Missing Transaction",
|
|
2505
|
+
description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
|
|
2506
|
+
category: "reliability",
|
|
2507
|
+
severity: "warning",
|
|
2508
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2509
|
+
check(file, project) {
|
|
2510
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2511
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2512
|
+
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2513
|
+
let writeCount = 0;
|
|
2514
|
+
let firstWriteLine = -1;
|
|
2515
|
+
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
2516
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2517
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2518
|
+
if (PRISMA_WRITE_OPS.test(file.lines[i])) {
|
|
2519
|
+
writeCount++;
|
|
2520
|
+
if (firstWriteLine === -1) firstWriteLine = i;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
if (writeCount < 2 || hasTransaction) return [];
|
|
2524
|
+
return [{
|
|
2525
|
+
ruleId: "missing-transaction",
|
|
2526
|
+
file: file.relativePath,
|
|
2527
|
+
line: firstWriteLine + 1,
|
|
2528
|
+
column: 1,
|
|
2529
|
+
message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2530
|
+
severity: "warning",
|
|
2531
|
+
category: "reliability",
|
|
2532
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2533
|
+
}];
|
|
2534
|
+
}
|
|
2535
|
+
};
|
|
2536
|
+
|
|
2537
|
+
// src/rules/redirect-in-try-catch.ts
|
|
2538
|
+
var redirectInTryCatchRule = {
|
|
2539
|
+
id: "redirect-in-try-catch",
|
|
2540
|
+
name: "Redirect Inside Try/Catch",
|
|
2541
|
+
description: "Detects Next.js redirect() inside try/catch blocks \u2014 redirect throws internally and the catch swallows it",
|
|
2542
|
+
category: "reliability",
|
|
2543
|
+
severity: "critical",
|
|
2544
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2545
|
+
check(file, _project) {
|
|
2546
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2547
|
+
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
2548
|
+
const findings = [];
|
|
2549
|
+
let tryDepth = 0;
|
|
2550
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2551
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2552
|
+
const line = file.lines[i];
|
|
2553
|
+
const trimmed = line.trim();
|
|
2554
|
+
if (/\btry\s*\{/.test(trimmed) || trimmed === "try {") {
|
|
2555
|
+
tryDepth++;
|
|
2556
|
+
}
|
|
2557
|
+
if (/\}\s*catch\s*[\s(]/.test(trimmed)) {
|
|
2558
|
+
}
|
|
2559
|
+
if (tryDepth > 0) {
|
|
2560
|
+
const match = /\bredirect\s*\(/.exec(line);
|
|
2561
|
+
if (match && !/\/\//.test(line.slice(0, match.index))) {
|
|
2562
|
+
findings.push({
|
|
2563
|
+
ruleId: "redirect-in-try-catch",
|
|
2564
|
+
file: file.relativePath,
|
|
2565
|
+
line: i + 1,
|
|
2566
|
+
column: match.index + 1,
|
|
2567
|
+
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
2568
|
+
severity: "critical",
|
|
2569
|
+
category: "reliability",
|
|
2570
|
+
fix: 'Move redirect() outside the try/catch block, or re-throw redirect errors in the catch: if (e instanceof Error && e.message === "NEXT_REDIRECT") throw e'
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
for (const ch of trimmed) {
|
|
2575
|
+
if (ch === "{" && tryDepth > 0) {
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
if (tryDepth > 0 && /^\}\s*$/.test(trimmed)) {
|
|
2579
|
+
let nextLine = "";
|
|
2580
|
+
for (let j = i + 1; j < file.lines.length; j++) {
|
|
2581
|
+
nextLine = file.lines[j].trim();
|
|
2582
|
+
if (nextLine) break;
|
|
2583
|
+
}
|
|
2584
|
+
if (!/^catch\b/.test(nextLine) && !/^finally\b/.test(nextLine)) {
|
|
2585
|
+
tryDepth--;
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
return findings;
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
|
|
2593
|
+
// src/rules/missing-revalidation.ts
|
|
2594
|
+
var USE_SERVER2 = /['"]use server['"]/;
|
|
2595
|
+
var DB_MUTATIONS = [
|
|
2596
|
+
/\.insert\s*\(/,
|
|
2597
|
+
/\.update\s*\(/,
|
|
2598
|
+
/\.delete\s*\(/,
|
|
2599
|
+
/\.upsert\s*\(/,
|
|
2600
|
+
/\.create\s*\(/,
|
|
2601
|
+
/\.createMany\s*\(/,
|
|
2602
|
+
/\.updateMany\s*\(/,
|
|
2603
|
+
/\.deleteMany\s*\(/,
|
|
2604
|
+
/\.remove\s*\(/,
|
|
2605
|
+
/\.save\s*\(/,
|
|
2606
|
+
/\.destroy\s*\(/
|
|
2607
|
+
];
|
|
2608
|
+
var REVALIDATION = [
|
|
2609
|
+
/revalidatePath\s*\(/,
|
|
2610
|
+
/revalidateTag\s*\(/,
|
|
2611
|
+
/redirect\s*\(/
|
|
2612
|
+
];
|
|
2613
|
+
var missingRevalidationRule = {
|
|
2614
|
+
id: "missing-revalidation",
|
|
2615
|
+
name: "Missing Revalidation After Mutation",
|
|
2616
|
+
description: "Detects server actions that mutate data without calling revalidatePath or revalidateTag \u2014 UI shows stale data",
|
|
2617
|
+
category: "reliability",
|
|
2618
|
+
severity: "warning",
|
|
2619
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2620
|
+
check(file, _project) {
|
|
2621
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2622
|
+
if (!USE_SERVER2.test(file.content)) return [];
|
|
2623
|
+
const hasMutation = DB_MUTATIONS.some((p) => p.test(file.content));
|
|
2624
|
+
if (!hasMutation) return [];
|
|
2625
|
+
const hasRevalidation = REVALIDATION.some((p) => p.test(file.content));
|
|
2626
|
+
if (hasRevalidation) return [];
|
|
2627
|
+
let reportLine = 1;
|
|
2628
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2629
|
+
if (DB_MUTATIONS.some((p) => p.test(file.lines[i]))) {
|
|
2630
|
+
reportLine = i + 1;
|
|
2631
|
+
break;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
return [{
|
|
2635
|
+
ruleId: "missing-revalidation",
|
|
2636
|
+
file: file.relativePath,
|
|
2637
|
+
line: reportLine,
|
|
2638
|
+
column: 1,
|
|
2639
|
+
message: "Server action mutates data without revalidatePath() or revalidateTag() \u2014 UI will show stale data",
|
|
2640
|
+
severity: "warning",
|
|
2641
|
+
category: "reliability",
|
|
2642
|
+
fix: 'Add revalidatePath("/affected-route") after the mutation'
|
|
2643
|
+
}];
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2647
|
+
// src/rules/use-client-overuse.ts
|
|
2648
|
+
var CLIENT_APIS = [
|
|
2649
|
+
/\buseState\b/,
|
|
2650
|
+
/\buseEffect\b/,
|
|
2651
|
+
/\buseRef\b/,
|
|
2652
|
+
/\buseReducer\b/,
|
|
2653
|
+
/\buseCallback\b/,
|
|
2654
|
+
/\buseMemo\b/,
|
|
2655
|
+
/\buseContext\b/,
|
|
2656
|
+
/\buseLayoutEffect\b/,
|
|
2657
|
+
/\buseInsertionEffect\b/,
|
|
2658
|
+
/\buseTransition\b/,
|
|
2659
|
+
/\buseDeferredValue\b/,
|
|
2660
|
+
/\buseSyncExternalStore\b/,
|
|
2661
|
+
/\buseFormStatus\b/,
|
|
2662
|
+
/\buseFormState\b/,
|
|
2663
|
+
/\buseOptimistic\b/,
|
|
2664
|
+
/\bonClick\b\s*[=:]/,
|
|
2665
|
+
/\bonChange\b\s*[=:]/,
|
|
2666
|
+
/\bonSubmit\b\s*[=:]/,
|
|
2667
|
+
/\bonBlur\b\s*[=:]/,
|
|
2668
|
+
/\bonFocus\b\s*[=:]/,
|
|
2669
|
+
/\bonKeyDown\b\s*[=:]/,
|
|
2670
|
+
/\bonKeyUp\b\s*[=:]/,
|
|
2671
|
+
/\bonMouseDown\b\s*[=:]/,
|
|
2672
|
+
/\bonMouseUp\b\s*[=:]/,
|
|
2673
|
+
/\bonScroll\b\s*[=:]/,
|
|
2674
|
+
/\bonInput\b\s*[=:]/,
|
|
2675
|
+
/\bonDrag\b/,
|
|
2676
|
+
/\bonDrop\b/,
|
|
2677
|
+
/\bonTouchStart\b/,
|
|
2678
|
+
/\bcreateContext\b/,
|
|
2679
|
+
/\bwindow\./,
|
|
2680
|
+
/\bdocument\./,
|
|
2681
|
+
/\blocalStorage\b/,
|
|
2682
|
+
/\bsessionStorage\b/,
|
|
2683
|
+
/\bnavigator\b/,
|
|
2684
|
+
/\bIntersectionObserver\b/,
|
|
2685
|
+
/\bResizeObserver\b/,
|
|
2686
|
+
/\bMutationObserver\b/
|
|
2687
|
+
];
|
|
2688
|
+
var useClientOveruseRule = {
|
|
2689
|
+
id: "use-client-overuse",
|
|
2690
|
+
name: '"use client" Overuse',
|
|
2691
|
+
description: `Detects files with "use client" that don't use any client-side APIs \u2014 unnecessary client rendering`,
|
|
2692
|
+
category: "ai-quality",
|
|
2693
|
+
severity: "info",
|
|
2694
|
+
fileExtensions: ["tsx", "jsx"],
|
|
2695
|
+
check(file, _project) {
|
|
2696
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2697
|
+
if (isConfigFile(file.relativePath)) return [];
|
|
2698
|
+
if (!isClientComponent(file.content)) return [];
|
|
2699
|
+
const usesClientApi = CLIENT_APIS.some((p) => p.test(file.content));
|
|
2700
|
+
if (usesClientApi) return [];
|
|
2701
|
+
return [{
|
|
2702
|
+
ruleId: "use-client-overuse",
|
|
2703
|
+
file: file.relativePath,
|
|
2704
|
+
line: 1,
|
|
2705
|
+
column: 1,
|
|
2706
|
+
message: '"use client" directive but no client-side APIs (hooks, event handlers, browser APIs) \u2014 this component could be a server component',
|
|
2707
|
+
severity: "info",
|
|
2708
|
+
category: "ai-quality",
|
|
2709
|
+
fix: 'Remove "use client" to let Next.js render this as a server component for better performance'
|
|
2710
|
+
}];
|
|
2711
|
+
}
|
|
2712
|
+
};
|
|
2713
|
+
|
|
2714
|
+
// src/rules/env-fallback-secret.ts
|
|
2715
|
+
var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
|
|
2716
|
+
var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
|
|
2717
|
+
var envFallbackSecretRule = {
|
|
2718
|
+
id: "env-fallback-secret",
|
|
2719
|
+
name: "Secret with Fallback Value",
|
|
2720
|
+
description: "Detects security-sensitive env vars with hardcoded fallback values \u2014 if the env var is missing, the fallback becomes the production secret",
|
|
2721
|
+
category: "security",
|
|
2722
|
+
severity: "critical",
|
|
2723
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
2724
|
+
check(file, _project) {
|
|
2725
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2726
|
+
const findings = [];
|
|
2727
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2728
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2729
|
+
const line = file.lines[i];
|
|
2730
|
+
const directMatch = SENSITIVE_ENV.exec(line);
|
|
2731
|
+
if (directMatch) {
|
|
2732
|
+
findings.push({
|
|
2733
|
+
ruleId: "env-fallback-secret",
|
|
2734
|
+
file: file.relativePath,
|
|
2735
|
+
line: i + 1,
|
|
2736
|
+
column: directMatch.index + 1,
|
|
2737
|
+
message: `Secret env var has a hardcoded fallback \u2014 if ${directMatch[1] || "the var"} is unset, this literal becomes the production secret`,
|
|
2738
|
+
severity: "critical",
|
|
2739
|
+
category: "security",
|
|
2740
|
+
fix: 'Throw an error if the env var is missing: const secret = process.env.SECRET ?? (() => { throw new Error("SECRET is required") })()'
|
|
2741
|
+
});
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
const genericMatch = ENV_FALLBACK.exec(line);
|
|
2745
|
+
if (genericMatch && !isConfigFile(file.relativePath)) {
|
|
2746
|
+
findings.push({
|
|
2747
|
+
ruleId: "env-fallback-secret",
|
|
2748
|
+
file: file.relativePath,
|
|
2749
|
+
line: i + 1,
|
|
2750
|
+
column: genericMatch.index + 1,
|
|
2751
|
+
message: "Security-sensitive env var has a hardcoded fallback \u2014 defaults to a literal string when missing",
|
|
2752
|
+
severity: "warning",
|
|
2753
|
+
category: "security",
|
|
2754
|
+
fix: "Fail fast when required env vars are missing instead of falling back to a default value"
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
return findings;
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
|
|
2762
|
+
// src/rules/verbose-error-response.ts
|
|
2763
|
+
var ERROR_LEAK_PATTERNS = [
|
|
2764
|
+
{ pattern: /error\.stack/, msg: "error.stack exposed \u2014 leaks internal file paths and code structure" },
|
|
2765
|
+
{ pattern: /error\.message/, msg: "error.message may leak internal details to clients" }
|
|
2766
|
+
];
|
|
2767
|
+
var verboseErrorResponseRule = {
|
|
2768
|
+
id: "verbose-error-response",
|
|
2769
|
+
name: "Verbose Error Response",
|
|
2770
|
+
description: "Detects error details (stack traces, error messages) sent directly in API responses",
|
|
2771
|
+
category: "security",
|
|
2772
|
+
severity: "warning",
|
|
2773
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2774
|
+
check(file, _project) {
|
|
2775
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2776
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
2777
|
+
const findings = [];
|
|
2778
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2779
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2780
|
+
const line = file.lines[i];
|
|
2781
|
+
for (const { pattern, msg } of ERROR_LEAK_PATTERNS) {
|
|
2782
|
+
const match = pattern.exec(line);
|
|
2783
|
+
if (match) {
|
|
2784
|
+
const severity = pattern.source.includes("stack") ? "warning" : "info";
|
|
2785
|
+
findings.push({
|
|
2786
|
+
ruleId: "verbose-error-response",
|
|
2787
|
+
file: file.relativePath,
|
|
2788
|
+
line: i + 1,
|
|
2789
|
+
column: match.index + 1,
|
|
2790
|
+
message: msg,
|
|
2791
|
+
severity,
|
|
2792
|
+
category: "security",
|
|
2793
|
+
fix: 'Return a generic error message: { error: "Internal server error" }. Log the real error server-side.'
|
|
2794
|
+
});
|
|
2795
|
+
break;
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
return findings;
|
|
2800
|
+
}
|
|
2801
|
+
};
|
|
2802
|
+
|
|
2803
|
+
// src/rules/missing-webhook-verification.ts
|
|
2804
|
+
var WEBHOOK_PATH = /webhook/i;
|
|
2805
|
+
var VERIFICATION_PATTERNS = [
|
|
2806
|
+
/constructEvent\s*\(/,
|
|
2807
|
+
// Stripe
|
|
2808
|
+
/webhooks\.verify\s*\(/,
|
|
2809
|
+
// Clerk, GitHub
|
|
2810
|
+
/verify\s*\(/,
|
|
2811
|
+
// Generic
|
|
2812
|
+
/verifySignature\s*\(/,
|
|
2813
|
+
// Generic
|
|
2814
|
+
/validateWebhook\s*\(/,
|
|
2815
|
+
// Generic
|
|
2816
|
+
/svix.*verify/i,
|
|
2817
|
+
// Svix (used by Clerk, Resend)
|
|
2818
|
+
/crypto\.timingSafeEqual\s*\(/,
|
|
2819
|
+
// Manual HMAC comparison
|
|
2820
|
+
/hmac/i,
|
|
2821
|
+
// HMAC verification
|
|
2822
|
+
/x-hub-signature/i,
|
|
2823
|
+
// GitHub webhooks
|
|
2824
|
+
/stripe-signature/i,
|
|
2825
|
+
// Stripe signature header
|
|
2826
|
+
/svix-signature/i,
|
|
2827
|
+
// Svix signature header
|
|
2828
|
+
/webhook-secret/i
|
|
2829
|
+
];
|
|
2830
|
+
var missingWebhookVerificationRule = {
|
|
2831
|
+
id: "missing-webhook-verification",
|
|
2832
|
+
name: "Missing Webhook Verification",
|
|
2833
|
+
description: "Detects webhook endpoints without signature verification \u2014 anyone can send fake events",
|
|
2834
|
+
category: "security",
|
|
2835
|
+
severity: "critical",
|
|
2836
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2837
|
+
check(file, _project) {
|
|
2838
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2839
|
+
if (!isApiRoute(file.relativePath)) return [];
|
|
2840
|
+
if (!WEBHOOK_PATH.test(file.relativePath)) return [];
|
|
2841
|
+
const hasVerification = VERIFICATION_PATTERNS.some((p) => p.test(file.content));
|
|
2842
|
+
if (hasVerification) return [];
|
|
2843
|
+
let handlerLine = 1;
|
|
2844
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2845
|
+
if (/export\s+(async\s+)?function\s+POST\b/.test(file.lines[i])) {
|
|
2846
|
+
handlerLine = i + 1;
|
|
2847
|
+
break;
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
return [{
|
|
2851
|
+
ruleId: "missing-webhook-verification",
|
|
2852
|
+
file: file.relativePath,
|
|
2853
|
+
line: handlerLine,
|
|
2854
|
+
column: 1,
|
|
2855
|
+
message: "Webhook endpoint has no signature verification \u2014 anyone can forge events to this route",
|
|
2856
|
+
severity: "critical",
|
|
2857
|
+
category: "security",
|
|
2858
|
+
fix: "Verify the webhook signature before processing. For Stripe: stripe.webhooks.constructEvent(body, sig, secret)"
|
|
2859
|
+
}];
|
|
2860
|
+
}
|
|
2861
|
+
};
|
|
2862
|
+
|
|
2863
|
+
// src/rules/server-action-auth.ts
|
|
2864
|
+
var USE_SERVER3 = /['"]use server['"]/;
|
|
2865
|
+
var AUTH_PATTERNS2 = [
|
|
2866
|
+
/getServerSession\s*\(/,
|
|
2867
|
+
/getSession\s*\(/,
|
|
2868
|
+
/\.auth\.getUser\s*\(/,
|
|
2869
|
+
/auth\(\)/,
|
|
2870
|
+
/authenticate\s*\(/,
|
|
2871
|
+
/isAuthenticated/,
|
|
2872
|
+
/requireAuth/,
|
|
2873
|
+
/withAuth/,
|
|
2874
|
+
/getToken\s*\(/,
|
|
2875
|
+
/verifyToken\s*\(/,
|
|
2876
|
+
/jwt\.verify\s*\(/,
|
|
2877
|
+
/createServerComponentClient/,
|
|
2878
|
+
/currentUser\s*\(/,
|
|
2879
|
+
/getAuth\s*\(/,
|
|
2880
|
+
/cookies\(\).*auth/s,
|
|
2881
|
+
/session/i
|
|
2882
|
+
];
|
|
2883
|
+
var PUBLIC_ACTION_NAMES = [
|
|
2884
|
+
/contact/i,
|
|
2885
|
+
/subscribe/i,
|
|
2886
|
+
/newsletter/i,
|
|
2887
|
+
/feedback/i,
|
|
2888
|
+
/signup/i,
|
|
2889
|
+
/login/i,
|
|
2890
|
+
/register/i
|
|
2891
|
+
];
|
|
2892
|
+
var serverActionAuthRule = {
|
|
2893
|
+
id: "server-action-auth",
|
|
2894
|
+
name: "Server Action Without Auth",
|
|
2895
|
+
description: "Detects server actions that perform mutations without any authentication check",
|
|
2896
|
+
category: "security",
|
|
2897
|
+
severity: "warning",
|
|
2898
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2899
|
+
check(file, project) {
|
|
2900
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2901
|
+
if (!USE_SERVER3.test(file.content)) return [];
|
|
2902
|
+
if (project.hasAuthMiddleware) return [];
|
|
2903
|
+
for (const p of PUBLIC_ACTION_NAMES) {
|
|
2904
|
+
if (p.test(file.relativePath)) return [];
|
|
2905
|
+
}
|
|
2906
|
+
const hasAuth = AUTH_PATTERNS2.some((p) => p.test(file.content));
|
|
2907
|
+
if (hasAuth) return [];
|
|
2908
|
+
const hasMutation = /\.(insert|update|delete|create|upsert|remove|destroy|save|push|set)\s*\(/i.test(file.content) || /\b(INSERT|UPDATE|DELETE)\b/.test(file.content);
|
|
2909
|
+
if (!hasMutation) return [];
|
|
2910
|
+
let reportLine = 1;
|
|
2911
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2912
|
+
if (USE_SERVER3.test(file.lines[i])) {
|
|
2913
|
+
reportLine = i + 1;
|
|
2914
|
+
break;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return [{
|
|
2918
|
+
ruleId: "server-action-auth",
|
|
2919
|
+
file: file.relativePath,
|
|
2920
|
+
line: reportLine,
|
|
2921
|
+
column: 1,
|
|
2922
|
+
message: "Server action performs mutations without any authentication check \u2014 anyone can call this action",
|
|
2923
|
+
severity: "warning",
|
|
2924
|
+
category: "security",
|
|
2925
|
+
fix: 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
|
|
2926
|
+
}];
|
|
2927
|
+
}
|
|
2928
|
+
};
|
|
2929
|
+
|
|
2930
|
+
// src/rules/eval-injection.ts
|
|
2931
|
+
var EVAL_PATTERNS = [
|
|
2932
|
+
{ pattern: /\beval\s*\(/, msg: "eval() executes arbitrary code \u2014 never use with dynamic input" },
|
|
2933
|
+
{ pattern: /\bnew\s+Function\s*\(/, msg: "new Function() is equivalent to eval \u2014 avoid dynamic code execution" },
|
|
2934
|
+
{ pattern: /\bsetTimeout\s*\(\s*['"`]/, msg: "setTimeout with a string argument is eval \u2014 pass a function instead" },
|
|
2935
|
+
{ pattern: /\bsetInterval\s*\(\s*['"`]/, msg: "setInterval with a string argument is eval \u2014 pass a function instead" }
|
|
2936
|
+
];
|
|
2937
|
+
var evalInjectionRule = {
|
|
2938
|
+
id: "eval-injection",
|
|
2939
|
+
name: "Eval / Code Injection",
|
|
2940
|
+
description: "Detects eval(), new Function(), and string arguments to setTimeout/setInterval",
|
|
2941
|
+
category: "security",
|
|
2942
|
+
severity: "critical",
|
|
2943
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
2944
|
+
check(file, _project) {
|
|
2945
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2946
|
+
const findings = [];
|
|
2947
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2948
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2949
|
+
const line = file.lines[i];
|
|
2950
|
+
for (const { pattern, msg } of EVAL_PATTERNS) {
|
|
2951
|
+
const match = pattern.exec(line);
|
|
2952
|
+
if (match) {
|
|
2953
|
+
findings.push({
|
|
2954
|
+
ruleId: "eval-injection",
|
|
2955
|
+
file: file.relativePath,
|
|
2956
|
+
line: i + 1,
|
|
2957
|
+
column: match.index + 1,
|
|
2958
|
+
message: msg,
|
|
2959
|
+
severity: "critical",
|
|
2960
|
+
category: "security"
|
|
2961
|
+
});
|
|
2962
|
+
break;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
return findings;
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
// src/rules/missing-useeffect-cleanup.ts
|
|
2971
|
+
var NEEDS_CLEANUP = [
|
|
2972
|
+
/\bsetInterval\s*\(/,
|
|
2973
|
+
/\baddEventListener\s*\(/,
|
|
2974
|
+
/\.subscribe\s*\(/,
|
|
2975
|
+
/\.on\s*\(\s*['"`]/,
|
|
2976
|
+
/new\s+WebSocket\s*\(/,
|
|
2977
|
+
/new\s+EventSource\s*\(/,
|
|
2978
|
+
/new\s+IntersectionObserver\s*\(/,
|
|
2979
|
+
/new\s+ResizeObserver\s*\(/,
|
|
2980
|
+
/new\s+MutationObserver\s*\(/
|
|
2981
|
+
];
|
|
2982
|
+
var missingUseEffectCleanupRule = {
|
|
2983
|
+
id: "missing-useeffect-cleanup",
|
|
2984
|
+
name: "Missing useEffect Cleanup",
|
|
2985
|
+
description: "Detects useEffect hooks with subscriptions or timers but no cleanup return function \u2014 causes memory leaks",
|
|
2986
|
+
category: "reliability",
|
|
2987
|
+
severity: "warning",
|
|
2988
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
2989
|
+
check(file, _project) {
|
|
2990
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2991
|
+
if (!isClientComponent(file.content)) return [];
|
|
2992
|
+
if (!/useEffect/.test(file.content)) return [];
|
|
2993
|
+
const findings = [];
|
|
2994
|
+
const lines = file.lines;
|
|
2995
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2996
|
+
const line = lines[i];
|
|
2997
|
+
if (!/\buseEffect\s*\(/.test(line)) continue;
|
|
2998
|
+
let braceDepth = 0;
|
|
2999
|
+
let effectStart = -1;
|
|
3000
|
+
let effectEnd = -1;
|
|
3001
|
+
let started = false;
|
|
3002
|
+
for (let j = i; j < lines.length && j < i + 100; j++) {
|
|
3003
|
+
for (const ch of lines[j]) {
|
|
3004
|
+
if (ch === "(") {
|
|
3005
|
+
if (!started) {
|
|
3006
|
+
started = true;
|
|
3007
|
+
}
|
|
3008
|
+
braceDepth++;
|
|
3009
|
+
} else if (ch === ")") {
|
|
3010
|
+
braceDepth--;
|
|
3011
|
+
if (started && braceDepth === 0) {
|
|
3012
|
+
effectEnd = j;
|
|
3013
|
+
break;
|
|
3014
|
+
}
|
|
3015
|
+
} else if (ch === "{" && effectStart === -1 && started) {
|
|
3016
|
+
effectStart = j;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
if (effectEnd !== -1) break;
|
|
3020
|
+
}
|
|
3021
|
+
if (effectStart === -1 || effectEnd === -1) continue;
|
|
3022
|
+
const effectBody = lines.slice(effectStart, effectEnd + 1).join("\n");
|
|
3023
|
+
const needsCleanup = NEEDS_CLEANUP.some((p) => p.test(effectBody));
|
|
3024
|
+
if (!needsCleanup) continue;
|
|
3025
|
+
const hasReturn = /return\s*(?:\(\s*\)\s*=>|function|\(\))/.test(effectBody) || /return\s*\(\s*\)\s*\{/.test(effectBody) || /return\s+\w+\s*;?\s*$/.test(effectBody);
|
|
3026
|
+
if (!hasReturn) {
|
|
3027
|
+
const hasCleanupReturn = /return\s+(?:\(\)|(?:\(\s*\)\s*=>)|(?:function))/.test(effectBody);
|
|
3028
|
+
if (!hasCleanupReturn) {
|
|
3029
|
+
findings.push({
|
|
3030
|
+
ruleId: "missing-useeffect-cleanup",
|
|
3031
|
+
file: file.relativePath,
|
|
3032
|
+
line: i + 1,
|
|
3033
|
+
column: 1,
|
|
3034
|
+
message: "useEffect with subscription/timer but no cleanup return \u2014 will leak memory on unmount",
|
|
3035
|
+
severity: "warning",
|
|
3036
|
+
category: "reliability",
|
|
3037
|
+
fix: "Return a cleanup function: useEffect(() => { const id = setInterval(...); return () => clearInterval(id); }, [])"
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
return findings;
|
|
3043
|
+
}
|
|
3044
|
+
};
|
|
3045
|
+
|
|
3046
|
+
// src/rules/next-public-sensitive.ts
|
|
3047
|
+
var SENSITIVE_PATTERN = /NEXT_PUBLIC_\w*(SECRET|PRIVATE|PASSWORD|DATABASE_URL|SERVICE_ROLE|SERVICE_KEY|ADMIN_KEY|sk_live|sk_test|SIGNING|ENCRYPTION)/i;
|
|
3048
|
+
var nextPublicSensitiveRule = {
|
|
3049
|
+
id: "next-public-sensitive",
|
|
3050
|
+
name: "Sensitive Env Var with NEXT_PUBLIC_ Prefix",
|
|
3051
|
+
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3052
|
+
category: "security",
|
|
3053
|
+
severity: "critical",
|
|
3054
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env", "env.local", "env.production"],
|
|
3055
|
+
check(file, _project) {
|
|
3056
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3057
|
+
const findings = [];
|
|
3058
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3059
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3060
|
+
const line = file.lines[i];
|
|
3061
|
+
const match = SENSITIVE_PATTERN.exec(line);
|
|
3062
|
+
if (match) {
|
|
3063
|
+
findings.push({
|
|
3064
|
+
ruleId: "next-public-sensitive",
|
|
3065
|
+
file: file.relativePath,
|
|
3066
|
+
line: i + 1,
|
|
3067
|
+
column: match.index + 1,
|
|
3068
|
+
message: `NEXT_PUBLIC_ prefix on a sensitive env var \u2014 this value will be embedded in the client-side JavaScript bundle`,
|
|
3069
|
+
severity: "critical",
|
|
3070
|
+
category: "security",
|
|
3071
|
+
fix: "Remove the NEXT_PUBLIC_ prefix. Access this value only in server components, API routes, or server actions."
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
return findings;
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
|
|
3079
|
+
// src/rules/ssrf-risk.ts
|
|
3080
|
+
var USER_INPUT_IN_FETCH = [
|
|
3081
|
+
/fetch\s*\(\s*(?:req|request)\.(?:body|query|params|nextUrl)/,
|
|
3082
|
+
/fetch\s*\(\s*(?:url|href|endpoint|target|link|src)\s*[,)]/,
|
|
3083
|
+
/fetch\s*\(\s*searchParams\.get\s*\(/,
|
|
3084
|
+
/fetch\s*\(\s*formData\.get\s*\(/,
|
|
3085
|
+
/new\s+URL\s*\(\s*(?:req|request)\.(?:body|query)/,
|
|
3086
|
+
/axios\s*[.(]\s*(?:req|request)\.(?:body|query)/,
|
|
3087
|
+
/axios\.get\s*\(\s*(?:url|href|endpoint|target|link)\s*[,)]/
|
|
3088
|
+
];
|
|
3089
|
+
var VALIDATION_PATTERNS3 = [
|
|
3090
|
+
/allowlist/i,
|
|
3091
|
+
/allowedUrls/i,
|
|
3092
|
+
/allowedHosts/i,
|
|
3093
|
+
/allowedDomains/i,
|
|
3094
|
+
/whitelist/i,
|
|
3095
|
+
/validUrl/i,
|
|
3096
|
+
/validateUrl/i,
|
|
3097
|
+
/URL\.canParse/,
|
|
3098
|
+
/new\s+URL\s*\(.*\)\.host/,
|
|
3099
|
+
/\.startsWith\s*\(\s*['"]https?:\/\//,
|
|
3100
|
+
/\.hostname\s*[!=]==?\s*['"`]/
|
|
3101
|
+
];
|
|
3102
|
+
var ssrfRiskRule = {
|
|
3103
|
+
id: "ssrf-risk",
|
|
3104
|
+
name: "SSRF Risk",
|
|
3105
|
+
description: "Detects fetch/HTTP calls with user-controlled URLs without validation \u2014 allows attackers to probe internal services",
|
|
3106
|
+
category: "security",
|
|
3107
|
+
severity: "warning",
|
|
3108
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3109
|
+
check(file, _project) {
|
|
3110
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3111
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3112
|
+
const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3113
|
+
if (hasValidation) return [];
|
|
3114
|
+
const findings = [];
|
|
3115
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3116
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3117
|
+
const line = file.lines[i];
|
|
3118
|
+
for (const pattern of USER_INPUT_IN_FETCH) {
|
|
3119
|
+
const match = pattern.exec(line);
|
|
3120
|
+
if (match) {
|
|
3121
|
+
findings.push({
|
|
3122
|
+
ruleId: "ssrf-risk",
|
|
3123
|
+
file: file.relativePath,
|
|
3124
|
+
line: i + 1,
|
|
3125
|
+
column: match.index + 1,
|
|
3126
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3127
|
+
severity: "warning",
|
|
3128
|
+
category: "security",
|
|
3129
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3130
|
+
});
|
|
3131
|
+
break;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
return findings;
|
|
3136
|
+
}
|
|
3137
|
+
};
|
|
3138
|
+
|
|
3139
|
+
// src/rules/path-traversal.ts
|
|
3140
|
+
var FS_WITH_USER_INPUT = [
|
|
3141
|
+
{ pattern: /(?:readFile|readFileSync|createReadStream)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File read with user-controlled path \u2014 allows reading arbitrary files" },
|
|
3142
|
+
{ pattern: /(?:readFile|readFileSync|createReadStream)\s*\(\s*(?:filePath|fileName|path|file|name)\s*[,)]/, msg: "File read with potentially user-controlled path \u2014 validate before use" },
|
|
3143
|
+
{ pattern: /(?:writeFile|writeFileSync|createWriteStream)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File write with user-controlled path \u2014 allows writing arbitrary files" },
|
|
3144
|
+
{ pattern: /(?:unlink|unlinkSync|rm|rmSync)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File delete with user-controlled path \u2014 allows deleting arbitrary files" },
|
|
3145
|
+
{ pattern: /path\.join\s*\([^)]*(?:req|request)\.(?:query|body|params)/, msg: "path.join with user input \u2014 still vulnerable to traversal with ../" },
|
|
3146
|
+
{ pattern: /\.sendFile\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "express.sendFile with user-controlled path \u2014 validate against a base directory" }
|
|
3147
|
+
];
|
|
3148
|
+
var SANITIZATION_PATTERNS = [
|
|
3149
|
+
/path\.resolve\s*\(.*\)\.startsWith/,
|
|
3150
|
+
/\.replace\s*\(\s*['"]\.\.['"],?\s*['"].*['"]\s*\)/,
|
|
3151
|
+
/\.includes\s*\(\s*['"]\.\.['"].*\)/,
|
|
3152
|
+
/normalize/,
|
|
3153
|
+
/sanitize/i,
|
|
3154
|
+
/realpath/
|
|
3155
|
+
];
|
|
3156
|
+
var pathTraversalRule = {
|
|
3157
|
+
id: "path-traversal",
|
|
3158
|
+
name: "Path Traversal",
|
|
3159
|
+
description: "Detects filesystem operations with user-controlled paths \u2014 allows reading/writing arbitrary files via ../ sequences",
|
|
3160
|
+
category: "security",
|
|
3161
|
+
severity: "critical",
|
|
3162
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3163
|
+
check(file, _project) {
|
|
3164
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3165
|
+
const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
|
|
3166
|
+
if (hasSanitization) return [];
|
|
3167
|
+
const findings = [];
|
|
3168
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3169
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3170
|
+
const line = file.lines[i];
|
|
3171
|
+
for (const { pattern, msg } of FS_WITH_USER_INPUT) {
|
|
3172
|
+
const match = pattern.exec(line);
|
|
3173
|
+
if (match) {
|
|
3174
|
+
const severity = /req|request/.test(match[0]) ? "critical" : "warning";
|
|
3175
|
+
findings.push({
|
|
3176
|
+
ruleId: "path-traversal",
|
|
3177
|
+
file: file.relativePath,
|
|
3178
|
+
line: i + 1,
|
|
3179
|
+
column: match.index + 1,
|
|
3180
|
+
message: msg,
|
|
3181
|
+
severity,
|
|
3182
|
+
category: "security",
|
|
3183
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3184
|
+
});
|
|
3185
|
+
break;
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
return findings;
|
|
3190
|
+
}
|
|
3191
|
+
};
|
|
3192
|
+
|
|
3193
|
+
// src/rules/hydration-mismatch.ts
|
|
3194
|
+
var BROWSER_ONLY_PATTERNS = [
|
|
3195
|
+
{ pattern: /\bwindow\./, msg: "window access in server-rendered code \u2014 will differ between server and client" },
|
|
3196
|
+
{ pattern: /\bdocument\./, msg: "document access in server-rendered code \u2014 undefined on the server" },
|
|
3197
|
+
{ pattern: /\blocalStorage\b/, msg: "localStorage in render path \u2014 undefined on server, causes hydration mismatch" },
|
|
3198
|
+
{ pattern: /\bsessionStorage\b/, msg: "sessionStorage in render path \u2014 undefined on server, causes hydration mismatch" },
|
|
3199
|
+
{ pattern: /\bnavigator\./, msg: "navigator access in render path \u2014 undefined on server" }
|
|
3200
|
+
];
|
|
3201
|
+
var NONDETERMINISTIC_PATTERNS = [
|
|
3202
|
+
{ pattern: /\bnew\s+Date\s*\(\s*\)/, msg: "new Date() in render path \u2014 server and client will have different timestamps, causing hydration mismatch" },
|
|
3203
|
+
{ pattern: /\bDate\.now\s*\(\s*\)/, msg: "Date.now() in render path \u2014 different on server vs client" },
|
|
3204
|
+
{ pattern: /\bMath\.random\s*\(\s*\)/, msg: "Math.random() in render path \u2014 produces different values on server vs client" }
|
|
3205
|
+
];
|
|
3206
|
+
var hydrationMismatchRule = {
|
|
3207
|
+
id: "hydration-mismatch",
|
|
3208
|
+
name: "Hydration Mismatch Risk",
|
|
3209
|
+
description: "Detects browser-only APIs and non-deterministic calls in server component render paths",
|
|
3210
|
+
category: "reliability",
|
|
3211
|
+
severity: "warning",
|
|
3212
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
3213
|
+
check(file, _project) {
|
|
3214
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3215
|
+
if (isClientComponent(file.content)) return [];
|
|
3216
|
+
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3217
|
+
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3218
|
+
const findings = [];
|
|
3219
|
+
let insideUseEffect = false;
|
|
3220
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3221
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3222
|
+
const line = file.lines[i];
|
|
3223
|
+
if (/\buseEffect\s*\(/.test(line)) {
|
|
3224
|
+
insideUseEffect = true;
|
|
3225
|
+
}
|
|
3226
|
+
if (insideUseEffect) {
|
|
3227
|
+
if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
|
|
3228
|
+
insideUseEffect = false;
|
|
3229
|
+
}
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
|
|
3233
|
+
const match = pattern.exec(line);
|
|
3234
|
+
if (match) {
|
|
3235
|
+
findings.push({
|
|
3236
|
+
ruleId: "hydration-mismatch",
|
|
3237
|
+
file: file.relativePath,
|
|
3238
|
+
line: i + 1,
|
|
3239
|
+
column: match.index + 1,
|
|
3240
|
+
message: msg,
|
|
3241
|
+
severity: "warning",
|
|
3242
|
+
category: "reliability",
|
|
3243
|
+
fix: 'Move this to a useEffect hook, or add "use client" if this component needs browser APIs'
|
|
3244
|
+
});
|
|
3245
|
+
break;
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
return findings;
|
|
3250
|
+
}
|
|
3251
|
+
};
|
|
3252
|
+
|
|
3253
|
+
// src/rules/server-component-fetch-self.ts
|
|
3254
|
+
var SELF_FETCH_PATTERNS = [
|
|
3255
|
+
/fetch\s*\(\s*['"`]\/api\//,
|
|
3256
|
+
/fetch\s*\(\s*['"`]http:\/\/localhost/,
|
|
3257
|
+
/fetch\s*\(\s*['"`]https?:\/\/localhost/,
|
|
3258
|
+
/fetch\s*\(\s*`\$\{.*\}\/api\//,
|
|
3259
|
+
/fetch\s*\(\s*(?:process\.env\.\w+\s*\+\s*)?['"`]\/api\//
|
|
3260
|
+
];
|
|
3261
|
+
var serverComponentFetchSelfRule = {
|
|
3262
|
+
id: "server-component-fetch-self",
|
|
3263
|
+
name: "Server Component Fetching Own API",
|
|
3264
|
+
description: "Detects server components that fetch their own API routes instead of calling data logic directly \u2014 unnecessary network roundtrip",
|
|
3265
|
+
category: "performance",
|
|
3266
|
+
severity: "info",
|
|
3267
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
3268
|
+
check(file, _project) {
|
|
3269
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3270
|
+
if (isClientComponent(file.content)) return [];
|
|
3271
|
+
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3272
|
+
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3273
|
+
const findings = [];
|
|
3274
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3275
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3276
|
+
const line = file.lines[i];
|
|
3277
|
+
for (const pattern of SELF_FETCH_PATTERNS) {
|
|
3278
|
+
const match = pattern.exec(line);
|
|
3279
|
+
if (match) {
|
|
3280
|
+
findings.push({
|
|
3281
|
+
ruleId: "server-component-fetch-self",
|
|
3282
|
+
file: file.relativePath,
|
|
3283
|
+
line: i + 1,
|
|
3284
|
+
column: match.index + 1,
|
|
3285
|
+
message: "Server component fetches its own API route \u2014 call the data logic directly instead of making a network request to yourself",
|
|
3286
|
+
severity: "info",
|
|
3287
|
+
category: "performance",
|
|
3288
|
+
fix: 'Import and call the data function directly instead of fetch("/api/...")'
|
|
3289
|
+
});
|
|
3290
|
+
break;
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
return findings;
|
|
3295
|
+
}
|
|
3296
|
+
};
|
|
3297
|
+
|
|
3298
|
+
// src/rules/unsafe-file-upload.ts
|
|
3299
|
+
var UPLOAD_PATTERNS = [
|
|
3300
|
+
/\.get\s*\(\s*['"`]file['"`]\s*\)/,
|
|
3301
|
+
/\.get\s*\(\s*['"`]image['"`]\s*\)/,
|
|
3302
|
+
/\.get\s*\(\s*['"`]upload['"`]\s*\)/,
|
|
3303
|
+
/\.get\s*\(\s*['"`]attachment['"`]\s*\)/,
|
|
3304
|
+
/\.get\s*\(\s*['"`]document['"`]\s*\)/,
|
|
3305
|
+
/\.get\s*\(\s*['"`]avatar['"`]\s*\)/,
|
|
3306
|
+
/\.get\s*\(\s*['"`]photo['"`]\s*\)/,
|
|
3307
|
+
/\.type\s*===?\s*['"`]file['"`]/,
|
|
3308
|
+
/req\.file\b/,
|
|
3309
|
+
/multer/i,
|
|
3310
|
+
/busboy/i,
|
|
3311
|
+
/formidable/i
|
|
3312
|
+
];
|
|
3313
|
+
var VALIDATION_PATTERNS4 = [
|
|
3314
|
+
/\.type\b.*(?:image|video|audio|pdf|text)\//,
|
|
3315
|
+
/content-type/i,
|
|
3316
|
+
/mime/i,
|
|
3317
|
+
/\.size\s*[><!]/,
|
|
3318
|
+
/maxFileSize/i,
|
|
3319
|
+
/maxSize/i,
|
|
3320
|
+
/fileSizeLimit/i,
|
|
3321
|
+
/allowedTypes/i,
|
|
3322
|
+
/acceptedTypes/i,
|
|
3323
|
+
/fileFilter/i,
|
|
3324
|
+
/\.endsWith\s*\(\s*['"`]\./,
|
|
3325
|
+
/\.extension/i
|
|
3326
|
+
];
|
|
3327
|
+
var unsafeFileUploadRule = {
|
|
3328
|
+
id: "unsafe-file-upload",
|
|
3329
|
+
name: "Unsafe File Upload",
|
|
3330
|
+
description: "Detects file upload handlers without type or size validation",
|
|
3331
|
+
category: "security",
|
|
3332
|
+
severity: "warning",
|
|
3333
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3334
|
+
check(file, _project) {
|
|
3335
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3336
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3337
|
+
const hasUpload = UPLOAD_PATTERNS.some((p) => p.test(file.content));
|
|
3338
|
+
if (!hasUpload) return [];
|
|
3339
|
+
const hasValidation = VALIDATION_PATTERNS4.some((p) => p.test(file.content));
|
|
3340
|
+
if (hasValidation) return [];
|
|
3341
|
+
let reportLine = 1;
|
|
3342
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3343
|
+
if (UPLOAD_PATTERNS.some((p) => p.test(file.lines[i]))) {
|
|
3344
|
+
reportLine = i + 1;
|
|
3345
|
+
break;
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
return [{
|
|
3349
|
+
ruleId: "unsafe-file-upload",
|
|
3350
|
+
file: file.relativePath,
|
|
3351
|
+
line: reportLine,
|
|
3352
|
+
column: 1,
|
|
3353
|
+
message: "File upload without type or size validation \u2014 accepts any file type and size",
|
|
3354
|
+
severity: "warning",
|
|
3355
|
+
category: "security",
|
|
3356
|
+
fix: "Validate file type (check MIME type, not just extension) and enforce a size limit before processing"
|
|
3357
|
+
}];
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
|
|
3361
|
+
// src/rules/supabase-missing-rls.ts
|
|
3362
|
+
var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
|
|
3363
|
+
var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
|
|
3364
|
+
var supabaseMissingRlsRule = {
|
|
3365
|
+
id: "supabase-missing-rls",
|
|
3366
|
+
name: "Missing Row-Level Security",
|
|
3367
|
+
description: "Detects SQL migrations that create tables without enabling Row-Level Security \u2014 all data is publicly accessible",
|
|
3368
|
+
category: "security",
|
|
3369
|
+
severity: "critical",
|
|
3370
|
+
fileExtensions: ["sql"],
|
|
3371
|
+
check(file, project) {
|
|
3372
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3373
|
+
if (!/migration|supabase|schema/i.test(file.relativePath)) return [];
|
|
3374
|
+
const content = file.content;
|
|
3375
|
+
const findings = [];
|
|
3376
|
+
const tables = [];
|
|
3377
|
+
CREATE_TABLE.lastIndex = 0;
|
|
3378
|
+
let match;
|
|
3379
|
+
while ((match = CREATE_TABLE.exec(content)) !== null) {
|
|
3380
|
+
const name = match[1];
|
|
3381
|
+
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
3382
|
+
const beforeMatch = content.slice(0, match.index);
|
|
3383
|
+
const line = beforeMatch.split("\n").length;
|
|
3384
|
+
tables.push({ name, line });
|
|
3385
|
+
}
|
|
3386
|
+
if (tables.length === 0) return [];
|
|
3387
|
+
const rlsTables = /* @__PURE__ */ new Set();
|
|
3388
|
+
ENABLE_RLS.lastIndex = 0;
|
|
3389
|
+
while ((match = ENABLE_RLS.exec(content)) !== null) {
|
|
3390
|
+
rlsTables.add(match[1].toLowerCase());
|
|
3391
|
+
}
|
|
3392
|
+
for (const filePath of project.allFiles) {
|
|
3393
|
+
if (!filePath.endsWith(".sql")) continue;
|
|
3394
|
+
if (filePath === file.relativePath) continue;
|
|
3395
|
+
}
|
|
3396
|
+
for (const table of tables) {
|
|
3397
|
+
if (!rlsTables.has(table.name.toLowerCase())) {
|
|
3398
|
+
findings.push({
|
|
3399
|
+
ruleId: "supabase-missing-rls",
|
|
3400
|
+
file: file.relativePath,
|
|
3401
|
+
line: table.line,
|
|
3402
|
+
column: 1,
|
|
3403
|
+
message: `Table "${table.name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
3404
|
+
severity: "critical",
|
|
3405
|
+
category: "security",
|
|
3406
|
+
fix: `Add: ALTER TABLE ${table.name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
return findings;
|
|
3411
|
+
}
|
|
3412
|
+
};
|
|
3413
|
+
|
|
3414
|
+
// src/rules/deprecated-oauth-flow.ts
|
|
3415
|
+
var IMPLICIT_GRANT = /response_type\s*[=:]\s*['"`]?token['"`]?/;
|
|
3416
|
+
var deprecatedOauthFlowRule = {
|
|
3417
|
+
id: "deprecated-oauth-flow",
|
|
3418
|
+
name: "Deprecated OAuth Flow",
|
|
3419
|
+
description: "Detects OAuth Implicit Grant flow (response_type=token) \u2014 deprecated in OAuth 2.1, vulnerable to token interception",
|
|
3420
|
+
category: "security",
|
|
3421
|
+
severity: "warning",
|
|
3422
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
3423
|
+
check(file, _project) {
|
|
3424
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3425
|
+
const findings = [];
|
|
3426
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3427
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3428
|
+
const line = file.lines[i];
|
|
3429
|
+
const match = IMPLICIT_GRANT.exec(line);
|
|
3430
|
+
if (match) {
|
|
3431
|
+
findings.push({
|
|
3432
|
+
ruleId: "deprecated-oauth-flow",
|
|
3433
|
+
file: file.relativePath,
|
|
3434
|
+
line: i + 1,
|
|
3435
|
+
column: match.index + 1,
|
|
3436
|
+
message: "OAuth Implicit Grant flow (response_type=token) is deprecated \u2014 tokens are exposed in the URL fragment",
|
|
3437
|
+
severity: "warning",
|
|
3438
|
+
category: "security",
|
|
3439
|
+
fix: "Use Authorization Code flow with PKCE: response_type=code with code_challenge and code_verifier"
|
|
3440
|
+
});
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
return findings;
|
|
3444
|
+
}
|
|
3445
|
+
};
|
|
3446
|
+
|
|
3447
|
+
// src/rules/jwt-no-expiry.ts
|
|
3448
|
+
var JWT_SIGN = /jwt\.sign\s*\(/;
|
|
3449
|
+
var HAS_EXPIRY = [
|
|
3450
|
+
/expiresIn/,
|
|
3451
|
+
/exp\s*:/,
|
|
3452
|
+
/expirationTime/,
|
|
3453
|
+
/maxAge/
|
|
3454
|
+
];
|
|
3455
|
+
var jwtNoExpiryRule = {
|
|
3456
|
+
id: "jwt-no-expiry",
|
|
3457
|
+
name: "JWT Without Expiration",
|
|
3458
|
+
description: "Detects jwt.sign() calls without an expiresIn option \u2014 tokens never expire, compromised tokens are valid forever",
|
|
3459
|
+
category: "security",
|
|
3460
|
+
severity: "warning",
|
|
3461
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
3462
|
+
check(file, _project) {
|
|
3463
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3464
|
+
if (!JWT_SIGN.test(file.content)) return [];
|
|
3465
|
+
const findings = [];
|
|
3466
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3467
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3468
|
+
const line = file.lines[i];
|
|
3469
|
+
const match = JWT_SIGN.exec(line);
|
|
3470
|
+
if (!match) continue;
|
|
3471
|
+
const context = file.lines.slice(i, i + 6).join("\n");
|
|
3472
|
+
const hasExpiry = HAS_EXPIRY.some((p) => p.test(context));
|
|
3473
|
+
if (!hasExpiry) {
|
|
3474
|
+
findings.push({
|
|
3475
|
+
ruleId: "jwt-no-expiry",
|
|
3476
|
+
file: file.relativePath,
|
|
3477
|
+
line: i + 1,
|
|
3478
|
+
column: match.index + 1,
|
|
3479
|
+
message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
|
|
3480
|
+
severity: "warning",
|
|
3481
|
+
category: "security",
|
|
3482
|
+
fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
|
|
3483
|
+
});
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
return findings;
|
|
3487
|
+
}
|
|
3488
|
+
};
|
|
3489
|
+
|
|
3490
|
+
// src/rules/client-side-auth-only.ts
|
|
3491
|
+
var CLIENT_AUTH_PATTERNS = [
|
|
3492
|
+
/localStorage\.(?:get|set)Item\s*\(\s*['"`](?:token|auth|session|jwt|access_token|user)['"`]/,
|
|
3493
|
+
/sessionStorage\.(?:get|set)Item\s*\(\s*['"`](?:token|auth|session|jwt|access_token|user)['"`]/
|
|
3494
|
+
];
|
|
3495
|
+
var PASSWORD_CHECK = /(?:password|passwd)\s*[!=]==?\s*['"`]/;
|
|
3496
|
+
var clientSideAuthOnlyRule = {
|
|
3497
|
+
id: "client-side-auth-only",
|
|
3498
|
+
name: "Client-Side Auth Only",
|
|
3499
|
+
description: "Detects authentication logic implemented only in client-side code \u2014 easily bypassed via browser DevTools",
|
|
3500
|
+
category: "security",
|
|
3501
|
+
severity: "critical",
|
|
3502
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
3503
|
+
check(file, _project) {
|
|
3504
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3505
|
+
if (!isClientComponent(file.content)) return [];
|
|
3506
|
+
const findings = [];
|
|
3507
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3508
|
+
const line = file.lines[i];
|
|
3509
|
+
const match = PASSWORD_CHECK.exec(line);
|
|
3510
|
+
if (match) {
|
|
3511
|
+
findings.push({
|
|
3512
|
+
ruleId: "client-side-auth-only",
|
|
3513
|
+
file: file.relativePath,
|
|
3514
|
+
line: i + 1,
|
|
3515
|
+
column: match.index + 1,
|
|
3516
|
+
message: "Password comparison in client-side code \u2014 the password is visible in the JavaScript bundle",
|
|
3517
|
+
severity: "critical",
|
|
3518
|
+
category: "security",
|
|
3519
|
+
fix: "Move authentication logic to a server action or API route"
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3524
|
+
const line = file.lines[i];
|
|
3525
|
+
for (const pattern of CLIENT_AUTH_PATTERNS) {
|
|
3526
|
+
const match = pattern.exec(line);
|
|
3527
|
+
if (match) {
|
|
3528
|
+
findings.push({
|
|
3529
|
+
ruleId: "client-side-auth-only",
|
|
3530
|
+
file: file.relativePath,
|
|
3531
|
+
line: i + 1,
|
|
3532
|
+
column: match.index + 1,
|
|
3533
|
+
message: "Auth token in localStorage \u2014 accessible to any script on the page (XSS risk). Use httpOnly cookies instead.",
|
|
3534
|
+
severity: "warning",
|
|
3535
|
+
category: "security",
|
|
3536
|
+
fix: "Store auth tokens in httpOnly cookies set by the server, not in localStorage"
|
|
3537
|
+
});
|
|
3538
|
+
break;
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
return findings;
|
|
3543
|
+
}
|
|
3544
|
+
};
|
|
3545
|
+
|
|
3546
|
+
// src/rules/missing-abort-controller.ts
|
|
3547
|
+
var FETCH_CALL = /\bfetch\s*\(/;
|
|
3548
|
+
var HAS_TIMEOUT = [
|
|
3549
|
+
/AbortController/,
|
|
3550
|
+
/abort/i,
|
|
3551
|
+
/signal\s*:/,
|
|
3552
|
+
/timeout/i,
|
|
3553
|
+
/setTimeout.*abort/s
|
|
3554
|
+
];
|
|
3555
|
+
var missingAbortControllerRule = {
|
|
3556
|
+
id: "missing-abort-controller",
|
|
3557
|
+
name: "Missing Abort Controller",
|
|
3558
|
+
description: "Detects fetch calls in API routes without timeout or AbortController \u2014 requests can hang indefinitely",
|
|
3559
|
+
category: "performance",
|
|
3560
|
+
severity: "info",
|
|
3561
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3562
|
+
check(file, _project) {
|
|
3563
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3564
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3565
|
+
if (!FETCH_CALL.test(file.content)) return [];
|
|
3566
|
+
const hasTimeout = HAS_TIMEOUT.some((p) => p.test(file.content));
|
|
3567
|
+
if (hasTimeout) return [];
|
|
3568
|
+
let reportLine = 1;
|
|
3569
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3570
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3571
|
+
if (FETCH_CALL.test(file.lines[i])) {
|
|
3572
|
+
reportLine = i + 1;
|
|
3573
|
+
break;
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
return [{
|
|
3577
|
+
ruleId: "missing-abort-controller",
|
|
3578
|
+
file: file.relativePath,
|
|
3579
|
+
line: reportLine,
|
|
3580
|
+
column: 1,
|
|
3581
|
+
message: "fetch() without timeout or AbortController \u2014 request will hang indefinitely if the upstream server doesn't respond",
|
|
3582
|
+
severity: "info",
|
|
3583
|
+
category: "performance",
|
|
3584
|
+
fix: "Add a timeout: const controller = new AbortController(); setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal })"
|
|
3585
|
+
}];
|
|
3586
|
+
}
|
|
3587
|
+
};
|
|
3588
|
+
|
|
2002
3589
|
// src/rules/index.ts
|
|
2003
3590
|
var rules = [
|
|
2004
3591
|
// Security
|
|
@@ -2012,6 +3599,23 @@ var rules = [
|
|
|
2012
3599
|
openRedirectRule,
|
|
2013
3600
|
rateLimitingRule,
|
|
2014
3601
|
phantomDependencyRule,
|
|
3602
|
+
insecureCookieRule,
|
|
3603
|
+
leakedEnvInLogsRule,
|
|
3604
|
+
insecureRandomRule,
|
|
3605
|
+
nextServerActionValidationRule,
|
|
3606
|
+
envFallbackSecretRule,
|
|
3607
|
+
verboseErrorResponseRule,
|
|
3608
|
+
missingWebhookVerificationRule,
|
|
3609
|
+
serverActionAuthRule,
|
|
3610
|
+
evalInjectionRule,
|
|
3611
|
+
nextPublicSensitiveRule,
|
|
3612
|
+
ssrfRiskRule,
|
|
3613
|
+
pathTraversalRule,
|
|
3614
|
+
unsafeFileUploadRule,
|
|
3615
|
+
supabaseMissingRlsRule,
|
|
3616
|
+
deprecatedOauthFlowRule,
|
|
3617
|
+
jwtNoExpiryRule,
|
|
3618
|
+
clientSideAuthOnlyRule,
|
|
2015
3619
|
// Reliability
|
|
2016
3620
|
hallucinatedImportsRule,
|
|
2017
3621
|
errorHandlingRule,
|
|
@@ -2019,11 +3623,18 @@ var rules = [
|
|
|
2019
3623
|
shallowCatchRule,
|
|
2020
3624
|
missingLoadingStateRule,
|
|
2021
3625
|
missingErrorBoundaryRule,
|
|
3626
|
+
missingTransactionRule,
|
|
3627
|
+
redirectInTryCatchRule,
|
|
3628
|
+
missingRevalidationRule,
|
|
3629
|
+
missingUseEffectCleanupRule,
|
|
3630
|
+
hydrationMismatchRule,
|
|
2022
3631
|
// Performance
|
|
2023
3632
|
noSyncFsRule,
|
|
2024
3633
|
noNPlusOneRule,
|
|
2025
3634
|
noUnboundedQueryRule,
|
|
2026
3635
|
noDynamicImportLoopRule,
|
|
3636
|
+
serverComponentFetchSelfRule,
|
|
3637
|
+
missingAbortControllerRule,
|
|
2027
3638
|
// AI Quality
|
|
2028
3639
|
aiSmellsRule,
|
|
2029
3640
|
placeholderContentRule,
|
|
@@ -2031,7 +3642,8 @@ var rules = [
|
|
|
2031
3642
|
staleFallbackRule,
|
|
2032
3643
|
comprehensionDebtRule,
|
|
2033
3644
|
codebaseConsistencyRule,
|
|
2034
|
-
deadExportsRule
|
|
3645
|
+
deadExportsRule,
|
|
3646
|
+
useClientOveruseRule
|
|
2035
3647
|
];
|
|
2036
3648
|
|
|
2037
3649
|
// src/scanner.ts
|