prodlint 0.3.1 → 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 +36 -20
- 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 +3 -1
package/dist/index.js
CHANGED
|
@@ -158,6 +158,133 @@ var NODE_BUILTINS = /* @__PURE__ */ new Set([
|
|
|
158
158
|
// node: prefixed are handled separately
|
|
159
159
|
]);
|
|
160
160
|
|
|
161
|
+
// src/utils/ast.ts
|
|
162
|
+
import { parse } from "@babel/parser";
|
|
163
|
+
function parseFile(content, fileName) {
|
|
164
|
+
const plugins = ["decorators"];
|
|
165
|
+
if (/\.tsx?$/.test(fileName)) {
|
|
166
|
+
plugins.push("typescript");
|
|
167
|
+
}
|
|
168
|
+
if (/\.[jt]sx$/.test(fileName)) {
|
|
169
|
+
plugins.push("jsx");
|
|
170
|
+
}
|
|
171
|
+
if (/\.(js|mjs|cjs)$/.test(fileName)) {
|
|
172
|
+
plugins.push("jsx");
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return parse(content, {
|
|
176
|
+
sourceType: "module",
|
|
177
|
+
allowImportExportEverywhere: true,
|
|
178
|
+
allowReturnOutsideFunction: true,
|
|
179
|
+
plugins
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function walkAST(node, visitor, parent = null) {
|
|
186
|
+
if (!node || typeof node !== "object") return;
|
|
187
|
+
visitor(node, parent);
|
|
188
|
+
for (const key of Object.keys(node)) {
|
|
189
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
190
|
+
const val = node[key];
|
|
191
|
+
if (Array.isArray(val)) {
|
|
192
|
+
for (const item of val) {
|
|
193
|
+
if (item && typeof item === "object" && item.type) {
|
|
194
|
+
walkAST(item, visitor, node);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
198
|
+
walkAST(val, visitor, node);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function isTaggedTemplateSql(node) {
|
|
203
|
+
const tag = node.tag;
|
|
204
|
+
if (tag.type === "Identifier" && tag.name === "sql") return true;
|
|
205
|
+
if (tag.type === "MemberExpression") {
|
|
206
|
+
const prop = tag.property;
|
|
207
|
+
if (prop.type === "Identifier" && (prop.name === "sql" || prop.name === "query" || prop.name === "raw")) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
function findLoopsAST(ast) {
|
|
214
|
+
const loops = [];
|
|
215
|
+
walkAST(ast.program, (node) => {
|
|
216
|
+
if (node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement" || node.type === "WhileStatement" || node.type === "DoWhileStatement") {
|
|
217
|
+
const loop = node;
|
|
218
|
+
const body = loop.body;
|
|
219
|
+
if (body.loc && node.loc) {
|
|
220
|
+
loops.push({
|
|
221
|
+
loopLine: node.loc.start.line - 1,
|
|
222
|
+
bodyStart: body.loc.start.line - 1,
|
|
223
|
+
bodyEnd: body.loc.end.line - 1
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (node.type === "CallExpression") {
|
|
228
|
+
const call = node;
|
|
229
|
+
if (call.callee.type === "MemberExpression" && call.callee.property.type === "Identifier" && (call.callee.property.name === "forEach" || call.callee.property.name === "map")) {
|
|
230
|
+
const callback = call.arguments[0];
|
|
231
|
+
if (callback && callback.loc && node.loc) {
|
|
232
|
+
loops.push({
|
|
233
|
+
loopLine: node.loc.start.line - 1,
|
|
234
|
+
bodyStart: callback.loc.start.line - 1,
|
|
235
|
+
bodyEnd: callback.loc.end.line - 1
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return loops;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/utils/frameworks.ts
|
|
245
|
+
var FRAMEWORK_SAFE_METHODS = {
|
|
246
|
+
prisma: ["contains", "startsWith", "endsWith", "has", "hasEvery", "hasSome", "isEmpty"],
|
|
247
|
+
supabase: ["contains", "containedBy", "overlaps", "eq", "neq", "gt", "gte", "lt", "lte"],
|
|
248
|
+
drizzle: ["arrayContains", "arrayContainedIn", "arrayOverlaps"],
|
|
249
|
+
lodash: ["flatten", "flattenDeep", "contains", "includes", "has"],
|
|
250
|
+
mongoose: ["contains"]
|
|
251
|
+
};
|
|
252
|
+
function isFrameworkSafeMethod(methodName, frameworks) {
|
|
253
|
+
for (const framework of frameworks) {
|
|
254
|
+
const safeMethods = FRAMEWORK_SAFE_METHODS[framework];
|
|
255
|
+
if (safeMethods && safeMethods.includes(methodName)) {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
var DEPENDENCY_TO_FRAMEWORK = {
|
|
262
|
+
"@prisma/client": "prisma",
|
|
263
|
+
"prisma": "prisma",
|
|
264
|
+
"@supabase/supabase-js": "supabase",
|
|
265
|
+
"@supabase/ssr": "supabase",
|
|
266
|
+
"drizzle-orm": "drizzle",
|
|
267
|
+
"@trpc/server": "trpc",
|
|
268
|
+
"next-auth": "next-auth",
|
|
269
|
+
"@auth/nextjs": "next-auth",
|
|
270
|
+
"@auth/core": "next-auth",
|
|
271
|
+
"express": "express",
|
|
272
|
+
"fastify": "fastify",
|
|
273
|
+
"hono": "hono",
|
|
274
|
+
"lodash": "lodash",
|
|
275
|
+
"lodash-es": "lodash",
|
|
276
|
+
"underscore": "lodash",
|
|
277
|
+
"mongoose": "mongoose",
|
|
278
|
+
"typeorm": "typeorm",
|
|
279
|
+
"sequelize": "sequelize",
|
|
280
|
+
"knex": "knex",
|
|
281
|
+
"@upstash/ratelimit": "upstash-ratelimit",
|
|
282
|
+
"express-rate-limit": "express-rate-limit",
|
|
283
|
+
"rate-limiter-flexible": "rate-limiter-flexible"
|
|
284
|
+
};
|
|
285
|
+
var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
|
|
286
|
+
var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
|
|
287
|
+
|
|
161
288
|
// src/utils/file-walker.ts
|
|
162
289
|
var DEFAULT_IGNORES = [
|
|
163
290
|
"**/node_modules/**",
|
|
@@ -184,6 +311,7 @@ var SCAN_EXTENSIONS = [
|
|
|
184
311
|
"cjs",
|
|
185
312
|
"json"
|
|
186
313
|
];
|
|
314
|
+
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
187
315
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
188
316
|
async function walkFiles(root, extraIgnores = []) {
|
|
189
317
|
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
@@ -208,14 +336,23 @@ async function readFileContext(root, relativePath) {
|
|
|
208
336
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
209
337
|
const content = await readFile(absolutePath, "utf-8");
|
|
210
338
|
const lines = content.split(/\r?\n|\r/);
|
|
339
|
+
const ext = extname(relativePath).slice(1);
|
|
340
|
+
let ast = void 0;
|
|
341
|
+
if (AST_EXTENSIONS.has(ext)) {
|
|
342
|
+
try {
|
|
343
|
+
ast = parseFile(content, relativePath);
|
|
344
|
+
} catch {
|
|
345
|
+
ast = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
211
348
|
return {
|
|
212
349
|
absolutePath,
|
|
213
350
|
relativePath,
|
|
214
351
|
content,
|
|
215
352
|
lines,
|
|
216
|
-
ext
|
|
217
|
-
|
|
218
|
-
|
|
353
|
+
ext,
|
|
354
|
+
commentMap: buildCommentMap(lines),
|
|
355
|
+
ast
|
|
219
356
|
};
|
|
220
357
|
} catch {
|
|
221
358
|
return null;
|
|
@@ -226,6 +363,8 @@ async function buildProjectContext(root, files) {
|
|
|
226
363
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
227
364
|
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
228
365
|
let hasAuthMiddleware = false;
|
|
366
|
+
let hasRateLimiting = false;
|
|
367
|
+
const detectedFrameworks = /* @__PURE__ */ new Set();
|
|
229
368
|
let gitignoreContent = null;
|
|
230
369
|
let envInGitignore = false;
|
|
231
370
|
try {
|
|
@@ -237,6 +376,18 @@ async function buildProjectContext(root, files) {
|
|
|
237
376
|
...packageJson?.peerDependencies ?? {}
|
|
238
377
|
};
|
|
239
378
|
declaredDependencies = new Set(Object.keys(deps));
|
|
379
|
+
for (const dep of declaredDependencies) {
|
|
380
|
+
const framework = DEPENDENCY_TO_FRAMEWORK[dep];
|
|
381
|
+
if (framework) {
|
|
382
|
+
detectedFrameworks.add(framework);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const framework of detectedFrameworks) {
|
|
386
|
+
if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
|
|
387
|
+
hasRateLimiting = true;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
240
391
|
} catch {
|
|
241
392
|
}
|
|
242
393
|
try {
|
|
@@ -280,6 +431,18 @@ async function buildProjectContext(root, files) {
|
|
|
280
431
|
}
|
|
281
432
|
} catch {
|
|
282
433
|
}
|
|
434
|
+
if (!hasRateLimiting) {
|
|
435
|
+
for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
|
|
436
|
+
try {
|
|
437
|
+
const content = await readFile(resolve(root, name), "utf-8");
|
|
438
|
+
if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
|
|
439
|
+
hasRateLimiting = true;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
283
446
|
try {
|
|
284
447
|
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
285
448
|
envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
|
|
@@ -291,6 +454,8 @@ async function buildProjectContext(root, files) {
|
|
|
291
454
|
declaredDependencies,
|
|
292
455
|
tsconfigPaths,
|
|
293
456
|
hasAuthMiddleware,
|
|
457
|
+
hasRateLimiting,
|
|
458
|
+
detectedFrameworks,
|
|
294
459
|
gitignoreContent,
|
|
295
460
|
envInGitignore,
|
|
296
461
|
allFiles: files
|
|
@@ -315,26 +480,58 @@ function getVersion() {
|
|
|
315
480
|
|
|
316
481
|
// src/scorer.ts
|
|
317
482
|
var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
|
|
483
|
+
var CATEGORY_WEIGHTS = {
|
|
484
|
+
"security": 0.4,
|
|
485
|
+
"reliability": 0.3,
|
|
486
|
+
"performance": 0.15,
|
|
487
|
+
"ai-quality": 0.15
|
|
488
|
+
};
|
|
318
489
|
var DEDUCTIONS = {
|
|
319
|
-
critical:
|
|
320
|
-
warning:
|
|
321
|
-
info:
|
|
490
|
+
critical: 8,
|
|
491
|
+
warning: 2,
|
|
492
|
+
info: 0.5
|
|
493
|
+
};
|
|
494
|
+
var PER_RULE_CAP = {
|
|
495
|
+
critical: 1,
|
|
496
|
+
warning: 2,
|
|
497
|
+
info: 3
|
|
322
498
|
};
|
|
323
499
|
function calculateScores(findings) {
|
|
324
500
|
const categoryScores = CATEGORIES.map((category) => {
|
|
325
501
|
const categoryFindings = findings.filter((f) => f.category === category);
|
|
326
|
-
|
|
502
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
327
503
|
for (const f of categoryFindings) {
|
|
328
|
-
|
|
504
|
+
const arr = byRule.get(f.ruleId) ?? [];
|
|
505
|
+
arr.push(f);
|
|
506
|
+
byRule.set(f.ruleId, arr);
|
|
507
|
+
}
|
|
508
|
+
let totalDeduction = 0;
|
|
509
|
+
for (const [, ruleFindings] of byRule) {
|
|
510
|
+
const bySeverity = { critical: 0, warning: 0, info: 0 };
|
|
511
|
+
for (const f of ruleFindings) {
|
|
512
|
+
bySeverity[f.severity]++;
|
|
513
|
+
}
|
|
514
|
+
for (const sev of ["critical", "warning", "info"]) {
|
|
515
|
+
const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
|
|
516
|
+
totalDeduction += count * DEDUCTIONS[sev];
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
let effectiveDeduction;
|
|
520
|
+
if (totalDeduction <= 30) {
|
|
521
|
+
effectiveDeduction = totalDeduction;
|
|
522
|
+
} else if (totalDeduction <= 50) {
|
|
523
|
+
effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
|
|
524
|
+
} else {
|
|
525
|
+
effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
|
|
329
526
|
}
|
|
330
527
|
return {
|
|
331
528
|
category,
|
|
332
|
-
score: Math.max(0,
|
|
529
|
+
score: Math.max(0, Math.round(100 - effectiveDeduction)),
|
|
333
530
|
findingCount: categoryFindings.length
|
|
334
531
|
};
|
|
335
532
|
});
|
|
336
533
|
const overallScore = Math.round(
|
|
337
|
-
categoryScores.reduce((sum, c) => sum + c.score, 0)
|
|
534
|
+
categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
|
|
338
535
|
);
|
|
339
536
|
return { overallScore, categoryScores };
|
|
340
537
|
}
|
|
@@ -505,25 +702,36 @@ var AUTH_PATTERNS = [
|
|
|
505
702
|
/jwt\.verify\s*\(/,
|
|
506
703
|
/createRouteHandlerClient/,
|
|
507
704
|
/createServerComponentClient/,
|
|
705
|
+
/createMiddlewareClient/,
|
|
508
706
|
/authorization/i,
|
|
509
|
-
/
|
|
707
|
+
/getAuth\s*\(/,
|
|
708
|
+
/withPageAuth/,
|
|
709
|
+
/cookies\(\).*auth/s
|
|
510
710
|
];
|
|
711
|
+
var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
|
|
511
712
|
var authChecksRule = {
|
|
512
713
|
id: "auth-checks",
|
|
513
714
|
name: "Missing Auth Checks",
|
|
514
715
|
description: "Detects API routes that lack authentication checks",
|
|
515
716
|
category: "security",
|
|
516
|
-
severity: "
|
|
717
|
+
severity: "warning",
|
|
517
718
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
518
719
|
check(file, project) {
|
|
519
720
|
if (!isApiRoute(file.relativePath)) return [];
|
|
520
721
|
for (const pattern of AUTH_EXEMPT_PATTERNS) {
|
|
521
722
|
if (pattern.test(file.relativePath)) return [];
|
|
522
723
|
}
|
|
523
|
-
const severity = project.hasAuthMiddleware ? "info" : "critical";
|
|
524
724
|
for (const pattern of AUTH_PATTERNS) {
|
|
525
725
|
if (pattern.test(file.content)) return [];
|
|
526
726
|
}
|
|
727
|
+
let severity;
|
|
728
|
+
if (project.hasAuthMiddleware) {
|
|
729
|
+
severity = "info";
|
|
730
|
+
} else if (MUTATION_EXPORT.test(file.content)) {
|
|
731
|
+
severity = "critical";
|
|
732
|
+
} else {
|
|
733
|
+
severity = "info";
|
|
734
|
+
}
|
|
527
735
|
let handlerLine = 1;
|
|
528
736
|
for (let i = 0; i < file.lines.length; i++) {
|
|
529
737
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -620,7 +828,10 @@ var errorHandlingRule = {
|
|
|
620
828
|
if (!isApiRoute(file.relativePath)) return [];
|
|
621
829
|
const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
|
|
622
830
|
const hasTryCatch = /try\s*\{/.test(file.content);
|
|
623
|
-
|
|
831
|
+
const hasCatchChain = /\.catch\s*\(/.test(file.content);
|
|
832
|
+
const hasOnError = /onError\s*[:(]/.test(file.content);
|
|
833
|
+
const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
|
|
834
|
+
if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
|
|
624
835
|
let handlerLine = 1;
|
|
625
836
|
for (let i = 0; i < file.lines.length; i++) {
|
|
626
837
|
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
@@ -725,10 +936,11 @@ var rateLimitingRule = {
|
|
|
725
936
|
name: "Missing Rate Limiting",
|
|
726
937
|
description: "Detects API routes without rate limiting",
|
|
727
938
|
category: "security",
|
|
728
|
-
severity: "
|
|
939
|
+
severity: "info",
|
|
729
940
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
730
|
-
check(file,
|
|
941
|
+
check(file, project) {
|
|
731
942
|
if (!isApiRoute(file.relativePath)) return [];
|
|
943
|
+
if (project.hasRateLimiting) return [];
|
|
732
944
|
for (const pattern of EXEMPT_PATTERNS) {
|
|
733
945
|
if (pattern.test(file.relativePath)) return [];
|
|
734
946
|
}
|
|
@@ -748,7 +960,7 @@ var rateLimitingRule = {
|
|
|
748
960
|
line: handlerLine,
|
|
749
961
|
column: 1,
|
|
750
962
|
message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
|
|
751
|
-
severity: "
|
|
963
|
+
severity: "info",
|
|
752
964
|
category: "security"
|
|
753
965
|
}];
|
|
754
966
|
}
|
|
@@ -976,6 +1188,8 @@ var SQL_INJECTION_PATTERNS = [
|
|
|
976
1188
|
// .query() or .execute() with template literal
|
|
977
1189
|
{ pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
|
|
978
1190
|
];
|
|
1191
|
+
var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
|
|
1192
|
+
var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
|
|
979
1193
|
var sqlInjectionRule = {
|
|
980
1194
|
id: "sql-injection",
|
|
981
1195
|
name: "SQL Injection Risk",
|
|
@@ -983,20 +1197,42 @@ var sqlInjectionRule = {
|
|
|
983
1197
|
category: "security",
|
|
984
1198
|
severity: "critical",
|
|
985
1199
|
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
986
|
-
check(file,
|
|
1200
|
+
check(file, project) {
|
|
987
1201
|
const findings = [];
|
|
1202
|
+
const safeTaggedLines = /* @__PURE__ */ new Set();
|
|
1203
|
+
if (file.ast) {
|
|
1204
|
+
try {
|
|
1205
|
+
walkAST(file.ast.program, (node) => {
|
|
1206
|
+
if (node.type === "TaggedTemplateExpression") {
|
|
1207
|
+
const tagged = node;
|
|
1208
|
+
if (isTaggedTemplateSql(tagged) && tagged.loc) {
|
|
1209
|
+
for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
|
|
1210
|
+
safeTaggedLines.add(l);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
} catch {
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
|
|
988
1219
|
for (let i = 0; i < file.lines.length; i++) {
|
|
989
1220
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
990
1221
|
const line = file.lines[i];
|
|
1222
|
+
const lineNum = i + 1;
|
|
1223
|
+
if (safeTaggedLines.has(lineNum)) continue;
|
|
1224
|
+
if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
|
|
1225
|
+
if (PARAMETERIZED_QUERY.test(line)) continue;
|
|
991
1226
|
for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
|
|
992
1227
|
if (pattern.test(line)) {
|
|
1228
|
+
const severity = usesORM ? "warning" : "critical";
|
|
993
1229
|
findings.push({
|
|
994
1230
|
ruleId: "sql-injection",
|
|
995
1231
|
file: file.relativePath,
|
|
996
|
-
line:
|
|
1232
|
+
line: lineNum,
|
|
997
1233
|
column: 1,
|
|
998
1234
|
message,
|
|
999
|
-
severity
|
|
1235
|
+
severity,
|
|
1000
1236
|
category: "security"
|
|
1001
1237
|
});
|
|
1002
1238
|
break;
|
|
@@ -1018,7 +1254,7 @@ var PLACEHOLDERS = [
|
|
|
1018
1254
|
{ pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
|
|
1019
1255
|
{ pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
|
|
1020
1256
|
{ pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
|
|
1021
|
-
{ pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1257
|
+
{ pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
|
|
1022
1258
|
{ pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
|
|
1023
1259
|
{ pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
|
|
1024
1260
|
];
|
|
@@ -1058,13 +1294,13 @@ var placeholderContentRule = {
|
|
|
1058
1294
|
// src/rules/stale-fallback.ts
|
|
1059
1295
|
var STALE_PATTERNS = [
|
|
1060
1296
|
{ pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
|
|
1061
|
-
{ pattern: /['"]https?:\/\/127\.0\.0\.1[
|
|
1062
|
-
{ pattern: /['"]redis:\/\/localhost['"]/, label: "Hardcoded Redis localhost URL" },
|
|
1063
|
-
{ pattern: /['"]mongodb:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1064
|
-
{ pattern: /['"]mongodb\+srv:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1065
|
-
{ pattern: /['"]postgres:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1066
|
-
{ pattern: /['"]postgresql:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1067
|
-
{ pattern: /['"]amqp:\/\/localhost['"]/, label: "Hardcoded AMQP localhost URL" }
|
|
1297
|
+
{ pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
|
|
1298
|
+
{ pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
|
|
1299
|
+
{ pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1300
|
+
{ pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
|
|
1301
|
+
{ pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1302
|
+
{ pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
|
|
1303
|
+
{ pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
|
|
1068
1304
|
];
|
|
1069
1305
|
var staleFallbackRule = {
|
|
1070
1306
|
id: "stale-fallback",
|
|
@@ -1103,15 +1339,15 @@ var staleFallbackRule = {
|
|
|
1103
1339
|
|
|
1104
1340
|
// src/rules/hallucinated-api.ts
|
|
1105
1341
|
var HALLUCINATED_APIS = [
|
|
1106
|
-
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()" },
|
|
1107
|
-
{ pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()" },
|
|
1108
|
-
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()" },
|
|
1109
|
-
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()" },
|
|
1110
|
-
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()" },
|
|
1111
|
-
{ pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()" },
|
|
1112
|
-
{ pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()" },
|
|
1113
|
-
{ pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)" },
|
|
1114
|
-
{ pattern:
|
|
1342
|
+
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1343
|
+
{ pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
|
|
1344
|
+
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1345
|
+
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1346
|
+
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
1347
|
+
{ pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
|
|
1348
|
+
{ pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
|
|
1349
|
+
{ pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
|
|
1350
|
+
{ pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
|
|
1115
1351
|
];
|
|
1116
1352
|
var hallucinatedApiRule = {
|
|
1117
1353
|
id: "hallucinated-api",
|
|
@@ -1120,14 +1356,16 @@ var hallucinatedApiRule = {
|
|
|
1120
1356
|
category: "ai-quality",
|
|
1121
1357
|
severity: "warning",
|
|
1122
1358
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1123
|
-
check(file,
|
|
1359
|
+
check(file, project) {
|
|
1124
1360
|
const findings = [];
|
|
1361
|
+
const frameworks = project.detectedFrameworks;
|
|
1125
1362
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1126
1363
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1127
1364
|
const line = file.lines[i];
|
|
1128
|
-
for (const { pattern, fix } of HALLUCINATED_APIS) {
|
|
1365
|
+
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
1129
1366
|
const match = pattern.exec(line);
|
|
1130
1367
|
if (match) {
|
|
1368
|
+
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
1131
1369
|
findings.push({
|
|
1132
1370
|
ruleId: "hallucinated-api",
|
|
1133
1371
|
file: file.relativePath,
|
|
@@ -1145,7 +1383,7 @@ var hallucinatedApiRule = {
|
|
|
1145
1383
|
};
|
|
1146
1384
|
|
|
1147
1385
|
// src/rules/open-redirect.ts
|
|
1148
|
-
var
|
|
1386
|
+
var DIRECT_INPUT_PATTERNS = [
|
|
1149
1387
|
// redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
|
|
1150
1388
|
/redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
|
|
1151
1389
|
// redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
|
|
@@ -1154,21 +1392,21 @@ var CRITICAL_PATTERNS = [
|
|
|
1154
1392
|
/NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
|
|
1155
1393
|
];
|
|
1156
1394
|
var WARNING_PATTERNS = [
|
|
1157
|
-
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
|
|
1395
|
+
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1158
1396
|
];
|
|
1159
1397
|
var openRedirectRule = {
|
|
1160
1398
|
id: "open-redirect",
|
|
1161
1399
|
name: "Open Redirect",
|
|
1162
1400
|
description: "Detects user-controlled input passed directly to redirect functions",
|
|
1163
1401
|
category: "security",
|
|
1164
|
-
severity: "
|
|
1402
|
+
severity: "warning",
|
|
1165
1403
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1166
1404
|
check(file, _project) {
|
|
1167
1405
|
const findings = [];
|
|
1168
1406
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1169
1407
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1170
1408
|
const line = file.lines[i];
|
|
1171
|
-
for (const pattern of
|
|
1409
|
+
for (const pattern of DIRECT_INPUT_PATTERNS) {
|
|
1172
1410
|
const match = pattern.exec(line);
|
|
1173
1411
|
if (match) {
|
|
1174
1412
|
findings.push({
|
|
@@ -1177,7 +1415,7 @@ var openRedirectRule = {
|
|
|
1177
1415
|
line: i + 1,
|
|
1178
1416
|
column: match.index + 1,
|
|
1179
1417
|
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1180
|
-
severity: "
|
|
1418
|
+
severity: "warning",
|
|
1181
1419
|
category: "security"
|
|
1182
1420
|
});
|
|
1183
1421
|
break;
|
|
@@ -1242,6 +1480,7 @@ var noSyncFsRule = {
|
|
|
1242
1480
|
|
|
1243
1481
|
// src/rules/no-n-plus-one.ts
|
|
1244
1482
|
var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
|
|
1483
|
+
var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
|
|
1245
1484
|
var noNPlusOneRule = {
|
|
1246
1485
|
id: "no-n-plus-one",
|
|
1247
1486
|
name: "No N+1 Queries",
|
|
@@ -1252,14 +1491,33 @@ var noNPlusOneRule = {
|
|
|
1252
1491
|
check(file, _project) {
|
|
1253
1492
|
if (isTestFile(file.relativePath)) return [];
|
|
1254
1493
|
if (isScriptFile(file.relativePath)) return [];
|
|
1494
|
+
const promiseAllMapLines = /* @__PURE__ */ new Set();
|
|
1495
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1496
|
+
if (PROMISE_ALL_MAP.test(file.lines[i])) {
|
|
1497
|
+
for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
|
|
1498
|
+
promiseAllMapLines.add(j);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1255
1502
|
const findings = [];
|
|
1256
|
-
|
|
1503
|
+
let loops;
|
|
1504
|
+
if (file.ast) {
|
|
1505
|
+
try {
|
|
1506
|
+
loops = findLoopsAST(file.ast);
|
|
1507
|
+
} catch {
|
|
1508
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1509
|
+
}
|
|
1510
|
+
} else {
|
|
1511
|
+
loops = findLoopBodies(file.lines, file.commentMap);
|
|
1512
|
+
}
|
|
1257
1513
|
const reported = /* @__PURE__ */ new Set();
|
|
1258
1514
|
for (const loop of loops) {
|
|
1259
1515
|
if (reported.has(loop.loopLine)) continue;
|
|
1516
|
+
if (promiseAllMapLines.has(loop.loopLine)) continue;
|
|
1260
1517
|
for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
|
|
1261
1518
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1262
1519
|
const line = file.lines[i];
|
|
1520
|
+
if (promiseAllMapLines.has(i)) continue;
|
|
1263
1521
|
const match = DB_CALL_PATTERN.exec(line);
|
|
1264
1522
|
if (match) {
|
|
1265
1523
|
reported.add(loop.loopLine);
|
|
@@ -1393,6 +1651,10 @@ var HANDLED_PATTERNS = [
|
|
|
1393
1651
|
/Promise\.allSettled/,
|
|
1394
1652
|
/Promise\.race/
|
|
1395
1653
|
];
|
|
1654
|
+
var CHAIN_START_PATTERNS = [
|
|
1655
|
+
/\.from\s*\(/,
|
|
1656
|
+
/\.rpc\s*\(/
|
|
1657
|
+
];
|
|
1396
1658
|
var unhandledPromiseRule = {
|
|
1397
1659
|
id: "unhandled-promise",
|
|
1398
1660
|
name: "Unhandled Promise",
|
|
@@ -1412,6 +1674,19 @@ var unhandledPromiseRule = {
|
|
|
1412
1674
|
if (!asyncMatch) continue;
|
|
1413
1675
|
const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
|
|
1414
1676
|
if (isHandled) continue;
|
|
1677
|
+
const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
|
|
1678
|
+
if (isChainContinuation) {
|
|
1679
|
+
let chainHandled = false;
|
|
1680
|
+
for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
|
|
1681
|
+
const prevTrimmed = file.lines[j].trim();
|
|
1682
|
+
if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
|
|
1683
|
+
if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
|
|
1684
|
+
chainHandled = true;
|
|
1685
|
+
break;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
if (chainHandled) continue;
|
|
1689
|
+
}
|
|
1415
1690
|
let handledAbove = false;
|
|
1416
1691
|
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
1417
1692
|
const prevTrimmed = file.lines[j].trim();
|
|
@@ -1479,9 +1754,9 @@ var missingErrorBoundaryRule = {
|
|
|
1479
1754
|
severity: "info",
|
|
1480
1755
|
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
1481
1756
|
check(file, project) {
|
|
1482
|
-
const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1757
|
+
const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
|
|
1483
1758
|
if (!match) return [];
|
|
1484
|
-
const dir =
|
|
1759
|
+
const dir = match[1] + match[2];
|
|
1485
1760
|
const hasErrorBoundary = project.allFiles.some(
|
|
1486
1761
|
(f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
|
|
1487
1762
|
);
|
|
@@ -1659,7 +1934,8 @@ var deadExportsRule = {
|
|
|
1659
1934
|
(f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
|
|
1660
1935
|
);
|
|
1661
1936
|
const exports = /* @__PURE__ */ new Map();
|
|
1662
|
-
const imports = /* @__PURE__ */ new
|
|
1937
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1938
|
+
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
1663
1939
|
const importedFiles = /* @__PURE__ */ new Set();
|
|
1664
1940
|
for (const file of sourceFiles) {
|
|
1665
1941
|
if (isEntryPoint(file.relativePath)) continue;
|
|
@@ -1683,14 +1959,28 @@ var deadExportsRule = {
|
|
|
1683
1959
|
for (const file of files) {
|
|
1684
1960
|
for (const line of file.lines) {
|
|
1685
1961
|
let match;
|
|
1962
|
+
const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
|
|
1963
|
+
const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
|
|
1686
1964
|
const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
|
|
1687
1965
|
while ((match = bracesRe.exec(line)) !== null) {
|
|
1688
1966
|
const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
|
|
1689
|
-
for (const sym of symbols)
|
|
1967
|
+
for (const sym of symbols) {
|
|
1968
|
+
allImportedSymbols.add(sym);
|
|
1969
|
+
if (fromBasename) {
|
|
1970
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1971
|
+
set.add(sym);
|
|
1972
|
+
imports.set(fromBasename, set);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1690
1975
|
}
|
|
1691
1976
|
const defaultRe = /import\s+(\w+)\s+from/g;
|
|
1692
1977
|
while ((match = defaultRe.exec(line)) !== null) {
|
|
1693
|
-
|
|
1978
|
+
allImportedSymbols.add(match[1]);
|
|
1979
|
+
if (fromBasename) {
|
|
1980
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
1981
|
+
set.add(match[1]);
|
|
1982
|
+
imports.set(fromBasename, set);
|
|
1983
|
+
}
|
|
1694
1984
|
}
|
|
1695
1985
|
const fromRe = /from\s+['"]([^'"]+)['"]/g;
|
|
1696
1986
|
while ((match = fromRe.exec(line)) !== null) {
|
|
@@ -1701,7 +1991,10 @@ var deadExportsRule = {
|
|
|
1701
1991
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
1702
1992
|
for (const [key, loc] of exports) {
|
|
1703
1993
|
const symbolName = key.split("::")[1];
|
|
1704
|
-
|
|
1994
|
+
const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
|
|
1995
|
+
const importSet = imports.get(exportFileBasename);
|
|
1996
|
+
const isImported = importSet?.has(symbolName) ?? false;
|
|
1997
|
+
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
1705
1998
|
deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
|
|
1706
1999
|
}
|
|
1707
2000
|
}
|
|
@@ -1771,12 +2064,33 @@ var shallowCatchRule = {
|
|
|
1771
2064
|
if (braceStart === -1) continue;
|
|
1772
2065
|
let depth = 0;
|
|
1773
2066
|
let bodyEnd = braceStart;
|
|
2067
|
+
let inSingle = false;
|
|
2068
|
+
let inDouble = false;
|
|
2069
|
+
let inTemplate = false;
|
|
1774
2070
|
for (let j = braceStart; j < file.lines.length; j++) {
|
|
1775
2071
|
const line = file.lines[j];
|
|
1776
2072
|
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
1777
2073
|
for (let k = startPos; k < line.length; k++) {
|
|
1778
|
-
|
|
1779
|
-
|
|
2074
|
+
const ch = line[k];
|
|
2075
|
+
const prev = k > 0 ? line[k - 1] : "";
|
|
2076
|
+
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2077
|
+
if (!escaped) {
|
|
2078
|
+
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2079
|
+
inSingle = !inSingle;
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2083
|
+
inDouble = !inDouble;
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
if (ch === "`" && !inSingle && !inDouble) {
|
|
2087
|
+
inTemplate = !inTemplate;
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
2092
|
+
if (ch === "{") depth++;
|
|
2093
|
+
if (ch === "}") {
|
|
1780
2094
|
depth--;
|
|
1781
2095
|
if (depth === 0) {
|
|
1782
2096
|
bodyEnd = j;
|
|
@@ -1943,6 +2257,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
1943
2257
|
"gpt-tokenizer"
|
|
1944
2258
|
// exists but often confused
|
|
1945
2259
|
]);
|
|
2260
|
+
var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
2261
|
+
"pg",
|
|
2262
|
+
"ws",
|
|
2263
|
+
"ms",
|
|
2264
|
+
"qs",
|
|
2265
|
+
"ip",
|
|
2266
|
+
"is",
|
|
2267
|
+
"he",
|
|
2268
|
+
"ky",
|
|
2269
|
+
"bl",
|
|
2270
|
+
"rc",
|
|
2271
|
+
"io",
|
|
2272
|
+
"db",
|
|
2273
|
+
"fp",
|
|
2274
|
+
"rx"
|
|
2275
|
+
]);
|
|
1946
2276
|
var SUSPICIOUS_PATTERNS = [
|
|
1947
2277
|
/^[a-z]{1,2}$/,
|
|
1948
2278
|
// 1-2 char names
|
|
@@ -1981,7 +2311,7 @@ var phantomDependencyRule = {
|
|
|
1981
2311
|
});
|
|
1982
2312
|
}
|
|
1983
2313
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
1984
|
-
if (pattern.test(name) && !name.startsWith("@")) {
|
|
2314
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
|
|
1985
2315
|
findings.push({
|
|
1986
2316
|
ruleId: "phantom-dependency",
|
|
1987
2317
|
file: "package.json",
|
|
@@ -1999,6 +2329,210 @@ var phantomDependencyRule = {
|
|
|
1999
2329
|
}
|
|
2000
2330
|
};
|
|
2001
2331
|
|
|
2332
|
+
// src/rules/insecure-cookie.ts
|
|
2333
|
+
var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
|
|
2334
|
+
var COOKIE_SET_PATTERNS = [
|
|
2335
|
+
/cookies\(\)\s*\.set\s*\(/,
|
|
2336
|
+
/res\.cookie\s*\(/,
|
|
2337
|
+
/response\.cookies\.set\s*\(/
|
|
2338
|
+
];
|
|
2339
|
+
var SECURE_OPTIONS = [
|
|
2340
|
+
/httpOnly\s*:\s*true/,
|
|
2341
|
+
/secure\s*:\s*true/,
|
|
2342
|
+
/sameSite\s*:/
|
|
2343
|
+
];
|
|
2344
|
+
var insecureCookieRule = {
|
|
2345
|
+
id: "insecure-cookie",
|
|
2346
|
+
name: "Insecure Cookie",
|
|
2347
|
+
description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
|
|
2348
|
+
category: "security",
|
|
2349
|
+
severity: "warning",
|
|
2350
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2351
|
+
check(file, _project) {
|
|
2352
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2353
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2354
|
+
const findings = [];
|
|
2355
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2356
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2357
|
+
const line = file.lines[i];
|
|
2358
|
+
const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
|
|
2359
|
+
if (!isCookieSet) continue;
|
|
2360
|
+
if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
|
|
2361
|
+
const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
|
|
2362
|
+
const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
|
|
2363
|
+
if (missingOptions.length > 0) {
|
|
2364
|
+
const missing = [];
|
|
2365
|
+
if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
|
|
2366
|
+
if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
|
|
2367
|
+
if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
|
|
2368
|
+
findings.push({
|
|
2369
|
+
ruleId: "insecure-cookie",
|
|
2370
|
+
file: file.relativePath,
|
|
2371
|
+
line: i + 1,
|
|
2372
|
+
column: 1,
|
|
2373
|
+
message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
|
|
2374
|
+
severity: "warning",
|
|
2375
|
+
category: "security",
|
|
2376
|
+
fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
return findings;
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2384
|
+
// src/rules/leaked-env-in-logs.ts
|
|
2385
|
+
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2386
|
+
var leakedEnvInLogsRule = {
|
|
2387
|
+
id: "leaked-env-in-logs",
|
|
2388
|
+
name: "Leaked Env in Logs",
|
|
2389
|
+
description: "Detects process.env values logged to console \u2014 potential secret exposure",
|
|
2390
|
+
category: "security",
|
|
2391
|
+
severity: "warning",
|
|
2392
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2393
|
+
check(file, _project) {
|
|
2394
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2395
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2396
|
+
const findings = [];
|
|
2397
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2398
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2399
|
+
const line = file.lines[i];
|
|
2400
|
+
const match = CONSOLE_WITH_ENV.exec(line);
|
|
2401
|
+
if (match) {
|
|
2402
|
+
findings.push({
|
|
2403
|
+
ruleId: "leaked-env-in-logs",
|
|
2404
|
+
file: file.relativePath,
|
|
2405
|
+
line: i + 1,
|
|
2406
|
+
column: match.index + 1,
|
|
2407
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2408
|
+
severity: "warning",
|
|
2409
|
+
category: "security",
|
|
2410
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return findings;
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
|
|
2418
|
+
// src/rules/insecure-random.ts
|
|
2419
|
+
var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
|
|
2420
|
+
var MATH_RANDOM = /Math\.random\s*\(\)/;
|
|
2421
|
+
var insecureRandomRule = {
|
|
2422
|
+
id: "insecure-random",
|
|
2423
|
+
name: "Insecure Random",
|
|
2424
|
+
description: "Detects Math.random() used near security-sensitive variable names",
|
|
2425
|
+
category: "security",
|
|
2426
|
+
severity: "warning",
|
|
2427
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2428
|
+
check(file, _project) {
|
|
2429
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2430
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2431
|
+
const findings = [];
|
|
2432
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2433
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2434
|
+
const line = file.lines[i];
|
|
2435
|
+
const match = MATH_RANDOM.exec(line);
|
|
2436
|
+
if (!match) continue;
|
|
2437
|
+
const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
|
|
2438
|
+
if (SECURITY_VAR_NAMES.test(context)) {
|
|
2439
|
+
findings.push({
|
|
2440
|
+
ruleId: "insecure-random",
|
|
2441
|
+
file: file.relativePath,
|
|
2442
|
+
line: i + 1,
|
|
2443
|
+
column: match.index + 1,
|
|
2444
|
+
message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
|
|
2445
|
+
severity: "warning",
|
|
2446
|
+
category: "security",
|
|
2447
|
+
fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
return findings;
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2455
|
+
// src/rules/next-server-action-validation.ts
|
|
2456
|
+
var USE_SERVER = /['"]use server['"]/;
|
|
2457
|
+
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
2458
|
+
var VALIDATION_PATTERNS2 = [
|
|
2459
|
+
/\.parse\s*\(/,
|
|
2460
|
+
/\.safeParse\s*\(/,
|
|
2461
|
+
/\bvalidate\s*\(/,
|
|
2462
|
+
/\.parseAsync\s*\(/,
|
|
2463
|
+
/\.safeParseAsync\s*\(/
|
|
2464
|
+
];
|
|
2465
|
+
var nextServerActionValidationRule = {
|
|
2466
|
+
id: "next-server-action-validation",
|
|
2467
|
+
name: "Next.js Server Action Validation",
|
|
2468
|
+
description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
|
|
2469
|
+
category: "security",
|
|
2470
|
+
severity: "critical",
|
|
2471
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2472
|
+
check(file, _project) {
|
|
2473
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2474
|
+
if (!USE_SERVER.test(file.content)) return [];
|
|
2475
|
+
if (!FORM_DATA_GET.test(file.content)) return [];
|
|
2476
|
+
const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
|
|
2477
|
+
if (hasValidation) return [];
|
|
2478
|
+
let reportLine = 1;
|
|
2479
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2480
|
+
if (FORM_DATA_GET.test(file.lines[i])) {
|
|
2481
|
+
reportLine = i + 1;
|
|
2482
|
+
break;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
return [{
|
|
2486
|
+
ruleId: "next-server-action-validation",
|
|
2487
|
+
file: file.relativePath,
|
|
2488
|
+
line: reportLine,
|
|
2489
|
+
column: 1,
|
|
2490
|
+
message: "Server action reads formData without schema validation \u2014 unvalidated user input",
|
|
2491
|
+
severity: "critical",
|
|
2492
|
+
category: "security",
|
|
2493
|
+
fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
|
|
2494
|
+
}];
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
|
|
2498
|
+
// src/rules/missing-transaction.ts
|
|
2499
|
+
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2500
|
+
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2501
|
+
var missingTransactionRule = {
|
|
2502
|
+
id: "missing-transaction",
|
|
2503
|
+
name: "Missing Transaction",
|
|
2504
|
+
description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
|
|
2505
|
+
category: "reliability",
|
|
2506
|
+
severity: "warning",
|
|
2507
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2508
|
+
check(file, project) {
|
|
2509
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2510
|
+
if (isScriptFile(file.relativePath)) return [];
|
|
2511
|
+
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2512
|
+
let writeCount = 0;
|
|
2513
|
+
let firstWriteLine = -1;
|
|
2514
|
+
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
2515
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2516
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2517
|
+
if (PRISMA_WRITE_OPS.test(file.lines[i])) {
|
|
2518
|
+
writeCount++;
|
|
2519
|
+
if (firstWriteLine === -1) firstWriteLine = i;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
if (writeCount < 2 || hasTransaction) return [];
|
|
2523
|
+
return [{
|
|
2524
|
+
ruleId: "missing-transaction",
|
|
2525
|
+
file: file.relativePath,
|
|
2526
|
+
line: firstWriteLine + 1,
|
|
2527
|
+
column: 1,
|
|
2528
|
+
message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2529
|
+
severity: "warning",
|
|
2530
|
+
category: "reliability",
|
|
2531
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2532
|
+
}];
|
|
2533
|
+
}
|
|
2534
|
+
};
|
|
2535
|
+
|
|
2002
2536
|
// src/rules/index.ts
|
|
2003
2537
|
var rules = [
|
|
2004
2538
|
// Security
|
|
@@ -2012,6 +2546,10 @@ var rules = [
|
|
|
2012
2546
|
openRedirectRule,
|
|
2013
2547
|
rateLimitingRule,
|
|
2014
2548
|
phantomDependencyRule,
|
|
2549
|
+
insecureCookieRule,
|
|
2550
|
+
leakedEnvInLogsRule,
|
|
2551
|
+
insecureRandomRule,
|
|
2552
|
+
nextServerActionValidationRule,
|
|
2015
2553
|
// Reliability
|
|
2016
2554
|
hallucinatedImportsRule,
|
|
2017
2555
|
errorHandlingRule,
|
|
@@ -2019,6 +2557,7 @@ var rules = [
|
|
|
2019
2557
|
shallowCatchRule,
|
|
2020
2558
|
missingLoadingStateRule,
|
|
2021
2559
|
missingErrorBoundaryRule,
|
|
2560
|
+
missingTransactionRule,
|
|
2022
2561
|
// Performance
|
|
2023
2562
|
noSyncFsRule,
|
|
2024
2563
|
noNPlusOneRule,
|