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