prodlint 0.3.0 → 0.5.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 +136 -102
- package/dist/cli.js +635 -77
- package/dist/index.d.ts +7 -0
- package/dist/index.js +593 -54
- package/dist/mcp.js +593 -54
- package/package.json +6 -1
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/**",
|
|
@@ -189,6 +316,7 @@ var SCAN_EXTENSIONS = [
|
|
|
189
316
|
"cjs",
|
|
190
317
|
"json"
|
|
191
318
|
];
|
|
319
|
+
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
192
320
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
193
321
|
async function walkFiles(root, extraIgnores = []) {
|
|
194
322
|
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
@@ -213,14 +341,23 @@ async function readFileContext(root, relativePath) {
|
|
|
213
341
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
214
342
|
const content = await readFile(absolutePath, "utf-8");
|
|
215
343
|
const lines = content.split(/\r?\n|\r/);
|
|
344
|
+
const ext = extname(relativePath).slice(1);
|
|
345
|
+
let ast = void 0;
|
|
346
|
+
if (AST_EXTENSIONS.has(ext)) {
|
|
347
|
+
try {
|
|
348
|
+
ast = parseFile(content, relativePath);
|
|
349
|
+
} catch {
|
|
350
|
+
ast = null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
216
353
|
return {
|
|
217
354
|
absolutePath,
|
|
218
355
|
relativePath,
|
|
219
356
|
content,
|
|
220
357
|
lines,
|
|
221
|
-
ext
|
|
222
|
-
|
|
223
|
-
|
|
358
|
+
ext,
|
|
359
|
+
commentMap: buildCommentMap(lines),
|
|
360
|
+
ast
|
|
224
361
|
};
|
|
225
362
|
} catch {
|
|
226
363
|
return null;
|
|
@@ -231,6 +368,8 @@ async function buildProjectContext(root, files) {
|
|
|
231
368
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
232
369
|
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
233
370
|
let hasAuthMiddleware = false;
|
|
371
|
+
let hasRateLimiting = false;
|
|
372
|
+
const detectedFrameworks = /* @__PURE__ */ new Set();
|
|
234
373
|
let gitignoreContent = null;
|
|
235
374
|
let envInGitignore = false;
|
|
236
375
|
try {
|
|
@@ -242,6 +381,18 @@ async function buildProjectContext(root, files) {
|
|
|
242
381
|
...packageJson?.peerDependencies ?? {}
|
|
243
382
|
};
|
|
244
383
|
declaredDependencies = new Set(Object.keys(deps));
|
|
384
|
+
for (const dep of declaredDependencies) {
|
|
385
|
+
const framework = DEPENDENCY_TO_FRAMEWORK[dep];
|
|
386
|
+
if (framework) {
|
|
387
|
+
detectedFrameworks.add(framework);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const framework of detectedFrameworks) {
|
|
391
|
+
if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
|
|
392
|
+
hasRateLimiting = true;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
245
396
|
} catch {
|
|
246
397
|
}
|
|
247
398
|
try {
|
|
@@ -285,6 +436,18 @@ async function buildProjectContext(root, files) {
|
|
|
285
436
|
}
|
|
286
437
|
} catch {
|
|
287
438
|
}
|
|
439
|
+
if (!hasRateLimiting) {
|
|
440
|
+
for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
|
|
441
|
+
try {
|
|
442
|
+
const content = await readFile(resolve(root, name), "utf-8");
|
|
443
|
+
if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
|
|
444
|
+
hasRateLimiting = true;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
288
451
|
try {
|
|
289
452
|
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
290
453
|
envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
|
|
@@ -296,6 +459,8 @@ async function buildProjectContext(root, files) {
|
|
|
296
459
|
declaredDependencies,
|
|
297
460
|
tsconfigPaths,
|
|
298
461
|
hasAuthMiddleware,
|
|
462
|
+
hasRateLimiting,
|
|
463
|
+
detectedFrameworks,
|
|
299
464
|
gitignoreContent,
|
|
300
465
|
envInGitignore,
|
|
301
466
|
allFiles: files
|
|
@@ -320,26 +485,58 @@ function getVersion() {
|
|
|
320
485
|
|
|
321
486
|
// src/scorer.ts
|
|
322
487
|
var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
|
|
488
|
+
var CATEGORY_WEIGHTS = {
|
|
489
|
+
"security": 0.4,
|
|
490
|
+
"reliability": 0.3,
|
|
491
|
+
"performance": 0.15,
|
|
492
|
+
"ai-quality": 0.15
|
|
493
|
+
};
|
|
323
494
|
var DEDUCTIONS = {
|
|
324
|
-
critical:
|
|
325
|
-
warning:
|
|
326
|
-
info:
|
|
495
|
+
critical: 8,
|
|
496
|
+
warning: 2,
|
|
497
|
+
info: 0.5
|
|
498
|
+
};
|
|
499
|
+
var PER_RULE_CAP = {
|
|
500
|
+
critical: 1,
|
|
501
|
+
warning: 2,
|
|
502
|
+
info: 3
|
|
327
503
|
};
|
|
328
504
|
function calculateScores(findings) {
|
|
329
505
|
const categoryScores = CATEGORIES.map((category) => {
|
|
330
506
|
const categoryFindings = findings.filter((f) => f.category === category);
|
|
331
|
-
|
|
507
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
332
508
|
for (const f of categoryFindings) {
|
|
333
|
-
|
|
509
|
+
const arr = byRule.get(f.ruleId) ?? [];
|
|
510
|
+
arr.push(f);
|
|
511
|
+
byRule.set(f.ruleId, arr);
|
|
512
|
+
}
|
|
513
|
+
let totalDeduction = 0;
|
|
514
|
+
for (const [, ruleFindings] of byRule) {
|
|
515
|
+
const bySeverity = { critical: 0, warning: 0, info: 0 };
|
|
516
|
+
for (const f of ruleFindings) {
|
|
517
|
+
bySeverity[f.severity]++;
|
|
518
|
+
}
|
|
519
|
+
for (const sev of ["critical", "warning", "info"]) {
|
|
520
|
+
const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
|
|
521
|
+
totalDeduction += count * DEDUCTIONS[sev];
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
let effectiveDeduction;
|
|
525
|
+
if (totalDeduction <= 30) {
|
|
526
|
+
effectiveDeduction = totalDeduction;
|
|
527
|
+
} else if (totalDeduction <= 50) {
|
|
528
|
+
effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
|
|
529
|
+
} else {
|
|
530
|
+
effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
|
|
334
531
|
}
|
|
335
532
|
return {
|
|
336
533
|
category,
|
|
337
|
-
score: Math.max(0,
|
|
534
|
+
score: Math.max(0, Math.round(100 - effectiveDeduction)),
|
|
338
535
|
findingCount: categoryFindings.length
|
|
339
536
|
};
|
|
340
537
|
});
|
|
341
538
|
const overallScore = Math.round(
|
|
342
|
-
categoryScores.reduce((sum, c) => sum + c.score, 0)
|
|
539
|
+
categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
|
|
343
540
|
);
|
|
344
541
|
return { overallScore, categoryScores };
|
|
345
542
|
}
|
|
@@ -510,25 +707,36 @@ var AUTH_PATTERNS = [
|
|
|
510
707
|
/jwt\.verify\s*\(/,
|
|
511
708
|
/createRouteHandlerClient/,
|
|
512
709
|
/createServerComponentClient/,
|
|
710
|
+
/createMiddlewareClient/,
|
|
513
711
|
/authorization/i,
|
|
514
|
-
/
|
|
712
|
+
/getAuth\s*\(/,
|
|
713
|
+
/withPageAuth/,
|
|
714
|
+
/cookies\(\).*auth/s
|
|
515
715
|
];
|
|
716
|
+
var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
|
|
516
717
|
var authChecksRule = {
|
|
517
718
|
id: "auth-checks",
|
|
518
719
|
name: "Missing Auth Checks",
|
|
519
720
|
description: "Detects API routes that lack authentication checks",
|
|
520
721
|
category: "security",
|
|
521
|
-
severity: "
|
|
722
|
+
severity: "warning",
|
|
522
723
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
523
724
|
check(file, project) {
|
|
524
725
|
if (!isApiRoute(file.relativePath)) return [];
|
|
525
726
|
for (const pattern of AUTH_EXEMPT_PATTERNS) {
|
|
526
727
|
if (pattern.test(file.relativePath)) return [];
|
|
527
728
|
}
|
|
528
|
-
const severity = project.hasAuthMiddleware ? "info" : "critical";
|
|
529
729
|
for (const pattern of AUTH_PATTERNS) {
|
|
530
730
|
if (pattern.test(file.content)) return [];
|
|
531
731
|
}
|
|
732
|
+
let severity;
|
|
733
|
+
if (project.hasAuthMiddleware) {
|
|
734
|
+
severity = "info";
|
|
735
|
+
} else if (MUTATION_EXPORT.test(file.content)) {
|
|
736
|
+
severity = "critical";
|
|
737
|
+
} else {
|
|
738
|
+
severity = "info";
|
|
739
|
+
}
|
|
532
740
|
let handlerLine = 1;
|
|
533
741
|
for (let i = 0; i < file.lines.length; i++) {
|
|
534
742
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -625,7 +833,10 @@ var errorHandlingRule = {
|
|
|
625
833
|
if (!isApiRoute(file.relativePath)) return [];
|
|
626
834
|
const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
|
|
627
835
|
const hasTryCatch = /try\s*\{/.test(file.content);
|
|
628
|
-
|
|
836
|
+
const hasCatchChain = /\.catch\s*\(/.test(file.content);
|
|
837
|
+
const hasOnError = /onError\s*[:(]/.test(file.content);
|
|
838
|
+
const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
|
|
839
|
+
if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
|
|
629
840
|
let handlerLine = 1;
|
|
630
841
|
for (let i = 0; i < file.lines.length; i++) {
|
|
631
842
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -730,10 +941,11 @@ var rateLimitingRule = {
|
|
|
730
941
|
name: "Missing Rate Limiting",
|
|
731
942
|
description: "Detects API routes without rate limiting",
|
|
732
943
|
category: "security",
|
|
733
|
-
severity: "
|
|
944
|
+
severity: "info",
|
|
734
945
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
735
|
-
check(file,
|
|
946
|
+
check(file, project) {
|
|
736
947
|
if (!isApiRoute(file.relativePath)) return [];
|
|
948
|
+
if (project.hasRateLimiting) return [];
|
|
737
949
|
for (const pattern of EXEMPT_PATTERNS) {
|
|
738
950
|
if (pattern.test(file.relativePath)) return [];
|
|
739
951
|
}
|
|
@@ -753,7 +965,7 @@ var rateLimitingRule = {
|
|
|
753
965
|
line: handlerLine,
|
|
754
966
|
column: 1,
|
|
755
967
|
message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
|
|
756
|
-
severity: "
|
|
968
|
+
severity: "info",
|
|
757
969
|
category: "security"
|
|
758
970
|
}];
|
|
759
971
|
}
|
|
@@ -981,6 +1193,8 @@ var SQL_INJECTION_PATTERNS = [
|
|
|
981
1193
|
// .query() or .execute() with template literal
|
|
982
1194
|
{ pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
|
|
983
1195
|
];
|
|
1196
|
+
var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
|
|
1197
|
+
var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
|
|
984
1198
|
var sqlInjectionRule = {
|
|
985
1199
|
id: "sql-injection",
|
|
986
1200
|
name: "SQL Injection Risk",
|
|
@@ -988,20 +1202,42 @@ var sqlInjectionRule = {
|
|
|
988
1202
|
category: "security",
|
|
989
1203
|
severity: "critical",
|
|
990
1204
|
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
991
|
-
check(file,
|
|
1205
|
+
check(file, project) {
|
|
992
1206
|
const findings = [];
|
|
1207
|
+
const safeTaggedLines = /* @__PURE__ */ new Set();
|
|
1208
|
+
if (file.ast) {
|
|
1209
|
+
try {
|
|
1210
|
+
walkAST(file.ast.program, (node) => {
|
|
1211
|
+
if (node.type === "TaggedTemplateExpression") {
|
|
1212
|
+
const tagged = node;
|
|
1213
|
+
if (isTaggedTemplateSql(tagged) && tagged.loc) {
|
|
1214
|
+
for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
|
|
1215
|
+
safeTaggedLines.add(l);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
} catch {
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
|
|
993
1224
|
for (let i = 0; i < file.lines.length; i++) {
|
|
994
1225
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
995
1226
|
const line = file.lines[i];
|
|
1227
|
+
const lineNum = i + 1;
|
|
1228
|
+
if (safeTaggedLines.has(lineNum)) continue;
|
|
1229
|
+
if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
|
|
1230
|
+
if (PARAMETERIZED_QUERY.test(line)) continue;
|
|
996
1231
|
for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
|
|
997
1232
|
if (pattern.test(line)) {
|
|
1233
|
+
const severity = usesORM ? "warning" : "critical";
|
|
998
1234
|
findings.push({
|
|
999
1235
|
ruleId: "sql-injection",
|
|
1000
1236
|
file: file.relativePath,
|
|
1001
|
-
line:
|
|
1237
|
+
line: lineNum,
|
|
1002
1238
|
column: 1,
|
|
1003
1239
|
message,
|
|
1004
|
-
severity
|
|
1240
|
+
severity,
|
|
1005
1241
|
category: "security"
|
|
1006
1242
|
});
|
|
1007
1243
|
break;
|
|
@@ -1023,7 +1259,7 @@ var PLACEHOLDERS = [
|
|
|
1023
1259
|
{ pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
|
|
1024
1260
|
{ pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
|
|
1025
1261
|
{ pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
|
|
1026
|
-
{ pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1262
|
+
{ pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1027
1263
|
{ pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
|
|
1028
1264
|
{ pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
|
|
1029
1265
|
];
|
|
@@ -1063,13 +1299,13 @@ var placeholderContentRule = {
|
|
|
1063
1299
|
// src/rules/stale-fallback.ts
|
|
1064
1300
|
var STALE_PATTERNS = [
|
|
1065
1301
|
{ 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" }
|
|
1302
|
+
{ pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
|
|
1303
|
+
{ pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
|
|
1304
|
+
{ pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1305
|
+
{ pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1306
|
+
{ pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1307
|
+
{ pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1308
|
+
{ pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
|
|
1073
1309
|
];
|
|
1074
1310
|
var staleFallbackRule = {
|
|
1075
1311
|
id: "stale-fallback",
|
|
@@ -1108,15 +1344,15 @@ var staleFallbackRule = {
|
|
|
1108
1344
|
|
|
1109
1345
|
// src/rules/hallucinated-api.ts
|
|
1110
1346
|
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:
|
|
1347
|
+
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1348
|
+
{ pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
|
|
1349
|
+
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1350
|
+
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1351
|
+
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
1352
|
+
{ pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
|
|
1353
|
+
{ pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
|
|
1354
|
+
{ pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
|
|
1355
|
+
{ pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
|
|
1120
1356
|
];
|
|
1121
1357
|
var hallucinatedApiRule = {
|
|
1122
1358
|
id: "hallucinated-api",
|
|
@@ -1125,14 +1361,16 @@ var hallucinatedApiRule = {
|
|
|
1125
1361
|
category: "ai-quality",
|
|
1126
1362
|
severity: "warning",
|
|
1127
1363
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1128
|
-
check(file,
|
|
1364
|
+
check(file, project) {
|
|
1129
1365
|
const findings = [];
|
|
1366
|
+
const frameworks = project.detectedFrameworks;
|
|
1130
1367
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1131
1368
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1132
1369
|
const line = file.lines[i];
|
|
1133
|
-
for (const { pattern, fix } of HALLUCINATED_APIS) {
|
|
1370
|
+
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
1134
1371
|
const match = pattern.exec(line);
|
|
1135
1372
|
if (match) {
|
|
1373
|
+
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
1136
1374
|
findings.push({
|
|
1137
1375
|
ruleId: "hallucinated-api",
|
|
1138
1376
|
file: file.relativePath,
|
|
@@ -1150,7 +1388,7 @@ var hallucinatedApiRule = {
|
|
|
1150
1388
|
};
|
|
1151
1389
|
|
|
1152
1390
|
// src/rules/open-redirect.ts
|
|
1153
|
-
var
|
|
1391
|
+
var DIRECT_INPUT_PATTERNS = [
|
|
1154
1392
|
// redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
|
|
1155
1393
|
/redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
|
|
1156
1394
|
// redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
|
|
@@ -1159,21 +1397,21 @@ var CRITICAL_PATTERNS = [
|
|
|
1159
1397
|
/NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
|
|
1160
1398
|
];
|
|
1161
1399
|
var WARNING_PATTERNS = [
|
|
1162
|
-
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
|
|
1400
|
+
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1163
1401
|
];
|
|
1164
1402
|
var openRedirectRule = {
|
|
1165
1403
|
id: "open-redirect",
|
|
1166
1404
|
name: "Open Redirect",
|
|
1167
1405
|
description: "Detects user-controlled input passed directly to redirect functions",
|
|
1168
1406
|
category: "security",
|
|
1169
|
-
severity: "
|
|
1407
|
+
severity: "warning",
|
|
1170
1408
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1171
1409
|
check(file, _project) {
|
|
1172
1410
|
const findings = [];
|
|
1173
1411
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1174
1412
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1175
1413
|
const line = file.lines[i];
|
|
1176
|
-
for (const pattern of
|
|
1414
|
+
for (const pattern of DIRECT_INPUT_PATTERNS) {
|
|
1177
1415
|
const match = pattern.exec(line);
|
|
1178
1416
|
if (match) {
|
|
1179
1417
|
findings.push({
|
|
@@ -1182,7 +1420,7 @@ var openRedirectRule = {
|
|
|
1182
1420
|
line: i + 1,
|
|
1183
1421
|
column: match.index + 1,
|
|
1184
1422
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1185
|
-
severity: "
|
|
1423
|
+
severity: "warning",
|
|
1186
1424
|
category: "security"
|
|
1187
1425
|
});
|
|
1188
1426
|
break;
|
|
@@ -1247,6 +1485,7 @@ var noSyncFsRule = {
|
|
|
1247
1485
|
|
|
1248
1486
|
// src/rules/no-n-plus-one.ts
|
|
1249
1487
|
var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
|
|
1488
|
+
var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
|
|
1250
1489
|
var noNPlusOneRule = {
|
|
1251
1490
|
id: "no-n-plus-one",
|
|
1252
1491
|
name: "No N+1 Queries",
|
|
@@ -1257,14 +1496,33 @@ var noNPlusOneRule = {
|
|
|
1257
1496
|
check(file, _project) {
|
|
1258
1497
|
if (isTestFile(file.relativePath)) return [];
|
|
1259
1498
|
if (isScriptFile(file.relativePath)) return [];
|
|
1499
|
+
const promiseAllMapLines = /* @__PURE__ */ new Set();
|
|
1500
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1501
|
+
if (PROMISE_ALL_MAP.test(file.lines[i])) {
|
|
1502
|
+
for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
|
|
1503
|
+
promiseAllMapLines.add(j);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1260
1507
|
const findings = [];
|
|
1261
|
-
|
|
1508
|
+
let loops;
|
|
1509
|
+
if (file.ast) {
|
|
1510
|
+
try {
|
|
1511
|
+
loops = findLoopsAST(file.ast);
|
|
1512
|
+
} catch {
|
|
1513
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1514
|
+
}
|
|
1515
|
+
} else {
|
|
1516
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1517
|
+
}
|
|
1262
1518
|
const reported = /* @__PURE__ */ new Set();
|
|
1263
1519
|
for (const loop of loops) {
|
|
1264
1520
|
if (reported.has(loop.loopLine)) continue;
|
|
1521
|
+
if (promiseAllMapLines.has(loop.loopLine)) continue;
|
|
1265
1522
|
for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
|
|
1266
1523
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1267
1524
|
const line = file.lines[i];
|
|
1525
|
+
if (promiseAllMapLines.has(i)) continue;
|
|
1268
1526
|
const match = DB_CALL_PATTERN.exec(line);
|
|
1269
1527
|
if (match) {
|
|
1270
1528
|
reported.add(loop.loopLine);
|
|
@@ -1398,6 +1656,10 @@ var HANDLED_PATTERNS = [
|
|
|
1398
1656
|
/Promise\.allSettled/,
|
|
1399
1657
|
/Promise\.race/
|
|
1400
1658
|
];
|
|
1659
|
+
var CHAIN_START_PATTERNS = [
|
|
1660
|
+
/\.from\s*\(/,
|
|
1661
|
+
/\.rpc\s*\(/
|
|
1662
|
+
];
|
|
1401
1663
|
var unhandledPromiseRule = {
|
|
1402
1664
|
id: "unhandled-promise",
|
|
1403
1665
|
name: "Unhandled Promise",
|
|
@@ -1417,6 +1679,19 @@ var unhandledPromiseRule = {
|
|
|
1417
1679
|
if (!asyncMatch) continue;
|
|
1418
1680
|
const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
|
|
1419
1681
|
if (isHandled) continue;
|
|
1682
|
+
const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
|
|
1683
|
+
if (isChainContinuation) {
|
|
1684
|
+
let chainHandled = false;
|
|
1685
|
+
for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
|
|
1686
|
+
const prevTrimmed = file.lines[j].trim();
|
|
1687
|
+
if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
|
|
1688
|
+
if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
|
|
1689
|
+
chainHandled = true;
|
|
1690
|
+
break;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (chainHandled) continue;
|
|
1694
|
+
}
|
|
1420
1695
|
let handledAbove = false;
|
|
1421
1696
|
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
1422
1697
|
const prevTrimmed = file.lines[j].trim();
|
|
@@ -1484,9 +1759,9 @@ var missingErrorBoundaryRule = {
|
|
|
1484
1759
|
severity: "info",
|
|
1485
1760
|
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
1486
1761
|
check(file, project) {
|
|
1487
|
-
const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1762
|
+
const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1488
1763
|
if (!match) return [];
|
|
1489
|
-
const dir =
|
|
1764
|
+
const dir = match[1] + match[2];
|
|
1490
1765
|
const hasErrorBoundary = project.allFiles.some(
|
|
1491
1766
|
(f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
|
|
1492
1767
|
);
|
|
@@ -1664,7 +1939,8 @@ var deadExportsRule = {
|
|
|
1664
1939
|
(f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
|
|
1665
1940
|
);
|
|
1666
1941
|
const exports = /* @__PURE__ */ new Map();
|
|
1667
|
-
const imports = /* @__PURE__ */ new
|
|
1942
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1943
|
+
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
1668
1944
|
const importedFiles = /* @__PURE__ */ new Set();
|
|
1669
1945
|
for (const file of sourceFiles) {
|
|
1670
1946
|
if (isEntryPoint(file.relativePath)) continue;
|
|
@@ -1688,14 +1964,28 @@ var deadExportsRule = {
|
|
|
1688
1964
|
for (const file of files) {
|
|
1689
1965
|
for (const line of file.lines) {
|
|
1690
1966
|
let match;
|
|
1967
|
+
const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
|
|
1968
|
+
const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
|
|
1691
1969
|
const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
|
|
1692
1970
|
while ((match = bracesRe.exec(line)) !== null) {
|
|
1693
1971
|
const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
|
|
1694
|
-
for (const sym of symbols)
|
|
1972
|
+
for (const sym of symbols) {
|
|
1973
|
+
allImportedSymbols.add(sym);
|
|
1974
|
+
if (fromBasename) {
|
|
1975
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1976
|
+
set.add(sym);
|
|
1977
|
+
imports.set(fromBasename, set);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1695
1980
|
}
|
|
1696
1981
|
const defaultRe = /import\s+(\w+)\s+from/g;
|
|
1697
1982
|
while ((match = defaultRe.exec(line)) !== null) {
|
|
1698
|
-
|
|
1983
|
+
allImportedSymbols.add(match[1]);
|
|
1984
|
+
if (fromBasename) {
|
|
1985
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1986
|
+
set.add(match[1]);
|
|
1987
|
+
imports.set(fromBasename, set);
|
|
1988
|
+
}
|
|
1699
1989
|
}
|
|
1700
1990
|
const fromRe = /from\s+['"]([^'"]+)['"]/g;
|
|
1701
1991
|
while ((match = fromRe.exec(line)) !== null) {
|
|
@@ -1706,7 +1996,10 @@ var deadExportsRule = {
|
|
|
1706
1996
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
1707
1997
|
for (const [key, loc] of exports) {
|
|
1708
1998
|
const symbolName = key.split("::")[1];
|
|
1709
|
-
|
|
1999
|
+
const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
|
|
2000
|
+
const importSet = imports.get(exportFileBasename);
|
|
2001
|
+
const isImported = importSet?.has(symbolName) ?? false;
|
|
2002
|
+
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
1710
2003
|
deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
|
|
1711
2004
|
}
|
|
1712
2005
|
}
|
|
@@ -1776,12 +2069,33 @@ var shallowCatchRule = {
|
|
|
1776
2069
|
if (braceStart === -1) continue;
|
|
1777
2070
|
let depth = 0;
|
|
1778
2071
|
let bodyEnd = braceStart;
|
|
2072
|
+
let inSingle = false;
|
|
2073
|
+
let inDouble = false;
|
|
2074
|
+
let inTemplate = false;
|
|
1779
2075
|
for (let j = braceStart; j < file.lines.length; j++) {
|
|
1780
2076
|
const line = file.lines[j];
|
|
1781
2077
|
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
1782
2078
|
for (let k = startPos; k < line.length; k++) {
|
|
1783
|
-
|
|
1784
|
-
|
|
2079
|
+
const ch = line[k];
|
|
2080
|
+
const prev = k > 0 ? line[k - 1] : "";
|
|
2081
|
+
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2082
|
+
if (!escaped) {
|
|
2083
|
+
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2084
|
+
inSingle = !inSingle;
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2088
|
+
inDouble = !inDouble;
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
if (ch === "`" && !inSingle && !inDouble) {
|
|
2092
|
+
inTemplate = !inTemplate;
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
2097
|
+
if (ch === "{") depth++;
|
|
2098
|
+
if (ch === "}") {
|
|
1785
2099
|
depth--;
|
|
1786
2100
|
if (depth === 0) {
|
|
1787
2101
|
bodyEnd = j;
|
|
@@ -1948,6 +2262,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
1948
2262
|
"gpt-tokenizer"
|
|
1949
2263
|
// exists but often confused
|
|
1950
2264
|
]);
|
|
2265
|
+
var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
2266
|
+
"pg",
|
|
2267
|
+
"ws",
|
|
2268
|
+
"ms",
|
|
2269
|
+
"qs",
|
|
2270
|
+
"ip",
|
|
2271
|
+
"is",
|
|
2272
|
+
"he",
|
|
2273
|
+
"ky",
|
|
2274
|
+
"bl",
|
|
2275
|
+
"rc",
|
|
2276
|
+
"io",
|
|
2277
|
+
"db",
|
|
2278
|
+
"fp",
|
|
2279
|
+
"rx"
|
|
2280
|
+
]);
|
|
1951
2281
|
var SUSPICIOUS_PATTERNS = [
|
|
1952
2282
|
/^[a-z]{1,2}$/,
|
|
1953
2283
|
// 1-2 char names
|
|
@@ -1986,7 +2316,7 @@ var phantomDependencyRule = {
|
|
|
1986
2316
|
});
|
|
1987
2317
|
}
|
|
1988
2318
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
1989
|
-
if (pattern.test(name) && !name.startsWith("@")) {
|
|
2319
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
|
|
1990
2320
|
findings.push({
|
|
1991
2321
|
ruleId: "phantom-dependency",
|
|
1992
2322
|
file: "package.json",
|
|
@@ -2004,6 +2334,210 @@ var phantomDependencyRule = {
|
|
|
2004
2334
|
}
|
|
2005
2335
|
};
|
|
2006
2336
|
|
|
2337
|
+
// src/rules/insecure-cookie.ts
|
|
2338
|
+
var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
|
|
2339
|
+
var COOKIE_SET_PATTERNS = [
|
|
2340
|
+
/cookies\(\)\s*\.set\s*\(/,
|
|
2341
|
+
/res\.cookie\s*\(/,
|
|
2342
|
+
/response\.cookies\.set\s*\(/
|
|
2343
|
+
];
|
|
2344
|
+
var SECURE_OPTIONS = [
|
|
2345
|
+
/httpOnly\s*:\s*true/,
|
|
2346
|
+
/secure\s*:\s*true/,
|
|
2347
|
+
/sameSite\s*:/
|
|
2348
|
+
];
|
|
2349
|
+
var insecureCookieRule = {
|
|
2350
|
+
id: "insecure-cookie",
|
|
2351
|
+
name: "Insecure Cookie",
|
|
2352
|
+
description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
|
|
2353
|
+
category: "security",
|
|
2354
|
+
severity: "warning",
|
|
2355
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2356
|
+
check(file, _project) {
|
|
2357
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2358
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2359
|
+
const findings = [];
|
|
2360
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2361
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2362
|
+
const line = file.lines[i];
|
|
2363
|
+
const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
|
|
2364
|
+
if (!isCookieSet) continue;
|
|
2365
|
+
if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
|
|
2366
|
+
const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
|
|
2367
|
+
const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
|
|
2368
|
+
if (missingOptions.length > 0) {
|
|
2369
|
+
const missing = [];
|
|
2370
|
+
if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
|
|
2371
|
+
if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
|
|
2372
|
+
if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
|
|
2373
|
+
findings.push({
|
|
2374
|
+
ruleId: "insecure-cookie",
|
|
2375
|
+
file: file.relativePath,
|
|
2376
|
+
line: i + 1,
|
|
2377
|
+
column: 1,
|
|
2378
|
+
message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
|
|
2379
|
+
severity: "warning",
|
|
2380
|
+
category: "security",
|
|
2381
|
+
fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
return findings;
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
|
|
2389
|
+
// src/rules/leaked-env-in-logs.ts
|
|
2390
|
+
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2391
|
+
var leakedEnvInLogsRule = {
|
|
2392
|
+
id: "leaked-env-in-logs",
|
|
2393
|
+
name: "Leaked Env in Logs",
|
|
2394
|
+
description: "Detects process.env values logged to console \u2014 potential secret exposure",
|
|
2395
|
+
category: "security",
|
|
2396
|
+
severity: "warning",
|
|
2397
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2398
|
+
check(file, _project) {
|
|
2399
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2400
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2401
|
+
const findings = [];
|
|
2402
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2403
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2404
|
+
const line = file.lines[i];
|
|
2405
|
+
const match = CONSOLE_WITH_ENV.exec(line);
|
|
2406
|
+
if (match) {
|
|
2407
|
+
findings.push({
|
|
2408
|
+
ruleId: "leaked-env-in-logs",
|
|
2409
|
+
file: file.relativePath,
|
|
2410
|
+
line: i + 1,
|
|
2411
|
+
column: match.index + 1,
|
|
2412
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2413
|
+
severity: "warning",
|
|
2414
|
+
category: "security",
|
|
2415
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
return findings;
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
|
|
2423
|
+
// src/rules/insecure-random.ts
|
|
2424
|
+
var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
|
|
2425
|
+
var MATH_RANDOM = /Math\.random\s*\(\)/;
|
|
2426
|
+
var insecureRandomRule = {
|
|
2427
|
+
id: "insecure-random",
|
|
2428
|
+
name: "Insecure Random",
|
|
2429
|
+
description: "Detects Math.random() used near security-sensitive variable names",
|
|
2430
|
+
category: "security",
|
|
2431
|
+
severity: "warning",
|
|
2432
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2433
|
+
check(file, _project) {
|
|
2434
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2435
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2436
|
+
const findings = [];
|
|
2437
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2438
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2439
|
+
const line = file.lines[i];
|
|
2440
|
+
const match = MATH_RANDOM.exec(line);
|
|
2441
|
+
if (!match) continue;
|
|
2442
|
+
const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
|
|
2443
|
+
if (SECURITY_VAR_NAMES.test(context)) {
|
|
2444
|
+
findings.push({
|
|
2445
|
+
ruleId: "insecure-random",
|
|
2446
|
+
file: file.relativePath,
|
|
2447
|
+
line: i + 1,
|
|
2448
|
+
column: match.index + 1,
|
|
2449
|
+
message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
|
|
2450
|
+
severity: "warning",
|
|
2451
|
+
category: "security",
|
|
2452
|
+
fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
return findings;
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
|
|
2460
|
+
// src/rules/next-server-action-validation.ts
|
|
2461
|
+
var USE_SERVER = /['"]use server['"]/;
|
|
2462
|
+
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
2463
|
+
var VALIDATION_PATTERNS2 = [
|
|
2464
|
+
/\.parse\s*\(/,
|
|
2465
|
+
/\.safeParse\s*\(/,
|
|
2466
|
+
/\bvalidate\s*\(/,
|
|
2467
|
+
/\.parseAsync\s*\(/,
|
|
2468
|
+
/\.safeParseAsync\s*\(/
|
|
2469
|
+
];
|
|
2470
|
+
var nextServerActionValidationRule = {
|
|
2471
|
+
id: "next-server-action-validation",
|
|
2472
|
+
name: "Next.js Server Action Validation",
|
|
2473
|
+
description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
|
|
2474
|
+
category: "security",
|
|
2475
|
+
severity: "critical",
|
|
2476
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2477
|
+
check(file, _project) {
|
|
2478
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2479
|
+
if (!USE_SERVER.test(file.content)) return [];
|
|
2480
|
+
if (!FORM_DATA_GET.test(file.content)) return [];
|
|
2481
|
+
const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
|
|
2482
|
+
if (hasValidation) return [];
|
|
2483
|
+
let reportLine = 1;
|
|
2484
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2485
|
+
if (FORM_DATA_GET.test(file.lines[i])) {
|
|
2486
|
+
reportLine = i + 1;
|
|
2487
|
+
break;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
return [{
|
|
2491
|
+
ruleId: "next-server-action-validation",
|
|
2492
|
+
file: file.relativePath,
|
|
2493
|
+
line: reportLine,
|
|
2494
|
+
column: 1,
|
|
2495
|
+
message: "Server action reads formData without schema validation \u2014 unvalidated user input",
|
|
2496
|
+
severity: "critical",
|
|
2497
|
+
category: "security",
|
|
2498
|
+
fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
|
|
2499
|
+
}];
|
|
2500
|
+
}
|
|
2501
|
+
};
|
|
2502
|
+
|
|
2503
|
+
// src/rules/missing-transaction.ts
|
|
2504
|
+
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2505
|
+
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2506
|
+
var missingTransactionRule = {
|
|
2507
|
+
id: "missing-transaction",
|
|
2508
|
+
name: "Missing Transaction",
|
|
2509
|
+
description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
|
|
2510
|
+
category: "reliability",
|
|
2511
|
+
severity: "warning",
|
|
2512
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2513
|
+
check(file, project) {
|
|
2514
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2515
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2516
|
+
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2517
|
+
let writeCount = 0;
|
|
2518
|
+
let firstWriteLine = -1;
|
|
2519
|
+
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
2520
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2521
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2522
|
+
if (PRISMA_WRITE_OPS.test(file.lines[i])) {
|
|
2523
|
+
writeCount++;
|
|
2524
|
+
if (firstWriteLine === -1) firstWriteLine = i;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
if (writeCount < 2 || hasTransaction) return [];
|
|
2528
|
+
return [{
|
|
2529
|
+
ruleId: "missing-transaction",
|
|
2530
|
+
file: file.relativePath,
|
|
2531
|
+
line: firstWriteLine + 1,
|
|
2532
|
+
column: 1,
|
|
2533
|
+
message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2534
|
+
severity: "warning",
|
|
2535
|
+
category: "reliability",
|
|
2536
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2537
|
+
}];
|
|
2538
|
+
}
|
|
2539
|
+
};
|
|
2540
|
+
|
|
2007
2541
|
// src/rules/index.ts
|
|
2008
2542
|
var rules = [
|
|
2009
2543
|
// Security
|
|
@@ -2017,6 +2551,10 @@ var rules = [
|
|
|
2017
2551
|
openRedirectRule,
|
|
2018
2552
|
rateLimitingRule,
|
|
2019
2553
|
phantomDependencyRule,
|
|
2554
|
+
insecureCookieRule,
|
|
2555
|
+
leakedEnvInLogsRule,
|
|
2556
|
+
insecureRandomRule,
|
|
2557
|
+
nextServerActionValidationRule,
|
|
2020
2558
|
// Reliability
|
|
2021
2559
|
hallucinatedImportsRule,
|
|
2022
2560
|
errorHandlingRule,
|
|
@@ -2024,6 +2562,7 @@ var rules = [
|
|
|
2024
2562
|
shallowCatchRule,
|
|
2025
2563
|
missingLoadingStateRule,
|
|
2026
2564
|
missingErrorBoundaryRule,
|
|
2565
|
+
missingTransactionRule,
|
|
2027
2566
|
// Performance
|
|
2028
2567
|
noSyncFsRule,
|
|
2029
2568
|
noNPlusOneRule,
|
|
@@ -2110,11 +2649,16 @@ function groupByFile(findings) {
|
|
|
2110
2649
|
}
|
|
2111
2650
|
return map;
|
|
2112
2651
|
}
|
|
2113
|
-
function reportPretty(result) {
|
|
2652
|
+
function reportPretty(result, opts = {}) {
|
|
2114
2653
|
const lines = [];
|
|
2654
|
+
const { critical, warning, info } = result.summary;
|
|
2115
2655
|
lines.push("");
|
|
2116
2656
|
lines.push(pc.bold(" prodlint") + pc.dim(` v${result.version}`));
|
|
2117
|
-
|
|
2657
|
+
const headerParts = [`Scanned ${result.filesScanned} files`];
|
|
2658
|
+
if (critical > 0) headerParts.push(`${critical} critical`);
|
|
2659
|
+
if (warning > 0) headerParts.push(`${warning} warnings`);
|
|
2660
|
+
if (info > 0) headerParts.push(`${info} info`);
|
|
2661
|
+
lines.push(pc.dim(` ${headerParts.join(" \xB7 ")}`));
|
|
2118
2662
|
lines.push("");
|
|
2119
2663
|
if (result.findings.length > 0) {
|
|
2120
2664
|
const grouped = groupByFile(result.findings);
|
|
@@ -2126,6 +2670,9 @@ function reportPretty(result) {
|
|
|
2126
2670
|
lines.push(
|
|
2127
2671
|
` ${pc.dim(`${f.line}:${f.column}`)} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`
|
|
2128
2672
|
);
|
|
2673
|
+
if (f.fix) {
|
|
2674
|
+
lines.push(` ${pc.dim(` \u21B3 ${f.fix}`)}`);
|
|
2675
|
+
}
|
|
2129
2676
|
}
|
|
2130
2677
|
lines.push("");
|
|
2131
2678
|
}
|
|
@@ -2140,22 +2687,23 @@ function reportPretty(result) {
|
|
|
2140
2687
|
const overallColor = scoreColor(result.overallScore);
|
|
2141
2688
|
lines.push(pc.bold(` Overall: ${overallColor(String(result.overallScore))}/100`));
|
|
2142
2689
|
lines.push("");
|
|
2143
|
-
const
|
|
2144
|
-
|
|
2145
|
-
if (
|
|
2146
|
-
if (
|
|
2147
|
-
if (
|
|
2148
|
-
if (parts.length === 0) {
|
|
2690
|
+
const summaryParts = [];
|
|
2691
|
+
if (critical > 0) summaryParts.push(pc.red(`${critical} critical`));
|
|
2692
|
+
if (warning > 0) summaryParts.push(pc.yellow(`${warning} warnings`));
|
|
2693
|
+
if (info > 0) summaryParts.push(pc.blue(`${info} info`));
|
|
2694
|
+
if (summaryParts.length === 0) {
|
|
2149
2695
|
lines.push(pc.green(" No issues found!"));
|
|
2150
2696
|
} else {
|
|
2151
|
-
lines.push(` ${
|
|
2697
|
+
lines.push(` ${summaryParts.join(pc.dim(" \xB7 "))}`);
|
|
2152
2698
|
}
|
|
2153
2699
|
lines.push("");
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2700
|
+
if (!opts.quiet) {
|
|
2701
|
+
const badgeColor = result.overallScore >= 80 ? "brightgreen" : result.overallScore >= 60 ? "yellow" : "red";
|
|
2702
|
+
const badgeUrl = `https://img.shields.io/badge/prodlint-${result.overallScore}%2F100-${badgeColor}`;
|
|
2703
|
+
lines.push(pc.dim(" Add to your README:"));
|
|
2704
|
+
lines.push(pc.dim(` [](https://prodlint.com)`));
|
|
2705
|
+
lines.push("");
|
|
2706
|
+
}
|
|
2159
2707
|
return lines.join("\n");
|
|
2160
2708
|
}
|
|
2161
2709
|
function reportJson(result) {
|
|
@@ -2170,12 +2718,15 @@ function renderBar(score) {
|
|
|
2170
2718
|
}
|
|
2171
2719
|
|
|
2172
2720
|
// src/cli.ts
|
|
2721
|
+
var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
2173
2722
|
async function main() {
|
|
2174
2723
|
const { values, positionals } = parseArgs({
|
|
2175
2724
|
allowPositionals: true,
|
|
2176
2725
|
options: {
|
|
2177
2726
|
json: { type: "boolean", default: false },
|
|
2178
2727
|
ignore: { type: "string", multiple: true, default: [] },
|
|
2728
|
+
"min-severity": { type: "string", default: "info" },
|
|
2729
|
+
quiet: { type: "boolean", default: false },
|
|
2179
2730
|
help: { type: "boolean", short: "h", default: false },
|
|
2180
2731
|
version: { type: "boolean", short: "v", default: false }
|
|
2181
2732
|
}
|
|
@@ -2189,14 +2740,17 @@ async function main() {
|
|
|
2189
2740
|
process.exit(0);
|
|
2190
2741
|
}
|
|
2191
2742
|
const targetPath = positionals[0] ?? ".";
|
|
2743
|
+
const minSeverity = values["min-severity"] ?? "info";
|
|
2192
2744
|
const result = await scan({
|
|
2193
2745
|
path: targetPath,
|
|
2194
2746
|
ignore: values.ignore
|
|
2195
2747
|
});
|
|
2748
|
+
const minRank = SEVERITY_RANK[minSeverity] ?? 1;
|
|
2749
|
+
result.findings = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank);
|
|
2196
2750
|
if (values.json) {
|
|
2197
2751
|
console.log(reportJson(result));
|
|
2198
2752
|
} else {
|
|
2199
|
-
console.log(reportPretty(result));
|
|
2753
|
+
console.log(reportPretty(result, { quiet: values.quiet }));
|
|
2200
2754
|
}
|
|
2201
2755
|
if (result.summary.critical > 0) {
|
|
2202
2756
|
process.exit(1);
|
|
@@ -2210,16 +2764,20 @@ function printHelp() {
|
|
|
2210
2764
|
npx prodlint [path] [options]
|
|
2211
2765
|
|
|
2212
2766
|
Options:
|
|
2213
|
-
--json
|
|
2214
|
-
--ignore <pattern>
|
|
2215
|
-
-
|
|
2216
|
-
|
|
2767
|
+
--json Output results as JSON
|
|
2768
|
+
--ignore <pattern> Glob patterns to ignore (can be repeated)
|
|
2769
|
+
--min-severity <level> Minimum severity to show: critical, warning, info (default: info)
|
|
2770
|
+
--quiet Suppress badge and summary
|
|
2771
|
+
-h, --help Show this help message
|
|
2772
|
+
-v, --version Show version
|
|
2217
2773
|
|
|
2218
2774
|
Examples:
|
|
2219
|
-
npx prodlint
|
|
2220
|
-
npx prodlint ./my-app
|
|
2221
|
-
npx prodlint --json
|
|
2222
|
-
npx prodlint --ignore "*.test"
|
|
2775
|
+
npx prodlint Scan current directory
|
|
2776
|
+
npx prodlint ./my-app Scan specific path
|
|
2777
|
+
npx prodlint --json JSON output
|
|
2778
|
+
npx prodlint --ignore "*.test" Ignore test files
|
|
2779
|
+
npx prodlint --min-severity warning Only warnings and criticals
|
|
2780
|
+
npx prodlint --quiet No badge output
|
|
2223
2781
|
`);
|
|
2224
2782
|
}
|
|
2225
2783
|
main().catch((err) => {
|