prodlint 0.3.1 → 0.6.0

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