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