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/mcp.js
CHANGED
|
@@ -167,6 +167,133 @@ var NODE_BUILTINS = /* @__PURE__ */ new Set([
|
|
|
167
167
|
// node: prefixed are handled separately
|
|
168
168
|
]);
|
|
169
169
|
|
|
170
|
+
// src/utils/ast.ts
|
|
171
|
+
import { parse } from "@babel/parser";
|
|
172
|
+
function parseFile(content, fileName) {
|
|
173
|
+
const plugins = ["decorators"];
|
|
174
|
+
if (/\.tsx?$/.test(fileName)) {
|
|
175
|
+
plugins.push("typescript");
|
|
176
|
+
}
|
|
177
|
+
if (/\.[jt]sx$/.test(fileName)) {
|
|
178
|
+
plugins.push("jsx");
|
|
179
|
+
}
|
|
180
|
+
if (/\.(js|mjs|cjs)$/.test(fileName)) {
|
|
181
|
+
plugins.push("jsx");
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
return parse(content, {
|
|
185
|
+
sourceType: "module",
|
|
186
|
+
allowImportExportEverywhere: true,
|
|
187
|
+
allowReturnOutsideFunction: true,
|
|
188
|
+
plugins
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function walkAST(node, visitor, parent = null) {
|
|
195
|
+
if (!node || typeof node !== "object") return;
|
|
196
|
+
visitor(node, parent);
|
|
197
|
+
for (const key of Object.keys(node)) {
|
|
198
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
199
|
+
const val = node[key];
|
|
200
|
+
if (Array.isArray(val)) {
|
|
201
|
+
for (const item of val) {
|
|
202
|
+
if (item && typeof item === "object" && item.type) {
|
|
203
|
+
walkAST(item, visitor, node);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
207
|
+
walkAST(val, visitor, node);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function isTaggedTemplateSql(node) {
|
|
212
|
+
const tag = node.tag;
|
|
213
|
+
if (tag.type === "Identifier" && tag.name === "sql") return true;
|
|
214
|
+
if (tag.type === "MemberExpression") {
|
|
215
|
+
const prop = tag.property;
|
|
216
|
+
if (prop.type === "Identifier" && (prop.name === "sql" || prop.name === "query" || prop.name === "raw")) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
function findLoopsAST(ast) {
|
|
223
|
+
const loops = [];
|
|
224
|
+
walkAST(ast.program, (node) => {
|
|
225
|
+
if (node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement" || node.type === "WhileStatement" || node.type === "DoWhileStatement") {
|
|
226
|
+
const loop = node;
|
|
227
|
+
const body = loop.body;
|
|
228
|
+
if (body.loc && node.loc) {
|
|
229
|
+
loops.push({
|
|
230
|
+
loopLine: node.loc.start.line - 1,
|
|
231
|
+
bodyStart: body.loc.start.line - 1,
|
|
232
|
+
bodyEnd: body.loc.end.line - 1
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (node.type === "CallExpression") {
|
|
237
|
+
const call = node;
|
|
238
|
+
if (call.callee.type === "MemberExpression" && call.callee.property.type === "Identifier" && (call.callee.property.name === "forEach" || call.callee.property.name === "map")) {
|
|
239
|
+
const callback = call.arguments[0];
|
|
240
|
+
if (callback && callback.loc && node.loc) {
|
|
241
|
+
loops.push({
|
|
242
|
+
loopLine: node.loc.start.line - 1,
|
|
243
|
+
bodyStart: callback.loc.start.line - 1,
|
|
244
|
+
bodyEnd: callback.loc.end.line - 1
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
return loops;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/utils/frameworks.ts
|
|
254
|
+
var FRAMEWORK_SAFE_METHODS = {
|
|
255
|
+
prisma: ["contains", "startsWith", "endsWith", "has", "hasEvery", "hasSome", "isEmpty"],
|
|
256
|
+
supabase: ["contains", "containedBy", "overlaps", "eq", "neq", "gt", "gte", "lt", "lte"],
|
|
257
|
+
drizzle: ["arrayContains", "arrayContainedIn", "arrayOverlaps"],
|
|
258
|
+
lodash: ["flatten", "flattenDeep", "contains", "includes", "has"],
|
|
259
|
+
mongoose: ["contains"]
|
|
260
|
+
};
|
|
261
|
+
function isFrameworkSafeMethod(methodName, frameworks) {
|
|
262
|
+
for (const framework of frameworks) {
|
|
263
|
+
const safeMethods = FRAMEWORK_SAFE_METHODS[framework];
|
|
264
|
+
if (safeMethods && safeMethods.includes(methodName)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
var DEPENDENCY_TO_FRAMEWORK = {
|
|
271
|
+
"@prisma/client": "prisma",
|
|
272
|
+
"prisma": "prisma",
|
|
273
|
+
"@supabase/supabase-js": "supabase",
|
|
274
|
+
"@supabase/ssr": "supabase",
|
|
275
|
+
"drizzle-orm": "drizzle",
|
|
276
|
+
"@trpc/server": "trpc",
|
|
277
|
+
"next-auth": "next-auth",
|
|
278
|
+
"@auth/nextjs": "next-auth",
|
|
279
|
+
"@auth/core": "next-auth",
|
|
280
|
+
"express": "express",
|
|
281
|
+
"fastify": "fastify",
|
|
282
|
+
"hono": "hono",
|
|
283
|
+
"lodash": "lodash",
|
|
284
|
+
"lodash-es": "lodash",
|
|
285
|
+
"underscore": "lodash",
|
|
286
|
+
"mongoose": "mongoose",
|
|
287
|
+
"typeorm": "typeorm",
|
|
288
|
+
"sequelize": "sequelize",
|
|
289
|
+
"knex": "knex",
|
|
290
|
+
"@upstash/ratelimit": "upstash-ratelimit",
|
|
291
|
+
"express-rate-limit": "express-rate-limit",
|
|
292
|
+
"rate-limiter-flexible": "rate-limiter-flexible"
|
|
293
|
+
};
|
|
294
|
+
var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
|
|
295
|
+
var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
|
|
296
|
+
|
|
170
297
|
// src/utils/file-walker.ts
|
|
171
298
|
var DEFAULT_IGNORES = [
|
|
172
299
|
"**/node_modules/**",
|
|
@@ -193,6 +320,7 @@ var SCAN_EXTENSIONS = [
|
|
|
193
320
|
"cjs",
|
|
194
321
|
"json"
|
|
195
322
|
];
|
|
323
|
+
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
196
324
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
197
325
|
async function walkFiles(root, extraIgnores = []) {
|
|
198
326
|
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
@@ -217,14 +345,23 @@ async function readFileContext(root, relativePath) {
|
|
|
217
345
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
218
346
|
const content = await readFile(absolutePath, "utf-8");
|
|
219
347
|
const lines = content.split(/\r?\n|\r/);
|
|
348
|
+
const ext = extname(relativePath).slice(1);
|
|
349
|
+
let ast = void 0;
|
|
350
|
+
if (AST_EXTENSIONS.has(ext)) {
|
|
351
|
+
try {
|
|
352
|
+
ast = parseFile(content, relativePath);
|
|
353
|
+
} catch {
|
|
354
|
+
ast = null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
220
357
|
return {
|
|
221
358
|
absolutePath,
|
|
222
359
|
relativePath,
|
|
223
360
|
content,
|
|
224
361
|
lines,
|
|
225
|
-
ext
|
|
226
|
-
|
|
227
|
-
|
|
362
|
+
ext,
|
|
363
|
+
commentMap: buildCommentMap(lines),
|
|
364
|
+
ast
|
|
228
365
|
};
|
|
229
366
|
} catch {
|
|
230
367
|
return null;
|
|
@@ -235,6 +372,8 @@ async function buildProjectContext(root, files) {
|
|
|
235
372
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
236
373
|
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
237
374
|
let hasAuthMiddleware = false;
|
|
375
|
+
let hasRateLimiting = false;
|
|
376
|
+
const detectedFrameworks = /* @__PURE__ */ new Set();
|
|
238
377
|
let gitignoreContent = null;
|
|
239
378
|
let envInGitignore = false;
|
|
240
379
|
try {
|
|
@@ -246,6 +385,18 @@ async function buildProjectContext(root, files) {
|
|
|
246
385
|
...packageJson?.peerDependencies ?? {}
|
|
247
386
|
};
|
|
248
387
|
declaredDependencies = new Set(Object.keys(deps));
|
|
388
|
+
for (const dep of declaredDependencies) {
|
|
389
|
+
const framework = DEPENDENCY_TO_FRAMEWORK[dep];
|
|
390
|
+
if (framework) {
|
|
391
|
+
detectedFrameworks.add(framework);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const framework of detectedFrameworks) {
|
|
395
|
+
if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
|
|
396
|
+
hasRateLimiting = true;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
249
400
|
} catch {
|
|
250
401
|
}
|
|
251
402
|
try {
|
|
@@ -289,6 +440,18 @@ async function buildProjectContext(root, files) {
|
|
|
289
440
|
}
|
|
290
441
|
} catch {
|
|
291
442
|
}
|
|
443
|
+
if (!hasRateLimiting) {
|
|
444
|
+
for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
|
|
445
|
+
try {
|
|
446
|
+
const content = await readFile(resolve(root, name), "utf-8");
|
|
447
|
+
if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
|
|
448
|
+
hasRateLimiting = true;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
292
455
|
try {
|
|
293
456
|
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
294
457
|
envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
|
|
@@ -300,6 +463,8 @@ async function buildProjectContext(root, files) {
|
|
|
300
463
|
declaredDependencies,
|
|
301
464
|
tsconfigPaths,
|
|
302
465
|
hasAuthMiddleware,
|
|
466
|
+
hasRateLimiting,
|
|
467
|
+
detectedFrameworks,
|
|
303
468
|
gitignoreContent,
|
|
304
469
|
envInGitignore,
|
|
305
470
|
allFiles: files
|
|
@@ -324,26 +489,58 @@ function getVersion() {
|
|
|
324
489
|
|
|
325
490
|
// src/scorer.ts
|
|
326
491
|
var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
|
|
492
|
+
var CATEGORY_WEIGHTS = {
|
|
493
|
+
"security": 0.4,
|
|
494
|
+
"reliability": 0.3,
|
|
495
|
+
"performance": 0.15,
|
|
496
|
+
"ai-quality": 0.15
|
|
497
|
+
};
|
|
327
498
|
var DEDUCTIONS = {
|
|
328
|
-
critical:
|
|
329
|
-
warning:
|
|
330
|
-
info:
|
|
499
|
+
critical: 8,
|
|
500
|
+
warning: 2,
|
|
501
|
+
info: 0.5
|
|
502
|
+
};
|
|
503
|
+
var PER_RULE_CAP = {
|
|
504
|
+
critical: 1,
|
|
505
|
+
warning: 2,
|
|
506
|
+
info: 3
|
|
331
507
|
};
|
|
332
508
|
function calculateScores(findings) {
|
|
333
509
|
const categoryScores = CATEGORIES.map((category) => {
|
|
334
510
|
const categoryFindings = findings.filter((f) => f.category === category);
|
|
335
|
-
|
|
511
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
336
512
|
for (const f of categoryFindings) {
|
|
337
|
-
|
|
513
|
+
const arr = byRule.get(f.ruleId) ?? [];
|
|
514
|
+
arr.push(f);
|
|
515
|
+
byRule.set(f.ruleId, arr);
|
|
516
|
+
}
|
|
517
|
+
let totalDeduction = 0;
|
|
518
|
+
for (const [, ruleFindings] of byRule) {
|
|
519
|
+
const bySeverity = { critical: 0, warning: 0, info: 0 };
|
|
520
|
+
for (const f of ruleFindings) {
|
|
521
|
+
bySeverity[f.severity]++;
|
|
522
|
+
}
|
|
523
|
+
for (const sev of ["critical", "warning", "info"]) {
|
|
524
|
+
const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
|
|
525
|
+
totalDeduction += count * DEDUCTIONS[sev];
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
let effectiveDeduction;
|
|
529
|
+
if (totalDeduction <= 30) {
|
|
530
|
+
effectiveDeduction = totalDeduction;
|
|
531
|
+
} else if (totalDeduction <= 50) {
|
|
532
|
+
effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
|
|
533
|
+
} else {
|
|
534
|
+
effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
|
|
338
535
|
}
|
|
339
536
|
return {
|
|
340
537
|
category,
|
|
341
|
-
score: Math.max(0,
|
|
538
|
+
score: Math.max(0, Math.round(100 - effectiveDeduction)),
|
|
342
539
|
findingCount: categoryFindings.length
|
|
343
540
|
};
|
|
344
541
|
});
|
|
345
542
|
const overallScore = Math.round(
|
|
346
|
-
categoryScores.reduce((sum, c) => sum + c.score, 0)
|
|
543
|
+
categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
|
|
347
544
|
);
|
|
348
545
|
return { overallScore, categoryScores };
|
|
349
546
|
}
|
|
@@ -514,25 +711,36 @@ var AUTH_PATTERNS = [
|
|
|
514
711
|
/jwt\.verify\s*\(/,
|
|
515
712
|
/createRouteHandlerClient/,
|
|
516
713
|
/createServerComponentClient/,
|
|
714
|
+
/createMiddlewareClient/,
|
|
517
715
|
/authorization/i,
|
|
518
|
-
/
|
|
716
|
+
/getAuth\s*\(/,
|
|
717
|
+
/withPageAuth/,
|
|
718
|
+
/cookies\(\).*auth/s
|
|
519
719
|
];
|
|
720
|
+
var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
|
|
520
721
|
var authChecksRule = {
|
|
521
722
|
id: "auth-checks",
|
|
522
723
|
name: "Missing Auth Checks",
|
|
523
724
|
description: "Detects API routes that lack authentication checks",
|
|
524
725
|
category: "security",
|
|
525
|
-
severity: "
|
|
726
|
+
severity: "warning",
|
|
526
727
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
527
728
|
check(file, project) {
|
|
528
729
|
if (!isApiRoute(file.relativePath)) return [];
|
|
529
730
|
for (const pattern of AUTH_EXEMPT_PATTERNS) {
|
|
530
731
|
if (pattern.test(file.relativePath)) return [];
|
|
531
732
|
}
|
|
532
|
-
const severity = project.hasAuthMiddleware ? "info" : "critical";
|
|
533
733
|
for (const pattern of AUTH_PATTERNS) {
|
|
534
734
|
if (pattern.test(file.content)) return [];
|
|
535
735
|
}
|
|
736
|
+
let severity;
|
|
737
|
+
if (project.hasAuthMiddleware) {
|
|
738
|
+
severity = "info";
|
|
739
|
+
} else if (MUTATION_EXPORT.test(file.content)) {
|
|
740
|
+
severity = "critical";
|
|
741
|
+
} else {
|
|
742
|
+
severity = "info";
|
|
743
|
+
}
|
|
536
744
|
let handlerLine = 1;
|
|
537
745
|
for (let i = 0; i < file.lines.length; i++) {
|
|
538
746
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -629,7 +837,10 @@ var errorHandlingRule = {
|
|
|
629
837
|
if (!isApiRoute(file.relativePath)) return [];
|
|
630
838
|
const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
|
|
631
839
|
const hasTryCatch = /try\s*\{/.test(file.content);
|
|
632
|
-
|
|
840
|
+
const hasCatchChain = /\.catch\s*\(/.test(file.content);
|
|
841
|
+
const hasOnError = /onError\s*[:(]/.test(file.content);
|
|
842
|
+
const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
|
|
843
|
+
if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
|
|
633
844
|
let handlerLine = 1;
|
|
634
845
|
for (let i = 0; i < file.lines.length; i++) {
|
|
635
846
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -734,10 +945,11 @@ var rateLimitingRule = {
|
|
|
734
945
|
name: "Missing Rate Limiting",
|
|
735
946
|
description: "Detects API routes without rate limiting",
|
|
736
947
|
category: "security",
|
|
737
|
-
severity: "
|
|
948
|
+
severity: "info",
|
|
738
949
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
739
|
-
check(file,
|
|
950
|
+
check(file, project) {
|
|
740
951
|
if (!isApiRoute(file.relativePath)) return [];
|
|
952
|
+
if (project.hasRateLimiting) return [];
|
|
741
953
|
for (const pattern of EXEMPT_PATTERNS) {
|
|
742
954
|
if (pattern.test(file.relativePath)) return [];
|
|
743
955
|
}
|
|
@@ -757,7 +969,7 @@ var rateLimitingRule = {
|
|
|
757
969
|
line: handlerLine,
|
|
758
970
|
column: 1,
|
|
759
971
|
message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
|
|
760
|
-
severity: "
|
|
972
|
+
severity: "info",
|
|
761
973
|
category: "security"
|
|
762
974
|
}];
|
|
763
975
|
}
|
|
@@ -985,6 +1197,8 @@ var SQL_INJECTION_PATTERNS = [
|
|
|
985
1197
|
// .query() or .execute() with template literal
|
|
986
1198
|
{ pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
|
|
987
1199
|
];
|
|
1200
|
+
var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
|
|
1201
|
+
var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
|
|
988
1202
|
var sqlInjectionRule = {
|
|
989
1203
|
id: "sql-injection",
|
|
990
1204
|
name: "SQL Injection Risk",
|
|
@@ -992,20 +1206,42 @@ var sqlInjectionRule = {
|
|
|
992
1206
|
category: "security",
|
|
993
1207
|
severity: "critical",
|
|
994
1208
|
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
995
|
-
check(file,
|
|
1209
|
+
check(file, project) {
|
|
996
1210
|
const findings = [];
|
|
1211
|
+
const safeTaggedLines = /* @__PURE__ */ new Set();
|
|
1212
|
+
if (file.ast) {
|
|
1213
|
+
try {
|
|
1214
|
+
walkAST(file.ast.program, (node) => {
|
|
1215
|
+
if (node.type === "TaggedTemplateExpression") {
|
|
1216
|
+
const tagged = node;
|
|
1217
|
+
if (isTaggedTemplateSql(tagged) && tagged.loc) {
|
|
1218
|
+
for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
|
|
1219
|
+
safeTaggedLines.add(l);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
} catch {
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
|
|
997
1228
|
for (let i = 0; i < file.lines.length; i++) {
|
|
998
1229
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
999
1230
|
const line = file.lines[i];
|
|
1231
|
+
const lineNum = i + 1;
|
|
1232
|
+
if (safeTaggedLines.has(lineNum)) continue;
|
|
1233
|
+
if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
|
|
1234
|
+
if (PARAMETERIZED_QUERY.test(line)) continue;
|
|
1000
1235
|
for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
|
|
1001
1236
|
if (pattern.test(line)) {
|
|
1237
|
+
const severity = usesORM ? "warning" : "critical";
|
|
1002
1238
|
findings.push({
|
|
1003
1239
|
ruleId: "sql-injection",
|
|
1004
1240
|
file: file.relativePath,
|
|
1005
|
-
line:
|
|
1241
|
+
line: lineNum,
|
|
1006
1242
|
column: 1,
|
|
1007
1243
|
message,
|
|
1008
|
-
severity
|
|
1244
|
+
severity,
|
|
1009
1245
|
category: "security"
|
|
1010
1246
|
});
|
|
1011
1247
|
break;
|
|
@@ -1027,7 +1263,7 @@ var PLACEHOLDERS = [
|
|
|
1027
1263
|
{ pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
|
|
1028
1264
|
{ pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
|
|
1029
1265
|
{ pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
|
|
1030
|
-
{ pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1266
|
+
{ pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1031
1267
|
{ pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
|
|
1032
1268
|
{ pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
|
|
1033
1269
|
];
|
|
@@ -1067,13 +1303,13 @@ var placeholderContentRule = {
|
|
|
1067
1303
|
// src/rules/stale-fallback.ts
|
|
1068
1304
|
var STALE_PATTERNS = [
|
|
1069
1305
|
{ pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
|
|
1070
|
-
{ pattern: /['"]https?:\/\/127\.0\.0\.1[
|
|
1071
|
-
{ pattern: /['"]redis:\/\/localhost['"]/, label: "Hardcoded Redis localhost URL" },
|
|
1072
|
-
{ pattern: /['"]mongodb:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1073
|
-
{ pattern: /['"]mongodb\+srv:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1074
|
-
{ pattern: /['"]postgres:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1075
|
-
{ pattern: /['"]postgresql:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1076
|
-
{ pattern: /['"]amqp:\/\/localhost['"]/, label: "Hardcoded AMQP localhost URL" }
|
|
1306
|
+
{ pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
|
|
1307
|
+
{ pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
|
|
1308
|
+
{ pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1309
|
+
{ pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1310
|
+
{ pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1311
|
+
{ pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1312
|
+
{ pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
|
|
1077
1313
|
];
|
|
1078
1314
|
var staleFallbackRule = {
|
|
1079
1315
|
id: "stale-fallback",
|
|
@@ -1112,15 +1348,15 @@ var staleFallbackRule = {
|
|
|
1112
1348
|
|
|
1113
1349
|
// src/rules/hallucinated-api.ts
|
|
1114
1350
|
var HALLUCINATED_APIS = [
|
|
1115
|
-
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()" },
|
|
1116
|
-
{ pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()" },
|
|
1117
|
-
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()" },
|
|
1118
|
-
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()" },
|
|
1119
|
-
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()" },
|
|
1120
|
-
{ pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()" },
|
|
1121
|
-
{ pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()" },
|
|
1122
|
-
{ pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)" },
|
|
1123
|
-
{ pattern:
|
|
1351
|
+
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1352
|
+
{ pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
|
|
1353
|
+
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1354
|
+
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1355
|
+
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
1356
|
+
{ pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
|
|
1357
|
+
{ pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
|
|
1358
|
+
{ pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
|
|
1359
|
+
{ pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
|
|
1124
1360
|
];
|
|
1125
1361
|
var hallucinatedApiRule = {
|
|
1126
1362
|
id: "hallucinated-api",
|
|
@@ -1129,14 +1365,16 @@ var hallucinatedApiRule = {
|
|
|
1129
1365
|
category: "ai-quality",
|
|
1130
1366
|
severity: "warning",
|
|
1131
1367
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1132
|
-
check(file,
|
|
1368
|
+
check(file, project) {
|
|
1133
1369
|
const findings = [];
|
|
1370
|
+
const frameworks = project.detectedFrameworks;
|
|
1134
1371
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1135
1372
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1136
1373
|
const line = file.lines[i];
|
|
1137
|
-
for (const { pattern, fix } of HALLUCINATED_APIS) {
|
|
1374
|
+
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
1138
1375
|
const match = pattern.exec(line);
|
|
1139
1376
|
if (match) {
|
|
1377
|
+
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
1140
1378
|
findings.push({
|
|
1141
1379
|
ruleId: "hallucinated-api",
|
|
1142
1380
|
file: file.relativePath,
|
|
@@ -1154,7 +1392,7 @@ var hallucinatedApiRule = {
|
|
|
1154
1392
|
};
|
|
1155
1393
|
|
|
1156
1394
|
// src/rules/open-redirect.ts
|
|
1157
|
-
var
|
|
1395
|
+
var DIRECT_INPUT_PATTERNS = [
|
|
1158
1396
|
// redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
|
|
1159
1397
|
/redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
|
|
1160
1398
|
// redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
|
|
@@ -1163,21 +1401,21 @@ var CRITICAL_PATTERNS = [
|
|
|
1163
1401
|
/NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
|
|
1164
1402
|
];
|
|
1165
1403
|
var WARNING_PATTERNS = [
|
|
1166
|
-
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
|
|
1404
|
+
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1167
1405
|
];
|
|
1168
1406
|
var openRedirectRule = {
|
|
1169
1407
|
id: "open-redirect",
|
|
1170
1408
|
name: "Open Redirect",
|
|
1171
1409
|
description: "Detects user-controlled input passed directly to redirect functions",
|
|
1172
1410
|
category: "security",
|
|
1173
|
-
severity: "
|
|
1411
|
+
severity: "warning",
|
|
1174
1412
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1175
1413
|
check(file, _project) {
|
|
1176
1414
|
const findings = [];
|
|
1177
1415
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1178
1416
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1179
1417
|
const line = file.lines[i];
|
|
1180
|
-
for (const pattern of
|
|
1418
|
+
for (const pattern of DIRECT_INPUT_PATTERNS) {
|
|
1181
1419
|
const match = pattern.exec(line);
|
|
1182
1420
|
if (match) {
|
|
1183
1421
|
findings.push({
|
|
@@ -1186,7 +1424,7 @@ var openRedirectRule = {
|
|
|
1186
1424
|
line: i + 1,
|
|
1187
1425
|
column: match.index + 1,
|
|
1188
1426
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1189
|
-
severity: "
|
|
1427
|
+
severity: "warning",
|
|
1190
1428
|
category: "security"
|
|
1191
1429
|
});
|
|
1192
1430
|
break;
|
|
@@ -1251,6 +1489,7 @@ var noSyncFsRule = {
|
|
|
1251
1489
|
|
|
1252
1490
|
// src/rules/no-n-plus-one.ts
|
|
1253
1491
|
var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
|
|
1492
|
+
var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
|
|
1254
1493
|
var noNPlusOneRule = {
|
|
1255
1494
|
id: "no-n-plus-one",
|
|
1256
1495
|
name: "No N+1 Queries",
|
|
@@ -1261,14 +1500,33 @@ var noNPlusOneRule = {
|
|
|
1261
1500
|
check(file, _project) {
|
|
1262
1501
|
if (isTestFile(file.relativePath)) return [];
|
|
1263
1502
|
if (isScriptFile(file.relativePath)) return [];
|
|
1503
|
+
const promiseAllMapLines = /* @__PURE__ */ new Set();
|
|
1504
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1505
|
+
if (PROMISE_ALL_MAP.test(file.lines[i])) {
|
|
1506
|
+
for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
|
|
1507
|
+
promiseAllMapLines.add(j);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1264
1511
|
const findings = [];
|
|
1265
|
-
|
|
1512
|
+
let loops;
|
|
1513
|
+
if (file.ast) {
|
|
1514
|
+
try {
|
|
1515
|
+
loops = findLoopsAST(file.ast);
|
|
1516
|
+
} catch {
|
|
1517
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1518
|
+
}
|
|
1519
|
+
} else {
|
|
1520
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1521
|
+
}
|
|
1266
1522
|
const reported = /* @__PURE__ */ new Set();
|
|
1267
1523
|
for (const loop of loops) {
|
|
1268
1524
|
if (reported.has(loop.loopLine)) continue;
|
|
1525
|
+
if (promiseAllMapLines.has(loop.loopLine)) continue;
|
|
1269
1526
|
for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
|
|
1270
1527
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1271
1528
|
const line = file.lines[i];
|
|
1529
|
+
if (promiseAllMapLines.has(i)) continue;
|
|
1272
1530
|
const match = DB_CALL_PATTERN.exec(line);
|
|
1273
1531
|
if (match) {
|
|
1274
1532
|
reported.add(loop.loopLine);
|
|
@@ -1402,6 +1660,10 @@ var HANDLED_PATTERNS = [
|
|
|
1402
1660
|
/Promise\.allSettled/,
|
|
1403
1661
|
/Promise\.race/
|
|
1404
1662
|
];
|
|
1663
|
+
var CHAIN_START_PATTERNS = [
|
|
1664
|
+
/\.from\s*\(/,
|
|
1665
|
+
/\.rpc\s*\(/
|
|
1666
|
+
];
|
|
1405
1667
|
var unhandledPromiseRule = {
|
|
1406
1668
|
id: "unhandled-promise",
|
|
1407
1669
|
name: "Unhandled Promise",
|
|
@@ -1421,6 +1683,19 @@ var unhandledPromiseRule = {
|
|
|
1421
1683
|
if (!asyncMatch) continue;
|
|
1422
1684
|
const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
|
|
1423
1685
|
if (isHandled) continue;
|
|
1686
|
+
const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
|
|
1687
|
+
if (isChainContinuation) {
|
|
1688
|
+
let chainHandled = false;
|
|
1689
|
+
for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
|
|
1690
|
+
const prevTrimmed = file.lines[j].trim();
|
|
1691
|
+
if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
|
|
1692
|
+
if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
|
|
1693
|
+
chainHandled = true;
|
|
1694
|
+
break;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (chainHandled) continue;
|
|
1698
|
+
}
|
|
1424
1699
|
let handledAbove = false;
|
|
1425
1700
|
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
1426
1701
|
const prevTrimmed = file.lines[j].trim();
|
|
@@ -1488,9 +1763,9 @@ var missingErrorBoundaryRule = {
|
|
|
1488
1763
|
severity: "info",
|
|
1489
1764
|
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
1490
1765
|
check(file, project) {
|
|
1491
|
-
const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1766
|
+
const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1492
1767
|
if (!match) return [];
|
|
1493
|
-
const dir =
|
|
1768
|
+
const dir = match[1] + match[2];
|
|
1494
1769
|
const hasErrorBoundary = project.allFiles.some(
|
|
1495
1770
|
(f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
|
|
1496
1771
|
);
|
|
@@ -1668,7 +1943,8 @@ var deadExportsRule = {
|
|
|
1668
1943
|
(f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
|
|
1669
1944
|
);
|
|
1670
1945
|
const exports = /* @__PURE__ */ new Map();
|
|
1671
|
-
const imports = /* @__PURE__ */ new
|
|
1946
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1947
|
+
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
1672
1948
|
const importedFiles = /* @__PURE__ */ new Set();
|
|
1673
1949
|
for (const file of sourceFiles) {
|
|
1674
1950
|
if (isEntryPoint(file.relativePath)) continue;
|
|
@@ -1692,14 +1968,28 @@ var deadExportsRule = {
|
|
|
1692
1968
|
for (const file of files) {
|
|
1693
1969
|
for (const line of file.lines) {
|
|
1694
1970
|
let match;
|
|
1971
|
+
const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
|
|
1972
|
+
const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
|
|
1695
1973
|
const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
|
|
1696
1974
|
while ((match = bracesRe.exec(line)) !== null) {
|
|
1697
1975
|
const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
|
|
1698
|
-
for (const sym of symbols)
|
|
1976
|
+
for (const sym of symbols) {
|
|
1977
|
+
allImportedSymbols.add(sym);
|
|
1978
|
+
if (fromBasename) {
|
|
1979
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1980
|
+
set.add(sym);
|
|
1981
|
+
imports.set(fromBasename, set);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1699
1984
|
}
|
|
1700
1985
|
const defaultRe = /import\s+(\w+)\s+from/g;
|
|
1701
1986
|
while ((match = defaultRe.exec(line)) !== null) {
|
|
1702
|
-
|
|
1987
|
+
allImportedSymbols.add(match[1]);
|
|
1988
|
+
if (fromBasename) {
|
|
1989
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1990
|
+
set.add(match[1]);
|
|
1991
|
+
imports.set(fromBasename, set);
|
|
1992
|
+
}
|
|
1703
1993
|
}
|
|
1704
1994
|
const fromRe = /from\s+['"]([^'"]+)['"]/g;
|
|
1705
1995
|
while ((match = fromRe.exec(line)) !== null) {
|
|
@@ -1710,7 +2000,10 @@ var deadExportsRule = {
|
|
|
1710
2000
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
1711
2001
|
for (const [key, loc] of exports) {
|
|
1712
2002
|
const symbolName = key.split("::")[1];
|
|
1713
|
-
|
|
2003
|
+
const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
|
|
2004
|
+
const importSet = imports.get(exportFileBasename);
|
|
2005
|
+
const isImported = importSet?.has(symbolName) ?? false;
|
|
2006
|
+
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
1714
2007
|
deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
|
|
1715
2008
|
}
|
|
1716
2009
|
}
|
|
@@ -1780,12 +2073,33 @@ var shallowCatchRule = {
|
|
|
1780
2073
|
if (braceStart === -1) continue;
|
|
1781
2074
|
let depth = 0;
|
|
1782
2075
|
let bodyEnd = braceStart;
|
|
2076
|
+
let inSingle = false;
|
|
2077
|
+
let inDouble = false;
|
|
2078
|
+
let inTemplate = false;
|
|
1783
2079
|
for (let j = braceStart; j < file.lines.length; j++) {
|
|
1784
2080
|
const line = file.lines[j];
|
|
1785
2081
|
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
1786
2082
|
for (let k = startPos; k < line.length; k++) {
|
|
1787
|
-
|
|
1788
|
-
|
|
2083
|
+
const ch = line[k];
|
|
2084
|
+
const prev = k > 0 ? line[k - 1] : "";
|
|
2085
|
+
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2086
|
+
if (!escaped) {
|
|
2087
|
+
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2088
|
+
inSingle = !inSingle;
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2092
|
+
inDouble = !inDouble;
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
if (ch === "`" && !inSingle && !inDouble) {
|
|
2096
|
+
inTemplate = !inTemplate;
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
2101
|
+
if (ch === "{") depth++;
|
|
2102
|
+
if (ch === "}") {
|
|
1789
2103
|
depth--;
|
|
1790
2104
|
if (depth === 0) {
|
|
1791
2105
|
bodyEnd = j;
|
|
@@ -1952,6 +2266,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
1952
2266
|
"gpt-tokenizer"
|
|
1953
2267
|
// exists but often confused
|
|
1954
2268
|
]);
|
|
2269
|
+
var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
2270
|
+
"pg",
|
|
2271
|
+
"ws",
|
|
2272
|
+
"ms",
|
|
2273
|
+
"qs",
|
|
2274
|
+
"ip",
|
|
2275
|
+
"is",
|
|
2276
|
+
"he",
|
|
2277
|
+
"ky",
|
|
2278
|
+
"bl",
|
|
2279
|
+
"rc",
|
|
2280
|
+
"io",
|
|
2281
|
+
"db",
|
|
2282
|
+
"fp",
|
|
2283
|
+
"rx"
|
|
2284
|
+
]);
|
|
1955
2285
|
var SUSPICIOUS_PATTERNS = [
|
|
1956
2286
|
/^[a-z]{1,2}$/,
|
|
1957
2287
|
// 1-2 char names
|
|
@@ -1990,7 +2320,7 @@ var phantomDependencyRule = {
|
|
|
1990
2320
|
});
|
|
1991
2321
|
}
|
|
1992
2322
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
1993
|
-
if (pattern.test(name) && !name.startsWith("@")) {
|
|
2323
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
|
|
1994
2324
|
findings.push({
|
|
1995
2325
|
ruleId: "phantom-dependency",
|
|
1996
2326
|
file: "package.json",
|
|
@@ -2008,6 +2338,210 @@ var phantomDependencyRule = {
|
|
|
2008
2338
|
}
|
|
2009
2339
|
};
|
|
2010
2340
|
|
|
2341
|
+
// src/rules/insecure-cookie.ts
|
|
2342
|
+
var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
|
|
2343
|
+
var COOKIE_SET_PATTERNS = [
|
|
2344
|
+
/cookies\(\)\s*\.set\s*\(/,
|
|
2345
|
+
/res\.cookie\s*\(/,
|
|
2346
|
+
/response\.cookies\.set\s*\(/
|
|
2347
|
+
];
|
|
2348
|
+
var SECURE_OPTIONS = [
|
|
2349
|
+
/httpOnly\s*:\s*true/,
|
|
2350
|
+
/secure\s*:\s*true/,
|
|
2351
|
+
/sameSite\s*:/
|
|
2352
|
+
];
|
|
2353
|
+
var insecureCookieRule = {
|
|
2354
|
+
id: "insecure-cookie",
|
|
2355
|
+
name: "Insecure Cookie",
|
|
2356
|
+
description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
|
|
2357
|
+
category: "security",
|
|
2358
|
+
severity: "warning",
|
|
2359
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2360
|
+
check(file, _project) {
|
|
2361
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2362
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2363
|
+
const findings = [];
|
|
2364
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2365
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2366
|
+
const line = file.lines[i];
|
|
2367
|
+
const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
|
|
2368
|
+
if (!isCookieSet) continue;
|
|
2369
|
+
if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
|
|
2370
|
+
const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
|
|
2371
|
+
const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
|
|
2372
|
+
if (missingOptions.length > 0) {
|
|
2373
|
+
const missing = [];
|
|
2374
|
+
if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
|
|
2375
|
+
if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
|
|
2376
|
+
if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
|
|
2377
|
+
findings.push({
|
|
2378
|
+
ruleId: "insecure-cookie",
|
|
2379
|
+
file: file.relativePath,
|
|
2380
|
+
line: i + 1,
|
|
2381
|
+
column: 1,
|
|
2382
|
+
message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
|
|
2383
|
+
severity: "warning",
|
|
2384
|
+
category: "security",
|
|
2385
|
+
fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
return findings;
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
// src/rules/leaked-env-in-logs.ts
|
|
2394
|
+
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2395
|
+
var leakedEnvInLogsRule = {
|
|
2396
|
+
id: "leaked-env-in-logs",
|
|
2397
|
+
name: "Leaked Env in Logs",
|
|
2398
|
+
description: "Detects process.env values logged to console \u2014 potential secret exposure",
|
|
2399
|
+
category: "security",
|
|
2400
|
+
severity: "warning",
|
|
2401
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2402
|
+
check(file, _project) {
|
|
2403
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2404
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2405
|
+
const findings = [];
|
|
2406
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2407
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2408
|
+
const line = file.lines[i];
|
|
2409
|
+
const match = CONSOLE_WITH_ENV.exec(line);
|
|
2410
|
+
if (match) {
|
|
2411
|
+
findings.push({
|
|
2412
|
+
ruleId: "leaked-env-in-logs",
|
|
2413
|
+
file: file.relativePath,
|
|
2414
|
+
line: i + 1,
|
|
2415
|
+
column: match.index + 1,
|
|
2416
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2417
|
+
severity: "warning",
|
|
2418
|
+
category: "security",
|
|
2419
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
return findings;
|
|
2424
|
+
}
|
|
2425
|
+
};
|
|
2426
|
+
|
|
2427
|
+
// src/rules/insecure-random.ts
|
|
2428
|
+
var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
|
|
2429
|
+
var MATH_RANDOM = /Math\.random\s*\(\)/;
|
|
2430
|
+
var insecureRandomRule = {
|
|
2431
|
+
id: "insecure-random",
|
|
2432
|
+
name: "Insecure Random",
|
|
2433
|
+
description: "Detects Math.random() used near security-sensitive variable names",
|
|
2434
|
+
category: "security",
|
|
2435
|
+
severity: "warning",
|
|
2436
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2437
|
+
check(file, _project) {
|
|
2438
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2439
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2440
|
+
const findings = [];
|
|
2441
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2442
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2443
|
+
const line = file.lines[i];
|
|
2444
|
+
const match = MATH_RANDOM.exec(line);
|
|
2445
|
+
if (!match) continue;
|
|
2446
|
+
const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
|
|
2447
|
+
if (SECURITY_VAR_NAMES.test(context)) {
|
|
2448
|
+
findings.push({
|
|
2449
|
+
ruleId: "insecure-random",
|
|
2450
|
+
file: file.relativePath,
|
|
2451
|
+
line: i + 1,
|
|
2452
|
+
column: match.index + 1,
|
|
2453
|
+
message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
|
|
2454
|
+
severity: "warning",
|
|
2455
|
+
category: "security",
|
|
2456
|
+
fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return findings;
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
// src/rules/next-server-action-validation.ts
|
|
2465
|
+
var USE_SERVER = /['"]use server['"]/;
|
|
2466
|
+
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
2467
|
+
var VALIDATION_PATTERNS2 = [
|
|
2468
|
+
/\.parse\s*\(/,
|
|
2469
|
+
/\.safeParse\s*\(/,
|
|
2470
|
+
/\bvalidate\s*\(/,
|
|
2471
|
+
/\.parseAsync\s*\(/,
|
|
2472
|
+
/\.safeParseAsync\s*\(/
|
|
2473
|
+
];
|
|
2474
|
+
var nextServerActionValidationRule = {
|
|
2475
|
+
id: "next-server-action-validation",
|
|
2476
|
+
name: "Next.js Server Action Validation",
|
|
2477
|
+
description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
|
|
2478
|
+
category: "security",
|
|
2479
|
+
severity: "critical",
|
|
2480
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2481
|
+
check(file, _project) {
|
|
2482
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2483
|
+
if (!USE_SERVER.test(file.content)) return [];
|
|
2484
|
+
if (!FORM_DATA_GET.test(file.content)) return [];
|
|
2485
|
+
const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
|
|
2486
|
+
if (hasValidation) return [];
|
|
2487
|
+
let reportLine = 1;
|
|
2488
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2489
|
+
if (FORM_DATA_GET.test(file.lines[i])) {
|
|
2490
|
+
reportLine = i + 1;
|
|
2491
|
+
break;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
return [{
|
|
2495
|
+
ruleId: "next-server-action-validation",
|
|
2496
|
+
file: file.relativePath,
|
|
2497
|
+
line: reportLine,
|
|
2498
|
+
column: 1,
|
|
2499
|
+
message: "Server action reads formData without schema validation \u2014 unvalidated user input",
|
|
2500
|
+
severity: "critical",
|
|
2501
|
+
category: "security",
|
|
2502
|
+
fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
|
|
2503
|
+
}];
|
|
2504
|
+
}
|
|
2505
|
+
};
|
|
2506
|
+
|
|
2507
|
+
// src/rules/missing-transaction.ts
|
|
2508
|
+
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2509
|
+
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2510
|
+
var missingTransactionRule = {
|
|
2511
|
+
id: "missing-transaction",
|
|
2512
|
+
name: "Missing Transaction",
|
|
2513
|
+
description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
|
|
2514
|
+
category: "reliability",
|
|
2515
|
+
severity: "warning",
|
|
2516
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2517
|
+
check(file, project) {
|
|
2518
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2519
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2520
|
+
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2521
|
+
let writeCount = 0;
|
|
2522
|
+
let firstWriteLine = -1;
|
|
2523
|
+
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
2524
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2525
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2526
|
+
if (PRISMA_WRITE_OPS.test(file.lines[i])) {
|
|
2527
|
+
writeCount++;
|
|
2528
|
+
if (firstWriteLine === -1) firstWriteLine = i;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
if (writeCount < 2 || hasTransaction) return [];
|
|
2532
|
+
return [{
|
|
2533
|
+
ruleId: "missing-transaction",
|
|
2534
|
+
file: file.relativePath,
|
|
2535
|
+
line: firstWriteLine + 1,
|
|
2536
|
+
column: 1,
|
|
2537
|
+
message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2538
|
+
severity: "warning",
|
|
2539
|
+
category: "reliability",
|
|
2540
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2541
|
+
}];
|
|
2542
|
+
}
|
|
2543
|
+
};
|
|
2544
|
+
|
|
2011
2545
|
// src/rules/index.ts
|
|
2012
2546
|
var rules = [
|
|
2013
2547
|
// Security
|
|
@@ -2021,6 +2555,10 @@ var rules = [
|
|
|
2021
2555
|
openRedirectRule,
|
|
2022
2556
|
rateLimitingRule,
|
|
2023
2557
|
phantomDependencyRule,
|
|
2558
|
+
insecureCookieRule,
|
|
2559
|
+
leakedEnvInLogsRule,
|
|
2560
|
+
insecureRandomRule,
|
|
2561
|
+
nextServerActionValidationRule,
|
|
2024
2562
|
// Reliability
|
|
2025
2563
|
hallucinatedImportsRule,
|
|
2026
2564
|
errorHandlingRule,
|
|
@@ -2028,6 +2566,7 @@ var rules = [
|
|
|
2028
2566
|
shallowCatchRule,
|
|
2029
2567
|
missingLoadingStateRule,
|
|
2030
2568
|
missingErrorBoundaryRule,
|
|
2569
|
+
missingTransactionRule,
|
|
2031
2570
|
// Performance
|
|
2032
2571
|
noSyncFsRule,
|
|
2033
2572
|
noNPlusOneRule,
|