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/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/**",
@@ -182,8 +309,10 @@ var SCAN_EXTENSIONS = [
182
309
  "jsx",
183
310
  "mjs",
184
311
  "cjs",
185
- "json"
312
+ "json",
313
+ "sql"
186
314
  ];
315
+ var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
187
316
  var MAX_FILE_SIZE = 1024 * 1024;
188
317
  async function walkFiles(root, extraIgnores = []) {
189
318
  const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
@@ -208,14 +337,23 @@ async function readFileContext(root, relativePath) {
208
337
  if (fileStats.size > MAX_FILE_SIZE) return null;
209
338
  const content = await readFile(absolutePath, "utf-8");
210
339
  const lines = content.split(/\r?\n|\r/);
340
+ const ext = extname(relativePath).slice(1);
341
+ let ast = void 0;
342
+ if (AST_EXTENSIONS.has(ext)) {
343
+ try {
344
+ ast = parseFile(content, relativePath);
345
+ } catch {
346
+ ast = null;
347
+ }
348
+ }
211
349
  return {
212
350
  absolutePath,
213
351
  relativePath,
214
352
  content,
215
353
  lines,
216
- ext: extname(relativePath).slice(1),
217
- // remove leading dot
218
- commentMap: buildCommentMap(lines)
354
+ ext,
355
+ commentMap: buildCommentMap(lines),
356
+ ast
219
357
  };
220
358
  } catch {
221
359
  return null;
@@ -226,6 +364,8 @@ async function buildProjectContext(root, files) {
226
364
  let declaredDependencies = /* @__PURE__ */ new Set();
227
365
  let tsconfigPaths = /* @__PURE__ */ new Set();
228
366
  let hasAuthMiddleware = false;
367
+ let hasRateLimiting = false;
368
+ const detectedFrameworks = /* @__PURE__ */ new Set();
229
369
  let gitignoreContent = null;
230
370
  let envInGitignore = false;
231
371
  try {
@@ -237,6 +377,18 @@ async function buildProjectContext(root, files) {
237
377
  ...packageJson?.peerDependencies ?? {}
238
378
  };
239
379
  declaredDependencies = new Set(Object.keys(deps));
380
+ for (const dep of declaredDependencies) {
381
+ const framework = DEPENDENCY_TO_FRAMEWORK[dep];
382
+ if (framework) {
383
+ detectedFrameworks.add(framework);
384
+ }
385
+ }
386
+ for (const framework of detectedFrameworks) {
387
+ if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
388
+ hasRateLimiting = true;
389
+ break;
390
+ }
391
+ }
240
392
  } catch {
241
393
  }
242
394
  try {
@@ -280,6 +432,18 @@ async function buildProjectContext(root, files) {
280
432
  }
281
433
  } catch {
282
434
  }
435
+ if (!hasRateLimiting) {
436
+ for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
437
+ try {
438
+ const content = await readFile(resolve(root, name), "utf-8");
439
+ if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
440
+ hasRateLimiting = true;
441
+ break;
442
+ }
443
+ } catch {
444
+ }
445
+ }
446
+ }
283
447
  try {
284
448
  gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
285
449
  envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
@@ -291,6 +455,8 @@ async function buildProjectContext(root, files) {
291
455
  declaredDependencies,
292
456
  tsconfigPaths,
293
457
  hasAuthMiddleware,
458
+ hasRateLimiting,
459
+ detectedFrameworks,
294
460
  gitignoreContent,
295
461
  envInGitignore,
296
462
  allFiles: files
@@ -315,26 +481,58 @@ function getVersion() {
315
481
 
316
482
  // src/scorer.ts
317
483
  var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
484
+ var CATEGORY_WEIGHTS = {
485
+ "security": 0.4,
486
+ "reliability": 0.3,
487
+ "performance": 0.15,
488
+ "ai-quality": 0.15
489
+ };
318
490
  var DEDUCTIONS = {
319
- critical: 10,
320
- warning: 3,
321
- info: 1
491
+ critical: 8,
492
+ warning: 2,
493
+ info: 0.5
494
+ };
495
+ var PER_RULE_CAP = {
496
+ critical: 1,
497
+ warning: 2,
498
+ info: 3
322
499
  };
323
500
  function calculateScores(findings) {
324
501
  const categoryScores = CATEGORIES.map((category) => {
325
502
  const categoryFindings = findings.filter((f) => f.category === category);
326
- let score = 100;
503
+ const byRule = /* @__PURE__ */ new Map();
327
504
  for (const f of categoryFindings) {
328
- score -= DEDUCTIONS[f.severity] ?? 0;
505
+ const arr = byRule.get(f.ruleId) ?? [];
506
+ arr.push(f);
507
+ byRule.set(f.ruleId, arr);
508
+ }
509
+ let totalDeduction = 0;
510
+ for (const [, ruleFindings] of byRule) {
511
+ const bySeverity = { critical: 0, warning: 0, info: 0 };
512
+ for (const f of ruleFindings) {
513
+ bySeverity[f.severity]++;
514
+ }
515
+ for (const sev of ["critical", "warning", "info"]) {
516
+ const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
517
+ totalDeduction += count * DEDUCTIONS[sev];
518
+ }
519
+ }
520
+ let effectiveDeduction;
521
+ if (totalDeduction <= 30) {
522
+ effectiveDeduction = totalDeduction;
523
+ } else if (totalDeduction <= 50) {
524
+ effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
525
+ } else {
526
+ effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
329
527
  }
330
528
  return {
331
529
  category,
332
- score: Math.max(0, score),
530
+ score: Math.max(0, Math.round(100 - effectiveDeduction)),
333
531
  findingCount: categoryFindings.length
334
532
  };
335
533
  });
336
534
  const overallScore = Math.round(
337
- categoryScores.reduce((sum, c) => sum + c.score, 0) / CATEGORIES.length
535
+ categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
338
536
  );
339
537
  return { overallScore, categoryScores };
340
538
  }
@@ -505,25 +703,36 @@ var AUTH_PATTERNS = [
505
703
  /jwt\.verify\s*\(/,
506
704
  /createRouteHandlerClient/,
507
705
  /createServerComponentClient/,
706
+ /createMiddlewareClient/,
508
707
  /authorization/i,
509
- /bearer/i
708
+ /getAuth\s*\(/,
709
+ /withPageAuth/,
710
+ /cookies\(\).*auth/s
510
711
  ];
712
+ var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
511
713
  var authChecksRule = {
512
714
  id: "auth-checks",
513
715
  name: "Missing Auth Checks",
514
716
  description: "Detects API routes that lack authentication checks",
515
717
  category: "security",
516
- severity: "critical",
718
+ severity: "warning",
517
719
  fileExtensions: ["ts", "tsx", "js", "jsx"],
518
720
  check(file, project) {
519
721
  if (!isApiRoute(file.relativePath)) return [];
520
722
  for (const pattern of AUTH_EXEMPT_PATTERNS) {
521
723
  if (pattern.test(file.relativePath)) return [];
522
724
  }
523
- const severity = project.hasAuthMiddleware ? "info" : "critical";
524
725
  for (const pattern of AUTH_PATTERNS) {
525
726
  if (pattern.test(file.content)) return [];
526
727
  }
728
+ let severity;
729
+ if (project.hasAuthMiddleware) {
730
+ severity = "info";
731
+ } else if (MUTATION_EXPORT.test(file.content)) {
732
+ severity = "critical";
733
+ } else {
734
+ severity = "info";
735
+ }
527
736
  let handlerLine = 1;
528
737
  for (let i = 0; i < file.lines.length; i++) {
529
738
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -620,7 +829,10 @@ var errorHandlingRule = {
620
829
  if (!isApiRoute(file.relativePath)) return [];
621
830
  const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
622
831
  const hasTryCatch = /try\s*\{/.test(file.content);
623
- if (hasTryCatch || hasFrameworkServe) return [];
832
+ const hasCatchChain = /\.catch\s*\(/.test(file.content);
833
+ const hasOnError = /onError\s*[:(]/.test(file.content);
834
+ const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
835
+ if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
624
836
  let handlerLine = 1;
625
837
  for (let i = 0; i < file.lines.length; i++) {
626
838
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -725,10 +937,11 @@ var rateLimitingRule = {
725
937
  name: "Missing Rate Limiting",
726
938
  description: "Detects API routes without rate limiting",
727
939
  category: "security",
728
- severity: "warning",
940
+ severity: "info",
729
941
  fileExtensions: ["ts", "tsx", "js", "jsx"],
730
- check(file, _project) {
942
+ check(file, project) {
731
943
  if (!isApiRoute(file.relativePath)) return [];
944
+ if (project.hasRateLimiting) return [];
732
945
  for (const pattern of EXEMPT_PATTERNS) {
733
946
  if (pattern.test(file.relativePath)) return [];
734
947
  }
@@ -748,7 +961,7 @@ var rateLimitingRule = {
748
961
  line: handlerLine,
749
962
  column: 1,
750
963
  message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
751
- severity: "warning",
964
+ severity: "info",
752
965
  category: "security"
753
966
  }];
754
967
  }
@@ -976,6 +1189,8 @@ var SQL_INJECTION_PATTERNS = [
976
1189
  // .query() or .execute() with template literal
977
1190
  { pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
978
1191
  ];
1192
+ var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
1193
+ var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
979
1194
  var sqlInjectionRule = {
980
1195
  id: "sql-injection",
981
1196
  name: "SQL Injection Risk",
@@ -983,20 +1198,42 @@ var sqlInjectionRule = {
983
1198
  category: "security",
984
1199
  severity: "critical",
985
1200
  fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
986
- check(file, _project) {
1201
+ check(file, project) {
987
1202
  const findings = [];
1203
+ const safeTaggedLines = /* @__PURE__ */ new Set();
1204
+ if (file.ast) {
1205
+ try {
1206
+ walkAST(file.ast.program, (node) => {
1207
+ if (node.type === "TaggedTemplateExpression") {
1208
+ const tagged = node;
1209
+ if (isTaggedTemplateSql(tagged) && tagged.loc) {
1210
+ for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
1211
+ safeTaggedLines.add(l);
1212
+ }
1213
+ }
1214
+ }
1215
+ });
1216
+ } catch {
1217
+ }
1218
+ }
1219
+ const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
988
1220
  for (let i = 0; i < file.lines.length; i++) {
989
1221
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
990
1222
  const line = file.lines[i];
1223
+ const lineNum = i + 1;
1224
+ if (safeTaggedLines.has(lineNum)) continue;
1225
+ if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
1226
+ if (PARAMETERIZED_QUERY.test(line)) continue;
991
1227
  for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
992
1228
  if (pattern.test(line)) {
1229
+ const severity = usesORM ? "warning" : "critical";
993
1230
  findings.push({
994
1231
  ruleId: "sql-injection",
995
1232
  file: file.relativePath,
996
- line: i + 1,
1233
+ line: lineNum,
997
1234
  column: 1,
998
1235
  message,
999
- severity: "critical",
1236
+ severity,
1000
1237
  category: "security"
1001
1238
  });
1002
1239
  break;
@@ -1018,7 +1255,7 @@ var PLACEHOLDERS = [
1018
1255
  { pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
1019
1256
  { pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
1020
1257
  { pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
1021
- { pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
1258
+ { pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
1022
1259
  { pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
1023
1260
  { pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
1024
1261
  ];
@@ -1058,13 +1295,13 @@ var placeholderContentRule = {
1058
1295
  // src/rules/stale-fallback.ts
1059
1296
  var STALE_PATTERNS = [
1060
1297
  { pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
1061
- { pattern: /['"]https?:\/\/127\.0\.0\.1[:'"]/, label: "Hardcoded 127.0.0.1 URL" },
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" }
1298
+ { pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1299
+ { pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
1300
+ { pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1301
+ { pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1302
+ { pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1303
+ { pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1304
+ { pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
1068
1305
  ];
1069
1306
  var staleFallbackRule = {
1070
1307
  id: "stale-fallback",
@@ -1103,15 +1340,15 @@ var staleFallbackRule = {
1103
1340
 
1104
1341
  // src/rules/hallucinated-api.ts
1105
1342
  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: /\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()" }
1343
+ { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
1344
+ { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
1345
+ { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
1346
+ { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
1347
+ { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
1348
+ { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
1349
+ { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
1350
+ { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
1351
+ { pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
1115
1352
  ];
1116
1353
  var hallucinatedApiRule = {
1117
1354
  id: "hallucinated-api",
@@ -1120,14 +1357,16 @@ var hallucinatedApiRule = {
1120
1357
  category: "ai-quality",
1121
1358
  severity: "warning",
1122
1359
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1123
- check(file, _project) {
1360
+ check(file, project) {
1124
1361
  const findings = [];
1362
+ const frameworks = project.detectedFrameworks;
1125
1363
  for (let i = 0; i < file.lines.length; i++) {
1126
1364
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1127
1365
  const line = file.lines[i];
1128
- for (const { pattern, fix } of HALLUCINATED_APIS) {
1366
+ for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
1129
1367
  const match = pattern.exec(line);
1130
1368
  if (match) {
1369
+ if (isFrameworkSafeMethod(methodName, frameworks)) continue;
1131
1370
  findings.push({
1132
1371
  ruleId: "hallucinated-api",
1133
1372
  file: file.relativePath,
@@ -1145,7 +1384,7 @@ var hallucinatedApiRule = {
1145
1384
  };
1146
1385
 
1147
1386
  // src/rules/open-redirect.ts
1148
- var CRITICAL_PATTERNS = [
1387
+ var DIRECT_INPUT_PATTERNS = [
1149
1388
  // redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
1150
1389
  /redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
1151
1390
  // redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
@@ -1154,21 +1393,21 @@ var CRITICAL_PATTERNS = [
1154
1393
  /NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
1155
1394
  ];
1156
1395
  var WARNING_PATTERNS = [
1157
- /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
1396
+ /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
1158
1397
  ];
1159
1398
  var openRedirectRule = {
1160
1399
  id: "open-redirect",
1161
1400
  name: "Open Redirect",
1162
1401
  description: "Detects user-controlled input passed directly to redirect functions",
1163
1402
  category: "security",
1164
- severity: "critical",
1403
+ severity: "warning",
1165
1404
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1166
1405
  check(file, _project) {
1167
1406
  const findings = [];
1168
1407
  for (let i = 0; i < file.lines.length; i++) {
1169
1408
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1170
1409
  const line = file.lines[i];
1171
- for (const pattern of CRITICAL_PATTERNS) {
1410
+ for (const pattern of DIRECT_INPUT_PATTERNS) {
1172
1411
  const match = pattern.exec(line);
1173
1412
  if (match) {
1174
1413
  findings.push({
@@ -1177,7 +1416,7 @@ var openRedirectRule = {
1177
1416
  line: i + 1,
1178
1417
  column: match.index + 1,
1179
1418
  message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1180
- severity: "critical",
1419
+ severity: "warning",
1181
1420
  category: "security"
1182
1421
  });
1183
1422
  break;
@@ -1242,6 +1481,7 @@ var noSyncFsRule = {
1242
1481
 
1243
1482
  // src/rules/no-n-plus-one.ts
1244
1483
  var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
1484
+ var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
1245
1485
  var noNPlusOneRule = {
1246
1486
  id: "no-n-plus-one",
1247
1487
  name: "No N+1 Queries",
@@ -1252,14 +1492,33 @@ var noNPlusOneRule = {
1252
1492
  check(file, _project) {
1253
1493
  if (isTestFile(file.relativePath)) return [];
1254
1494
  if (isScriptFile(file.relativePath)) return [];
1495
+ const promiseAllMapLines = /* @__PURE__ */ new Set();
1496
+ for (let i = 0; i < file.lines.length; i++) {
1497
+ if (PROMISE_ALL_MAP.test(file.lines[i])) {
1498
+ for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
1499
+ promiseAllMapLines.add(j);
1500
+ }
1501
+ }
1502
+ }
1255
1503
  const findings = [];
1256
- const loops = findLoopBodies(file.lines, file.commentMap);
1504
+ let loops;
1505
+ if (file.ast) {
1506
+ try {
1507
+ loops = findLoopsAST(file.ast);
1508
+ } catch {
1509
+ loops = findLoopBodies(file.lines, file.commentMap);
1510
+ }
1511
+ } else {
1512
+ loops = findLoopBodies(file.lines, file.commentMap);
1513
+ }
1257
1514
  const reported = /* @__PURE__ */ new Set();
1258
1515
  for (const loop of loops) {
1259
1516
  if (reported.has(loop.loopLine)) continue;
1517
+ if (promiseAllMapLines.has(loop.loopLine)) continue;
1260
1518
  for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
1261
1519
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1262
1520
  const line = file.lines[i];
1521
+ if (promiseAllMapLines.has(i)) continue;
1263
1522
  const match = DB_CALL_PATTERN.exec(line);
1264
1523
  if (match) {
1265
1524
  reported.add(loop.loopLine);
@@ -1393,6 +1652,10 @@ var HANDLED_PATTERNS = [
1393
1652
  /Promise\.allSettled/,
1394
1653
  /Promise\.race/
1395
1654
  ];
1655
+ var CHAIN_START_PATTERNS = [
1656
+ /\.from\s*\(/,
1657
+ /\.rpc\s*\(/
1658
+ ];
1396
1659
  var unhandledPromiseRule = {
1397
1660
  id: "unhandled-promise",
1398
1661
  name: "Unhandled Promise",
@@ -1412,6 +1675,19 @@ var unhandledPromiseRule = {
1412
1675
  if (!asyncMatch) continue;
1413
1676
  const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
1414
1677
  if (isHandled) continue;
1678
+ const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
1679
+ if (isChainContinuation) {
1680
+ let chainHandled = false;
1681
+ for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
1682
+ const prevTrimmed = file.lines[j].trim();
1683
+ if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
1684
+ if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
1685
+ chainHandled = true;
1686
+ break;
1687
+ }
1688
+ }
1689
+ if (chainHandled) continue;
1690
+ }
1415
1691
  let handledAbove = false;
1416
1692
  for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
1417
1693
  const prevTrimmed = file.lines[j].trim();
@@ -1479,9 +1755,9 @@ var missingErrorBoundaryRule = {
1479
1755
  severity: "info",
1480
1756
  fileExtensions: ["tsx", "jsx", "ts", "js"],
1481
1757
  check(file, project) {
1482
- const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
1758
+ const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
1483
1759
  if (!match) return [];
1484
- const dir = "app/" + match[1];
1760
+ const dir = match[1] + match[2];
1485
1761
  const hasErrorBoundary = project.allFiles.some(
1486
1762
  (f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
1487
1763
  );
@@ -1659,7 +1935,8 @@ var deadExportsRule = {
1659
1935
  (f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
1660
1936
  );
1661
1937
  const exports = /* @__PURE__ */ new Map();
1662
- const imports = /* @__PURE__ */ new Set();
1938
+ const imports = /* @__PURE__ */ new Map();
1939
+ const allImportedSymbols = /* @__PURE__ */ new Set();
1663
1940
  const importedFiles = /* @__PURE__ */ new Set();
1664
1941
  for (const file of sourceFiles) {
1665
1942
  if (isEntryPoint(file.relativePath)) continue;
@@ -1683,14 +1960,28 @@ var deadExportsRule = {
1683
1960
  for (const file of files) {
1684
1961
  for (const line of file.lines) {
1685
1962
  let match;
1963
+ const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
1964
+ const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
1686
1965
  const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
1687
1966
  while ((match = bracesRe.exec(line)) !== null) {
1688
1967
  const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
1689
- for (const sym of symbols) imports.add(sym);
1968
+ for (const sym of symbols) {
1969
+ allImportedSymbols.add(sym);
1970
+ if (fromBasename) {
1971
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1972
+ set.add(sym);
1973
+ imports.set(fromBasename, set);
1974
+ }
1975
+ }
1690
1976
  }
1691
1977
  const defaultRe = /import\s+(\w+)\s+from/g;
1692
1978
  while ((match = defaultRe.exec(line)) !== null) {
1693
- imports.add(match[1]);
1979
+ allImportedSymbols.add(match[1]);
1980
+ if (fromBasename) {
1981
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1982
+ set.add(match[1]);
1983
+ imports.set(fromBasename, set);
1984
+ }
1694
1985
  }
1695
1986
  const fromRe = /from\s+['"]([^'"]+)['"]/g;
1696
1987
  while ((match = fromRe.exec(line)) !== null) {
@@ -1701,7 +1992,10 @@ var deadExportsRule = {
1701
1992
  const deadByFile = /* @__PURE__ */ new Map();
1702
1993
  for (const [key, loc] of exports) {
1703
1994
  const symbolName = key.split("::")[1];
1704
- if (!imports.has(symbolName)) {
1995
+ const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
1996
+ const importSet = imports.get(exportFileBasename);
1997
+ const isImported = importSet?.has(symbolName) ?? false;
1998
+ if (!isImported && !allImportedSymbols.has(symbolName)) {
1705
1999
  deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
1706
2000
  }
1707
2001
  }
@@ -1771,12 +2065,33 @@ var shallowCatchRule = {
1771
2065
  if (braceStart === -1) continue;
1772
2066
  let depth = 0;
1773
2067
  let bodyEnd = braceStart;
2068
+ let inSingle = false;
2069
+ let inDouble = false;
2070
+ let inTemplate = false;
1774
2071
  for (let j = braceStart; j < file.lines.length; j++) {
1775
2072
  const line = file.lines[j];
1776
2073
  const startPos = j === braceStart ? line.indexOf("{") : 0;
1777
2074
  for (let k = startPos; k < line.length; k++) {
1778
- if (line[k] === "{") depth++;
1779
- if (line[k] === "}") {
2075
+ const ch = line[k];
2076
+ const prev = k > 0 ? line[k - 1] : "";
2077
+ const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
2078
+ if (!escaped) {
2079
+ if (ch === "'" && !inDouble && !inTemplate) {
2080
+ inSingle = !inSingle;
2081
+ continue;
2082
+ }
2083
+ if (ch === '"' && !inSingle && !inTemplate) {
2084
+ inDouble = !inDouble;
2085
+ continue;
2086
+ }
2087
+ if (ch === "`" && !inSingle && !inDouble) {
2088
+ inTemplate = !inTemplate;
2089
+ continue;
2090
+ }
2091
+ }
2092
+ if (inSingle || inDouble || inTemplate) continue;
2093
+ if (ch === "{") depth++;
2094
+ if (ch === "}") {
1780
2095
  depth--;
1781
2096
  if (depth === 0) {
1782
2097
  bodyEnd = j;
@@ -1943,6 +2258,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
1943
2258
  "gpt-tokenizer"
1944
2259
  // exists but often confused
1945
2260
  ]);
2261
+ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2262
+ "pg",
2263
+ "ws",
2264
+ "ms",
2265
+ "qs",
2266
+ "ip",
2267
+ "is",
2268
+ "he",
2269
+ "ky",
2270
+ "bl",
2271
+ "rc",
2272
+ "io",
2273
+ "db",
2274
+ "fp",
2275
+ "rx"
2276
+ ]);
1946
2277
  var SUSPICIOUS_PATTERNS = [
1947
2278
  /^[a-z]{1,2}$/,
1948
2279
  // 1-2 char names
@@ -1981,7 +2312,7 @@ var phantomDependencyRule = {
1981
2312
  });
1982
2313
  }
1983
2314
  for (const pattern of SUSPICIOUS_PATTERNS) {
1984
- if (pattern.test(name) && !name.startsWith("@")) {
2315
+ if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
1985
2316
  findings.push({
1986
2317
  ruleId: "phantom-dependency",
1987
2318
  file: "package.json",
@@ -1999,6 +2330,1262 @@ var phantomDependencyRule = {
1999
2330
  }
2000
2331
  };
2001
2332
 
2333
+ // src/rules/insecure-cookie.ts
2334
+ var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
2335
+ var COOKIE_SET_PATTERNS = [
2336
+ /cookies\(\)\s*\.set\s*\(/,
2337
+ /res\.cookie\s*\(/,
2338
+ /response\.cookies\.set\s*\(/
2339
+ ];
2340
+ var SECURE_OPTIONS = [
2341
+ /httpOnly\s*:\s*true/,
2342
+ /secure\s*:\s*true/,
2343
+ /sameSite\s*:/
2344
+ ];
2345
+ var insecureCookieRule = {
2346
+ id: "insecure-cookie",
2347
+ name: "Insecure Cookie",
2348
+ description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
2349
+ category: "security",
2350
+ severity: "warning",
2351
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2352
+ check(file, _project) {
2353
+ if (isTestFile(file.relativePath)) return [];
2354
+ if (isScriptFile(file.relativePath)) return [];
2355
+ const findings = [];
2356
+ for (let i = 0; i < file.lines.length; i++) {
2357
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2358
+ const line = file.lines[i];
2359
+ const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
2360
+ if (!isCookieSet) continue;
2361
+ if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
2362
+ const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
2363
+ const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
2364
+ if (missingOptions.length > 0) {
2365
+ const missing = [];
2366
+ if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
2367
+ if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
2368
+ if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
2369
+ findings.push({
2370
+ ruleId: "insecure-cookie",
2371
+ file: file.relativePath,
2372
+ line: i + 1,
2373
+ column: 1,
2374
+ message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
2375
+ severity: "warning",
2376
+ category: "security",
2377
+ fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
2378
+ });
2379
+ }
2380
+ }
2381
+ return findings;
2382
+ }
2383
+ };
2384
+
2385
+ // src/rules/leaked-env-in-logs.ts
2386
+ var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
2387
+ var leakedEnvInLogsRule = {
2388
+ id: "leaked-env-in-logs",
2389
+ name: "Leaked Env in Logs",
2390
+ description: "Detects process.env values logged to console \u2014 potential secret exposure",
2391
+ category: "security",
2392
+ severity: "warning",
2393
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2394
+ check(file, _project) {
2395
+ if (isTestFile(file.relativePath)) return [];
2396
+ if (isScriptFile(file.relativePath)) return [];
2397
+ const findings = [];
2398
+ for (let i = 0; i < file.lines.length; i++) {
2399
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2400
+ const line = file.lines[i];
2401
+ const match = CONSOLE_WITH_ENV.exec(line);
2402
+ if (match) {
2403
+ findings.push({
2404
+ ruleId: "leaked-env-in-logs",
2405
+ file: file.relativePath,
2406
+ line: i + 1,
2407
+ column: match.index + 1,
2408
+ message: "process.env value in console output \u2014 may leak secrets in production logs",
2409
+ severity: "warning",
2410
+ category: "security",
2411
+ fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
2412
+ });
2413
+ }
2414
+ }
2415
+ return findings;
2416
+ }
2417
+ };
2418
+
2419
+ // src/rules/insecure-random.ts
2420
+ var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
2421
+ var MATH_RANDOM = /Math\.random\s*\(\)/;
2422
+ var insecureRandomRule = {
2423
+ id: "insecure-random",
2424
+ name: "Insecure Random",
2425
+ description: "Detects Math.random() used near security-sensitive variable names",
2426
+ category: "security",
2427
+ severity: "warning",
2428
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2429
+ check(file, _project) {
2430
+ if (isTestFile(file.relativePath)) return [];
2431
+ if (isScriptFile(file.relativePath)) return [];
2432
+ const findings = [];
2433
+ for (let i = 0; i < file.lines.length; i++) {
2434
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2435
+ const line = file.lines[i];
2436
+ const match = MATH_RANDOM.exec(line);
2437
+ if (!match) continue;
2438
+ const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
2439
+ if (SECURITY_VAR_NAMES.test(context)) {
2440
+ findings.push({
2441
+ ruleId: "insecure-random",
2442
+ file: file.relativePath,
2443
+ line: i + 1,
2444
+ column: match.index + 1,
2445
+ message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
2446
+ severity: "warning",
2447
+ category: "security",
2448
+ fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
2449
+ });
2450
+ }
2451
+ }
2452
+ return findings;
2453
+ }
2454
+ };
2455
+
2456
+ // src/rules/next-server-action-validation.ts
2457
+ var USE_SERVER = /['"]use server['"]/;
2458
+ var FORM_DATA_GET = /formData\.get\s*\(/;
2459
+ var VALIDATION_PATTERNS2 = [
2460
+ /\.parse\s*\(/,
2461
+ /\.safeParse\s*\(/,
2462
+ /\bvalidate\s*\(/,
2463
+ /\.parseAsync\s*\(/,
2464
+ /\.safeParseAsync\s*\(/
2465
+ ];
2466
+ var nextServerActionValidationRule = {
2467
+ id: "next-server-action-validation",
2468
+ name: "Next.js Server Action Validation",
2469
+ description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
2470
+ category: "security",
2471
+ severity: "critical",
2472
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2473
+ check(file, _project) {
2474
+ if (isTestFile(file.relativePath)) return [];
2475
+ if (!USE_SERVER.test(file.content)) return [];
2476
+ if (!FORM_DATA_GET.test(file.content)) return [];
2477
+ const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
2478
+ if (hasValidation) return [];
2479
+ let reportLine = 1;
2480
+ for (let i = 0; i < file.lines.length; i++) {
2481
+ if (FORM_DATA_GET.test(file.lines[i])) {
2482
+ reportLine = i + 1;
2483
+ break;
2484
+ }
2485
+ }
2486
+ return [{
2487
+ ruleId: "next-server-action-validation",
2488
+ file: file.relativePath,
2489
+ line: reportLine,
2490
+ column: 1,
2491
+ message: "Server action reads formData without schema validation \u2014 unvalidated user input",
2492
+ severity: "critical",
2493
+ category: "security",
2494
+ fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
2495
+ }];
2496
+ }
2497
+ };
2498
+
2499
+ // src/rules/missing-transaction.ts
2500
+ var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
2501
+ var PRISMA_TRANSACTION = /\$transaction\s*\(/;
2502
+ var missingTransactionRule = {
2503
+ id: "missing-transaction",
2504
+ name: "Missing Transaction",
2505
+ description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
2506
+ category: "reliability",
2507
+ severity: "warning",
2508
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2509
+ check(file, project) {
2510
+ if (isTestFile(file.relativePath)) return [];
2511
+ if (isScriptFile(file.relativePath)) return [];
2512
+ if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
2513
+ let writeCount = 0;
2514
+ let firstWriteLine = -1;
2515
+ const hasTransaction = PRISMA_TRANSACTION.test(file.content);
2516
+ for (let i = 0; i < file.lines.length; i++) {
2517
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2518
+ if (PRISMA_WRITE_OPS.test(file.lines[i])) {
2519
+ writeCount++;
2520
+ if (firstWriteLine === -1) firstWriteLine = i;
2521
+ }
2522
+ }
2523
+ if (writeCount < 2 || hasTransaction) return [];
2524
+ return [{
2525
+ ruleId: "missing-transaction",
2526
+ file: file.relativePath,
2527
+ line: firstWriteLine + 1,
2528
+ column: 1,
2529
+ message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
2530
+ severity: "warning",
2531
+ category: "reliability",
2532
+ fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
2533
+ }];
2534
+ }
2535
+ };
2536
+
2537
+ // src/rules/redirect-in-try-catch.ts
2538
+ var redirectInTryCatchRule = {
2539
+ id: "redirect-in-try-catch",
2540
+ name: "Redirect Inside Try/Catch",
2541
+ description: "Detects Next.js redirect() inside try/catch blocks \u2014 redirect throws internally and the catch swallows it",
2542
+ category: "reliability",
2543
+ severity: "critical",
2544
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2545
+ check(file, _project) {
2546
+ if (isTestFile(file.relativePath)) return [];
2547
+ if (!/redirect\s*\(/.test(file.content)) return [];
2548
+ const findings = [];
2549
+ let tryDepth = 0;
2550
+ for (let i = 0; i < file.lines.length; i++) {
2551
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2552
+ const line = file.lines[i];
2553
+ const trimmed = line.trim();
2554
+ if (/\btry\s*\{/.test(trimmed) || trimmed === "try {") {
2555
+ tryDepth++;
2556
+ }
2557
+ if (/\}\s*catch\s*[\s(]/.test(trimmed)) {
2558
+ }
2559
+ if (tryDepth > 0) {
2560
+ const match = /\bredirect\s*\(/.exec(line);
2561
+ if (match && !/\/\//.test(line.slice(0, match.index))) {
2562
+ findings.push({
2563
+ ruleId: "redirect-in-try-catch",
2564
+ file: file.relativePath,
2565
+ line: i + 1,
2566
+ column: match.index + 1,
2567
+ message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
2568
+ severity: "critical",
2569
+ category: "reliability",
2570
+ 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'
2571
+ });
2572
+ }
2573
+ }
2574
+ for (const ch of trimmed) {
2575
+ if (ch === "{" && tryDepth > 0) {
2576
+ }
2577
+ }
2578
+ if (tryDepth > 0 && /^\}\s*$/.test(trimmed)) {
2579
+ let nextLine = "";
2580
+ for (let j = i + 1; j < file.lines.length; j++) {
2581
+ nextLine = file.lines[j].trim();
2582
+ if (nextLine) break;
2583
+ }
2584
+ if (!/^catch\b/.test(nextLine) && !/^finally\b/.test(nextLine)) {
2585
+ tryDepth--;
2586
+ }
2587
+ }
2588
+ }
2589
+ return findings;
2590
+ }
2591
+ };
2592
+
2593
+ // src/rules/missing-revalidation.ts
2594
+ var USE_SERVER2 = /['"]use server['"]/;
2595
+ var DB_MUTATIONS = [
2596
+ /\.insert\s*\(/,
2597
+ /\.update\s*\(/,
2598
+ /\.delete\s*\(/,
2599
+ /\.upsert\s*\(/,
2600
+ /\.create\s*\(/,
2601
+ /\.createMany\s*\(/,
2602
+ /\.updateMany\s*\(/,
2603
+ /\.deleteMany\s*\(/,
2604
+ /\.remove\s*\(/,
2605
+ /\.save\s*\(/,
2606
+ /\.destroy\s*\(/
2607
+ ];
2608
+ var REVALIDATION = [
2609
+ /revalidatePath\s*\(/,
2610
+ /revalidateTag\s*\(/,
2611
+ /redirect\s*\(/
2612
+ ];
2613
+ var missingRevalidationRule = {
2614
+ id: "missing-revalidation",
2615
+ name: "Missing Revalidation After Mutation",
2616
+ description: "Detects server actions that mutate data without calling revalidatePath or revalidateTag \u2014 UI shows stale data",
2617
+ category: "reliability",
2618
+ severity: "warning",
2619
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2620
+ check(file, _project) {
2621
+ if (isTestFile(file.relativePath)) return [];
2622
+ if (!USE_SERVER2.test(file.content)) return [];
2623
+ const hasMutation = DB_MUTATIONS.some((p) => p.test(file.content));
2624
+ if (!hasMutation) return [];
2625
+ const hasRevalidation = REVALIDATION.some((p) => p.test(file.content));
2626
+ if (hasRevalidation) return [];
2627
+ let reportLine = 1;
2628
+ for (let i = 0; i < file.lines.length; i++) {
2629
+ if (DB_MUTATIONS.some((p) => p.test(file.lines[i]))) {
2630
+ reportLine = i + 1;
2631
+ break;
2632
+ }
2633
+ }
2634
+ return [{
2635
+ ruleId: "missing-revalidation",
2636
+ file: file.relativePath,
2637
+ line: reportLine,
2638
+ column: 1,
2639
+ message: "Server action mutates data without revalidatePath() or revalidateTag() \u2014 UI will show stale data",
2640
+ severity: "warning",
2641
+ category: "reliability",
2642
+ fix: 'Add revalidatePath("/affected-route") after the mutation'
2643
+ }];
2644
+ }
2645
+ };
2646
+
2647
+ // src/rules/use-client-overuse.ts
2648
+ var CLIENT_APIS = [
2649
+ /\buseState\b/,
2650
+ /\buseEffect\b/,
2651
+ /\buseRef\b/,
2652
+ /\buseReducer\b/,
2653
+ /\buseCallback\b/,
2654
+ /\buseMemo\b/,
2655
+ /\buseContext\b/,
2656
+ /\buseLayoutEffect\b/,
2657
+ /\buseInsertionEffect\b/,
2658
+ /\buseTransition\b/,
2659
+ /\buseDeferredValue\b/,
2660
+ /\buseSyncExternalStore\b/,
2661
+ /\buseFormStatus\b/,
2662
+ /\buseFormState\b/,
2663
+ /\buseOptimistic\b/,
2664
+ /\bonClick\b\s*[=:]/,
2665
+ /\bonChange\b\s*[=:]/,
2666
+ /\bonSubmit\b\s*[=:]/,
2667
+ /\bonBlur\b\s*[=:]/,
2668
+ /\bonFocus\b\s*[=:]/,
2669
+ /\bonKeyDown\b\s*[=:]/,
2670
+ /\bonKeyUp\b\s*[=:]/,
2671
+ /\bonMouseDown\b\s*[=:]/,
2672
+ /\bonMouseUp\b\s*[=:]/,
2673
+ /\bonScroll\b\s*[=:]/,
2674
+ /\bonInput\b\s*[=:]/,
2675
+ /\bonDrag\b/,
2676
+ /\bonDrop\b/,
2677
+ /\bonTouchStart\b/,
2678
+ /\bcreateContext\b/,
2679
+ /\bwindow\./,
2680
+ /\bdocument\./,
2681
+ /\blocalStorage\b/,
2682
+ /\bsessionStorage\b/,
2683
+ /\bnavigator\b/,
2684
+ /\bIntersectionObserver\b/,
2685
+ /\bResizeObserver\b/,
2686
+ /\bMutationObserver\b/
2687
+ ];
2688
+ var useClientOveruseRule = {
2689
+ id: "use-client-overuse",
2690
+ name: '"use client" Overuse',
2691
+ description: `Detects files with "use client" that don't use any client-side APIs \u2014 unnecessary client rendering`,
2692
+ category: "ai-quality",
2693
+ severity: "info",
2694
+ fileExtensions: ["tsx", "jsx"],
2695
+ check(file, _project) {
2696
+ if (isTestFile(file.relativePath)) return [];
2697
+ if (isConfigFile(file.relativePath)) return [];
2698
+ if (!isClientComponent(file.content)) return [];
2699
+ const usesClientApi = CLIENT_APIS.some((p) => p.test(file.content));
2700
+ if (usesClientApi) return [];
2701
+ return [{
2702
+ ruleId: "use-client-overuse",
2703
+ file: file.relativePath,
2704
+ line: 1,
2705
+ column: 1,
2706
+ message: '"use client" directive but no client-side APIs (hooks, event handlers, browser APIs) \u2014 this component could be a server component',
2707
+ severity: "info",
2708
+ category: "ai-quality",
2709
+ fix: 'Remove "use client" to let Next.js render this as a server component for better performance'
2710
+ }];
2711
+ }
2712
+ };
2713
+
2714
+ // src/rules/env-fallback-secret.ts
2715
+ 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;
2716
+ var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
2717
+ var envFallbackSecretRule = {
2718
+ id: "env-fallback-secret",
2719
+ name: "Secret with Fallback Value",
2720
+ description: "Detects security-sensitive env vars with hardcoded fallback values \u2014 if the env var is missing, the fallback becomes the production secret",
2721
+ category: "security",
2722
+ severity: "critical",
2723
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
2724
+ check(file, _project) {
2725
+ if (isTestFile(file.relativePath)) return [];
2726
+ const findings = [];
2727
+ for (let i = 0; i < file.lines.length; i++) {
2728
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2729
+ const line = file.lines[i];
2730
+ const directMatch = SENSITIVE_ENV.exec(line);
2731
+ if (directMatch) {
2732
+ findings.push({
2733
+ ruleId: "env-fallback-secret",
2734
+ file: file.relativePath,
2735
+ line: i + 1,
2736
+ column: directMatch.index + 1,
2737
+ message: `Secret env var has a hardcoded fallback \u2014 if ${directMatch[1] || "the var"} is unset, this literal becomes the production secret`,
2738
+ severity: "critical",
2739
+ category: "security",
2740
+ fix: 'Throw an error if the env var is missing: const secret = process.env.SECRET ?? (() => { throw new Error("SECRET is required") })()'
2741
+ });
2742
+ continue;
2743
+ }
2744
+ const genericMatch = ENV_FALLBACK.exec(line);
2745
+ if (genericMatch && !isConfigFile(file.relativePath)) {
2746
+ findings.push({
2747
+ ruleId: "env-fallback-secret",
2748
+ file: file.relativePath,
2749
+ line: i + 1,
2750
+ column: genericMatch.index + 1,
2751
+ message: "Security-sensitive env var has a hardcoded fallback \u2014 defaults to a literal string when missing",
2752
+ severity: "warning",
2753
+ category: "security",
2754
+ fix: "Fail fast when required env vars are missing instead of falling back to a default value"
2755
+ });
2756
+ }
2757
+ }
2758
+ return findings;
2759
+ }
2760
+ };
2761
+
2762
+ // src/rules/verbose-error-response.ts
2763
+ var ERROR_LEAK_PATTERNS = [
2764
+ { pattern: /error\.stack/, msg: "error.stack exposed \u2014 leaks internal file paths and code structure" },
2765
+ { pattern: /error\.message/, msg: "error.message may leak internal details to clients" }
2766
+ ];
2767
+ var verboseErrorResponseRule = {
2768
+ id: "verbose-error-response",
2769
+ name: "Verbose Error Response",
2770
+ description: "Detects error details (stack traces, error messages) sent directly in API responses",
2771
+ category: "security",
2772
+ severity: "warning",
2773
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2774
+ check(file, _project) {
2775
+ if (isTestFile(file.relativePath)) return [];
2776
+ if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
2777
+ const findings = [];
2778
+ for (let i = 0; i < file.lines.length; i++) {
2779
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2780
+ const line = file.lines[i];
2781
+ for (const { pattern, msg } of ERROR_LEAK_PATTERNS) {
2782
+ const match = pattern.exec(line);
2783
+ if (match) {
2784
+ const severity = pattern.source.includes("stack") ? "warning" : "info";
2785
+ findings.push({
2786
+ ruleId: "verbose-error-response",
2787
+ file: file.relativePath,
2788
+ line: i + 1,
2789
+ column: match.index + 1,
2790
+ message: msg,
2791
+ severity,
2792
+ category: "security",
2793
+ fix: 'Return a generic error message: { error: "Internal server error" }. Log the real error server-side.'
2794
+ });
2795
+ break;
2796
+ }
2797
+ }
2798
+ }
2799
+ return findings;
2800
+ }
2801
+ };
2802
+
2803
+ // src/rules/missing-webhook-verification.ts
2804
+ var WEBHOOK_PATH = /webhook/i;
2805
+ var VERIFICATION_PATTERNS = [
2806
+ /constructEvent\s*\(/,
2807
+ // Stripe
2808
+ /webhooks\.verify\s*\(/,
2809
+ // Clerk, GitHub
2810
+ /verify\s*\(/,
2811
+ // Generic
2812
+ /verifySignature\s*\(/,
2813
+ // Generic
2814
+ /validateWebhook\s*\(/,
2815
+ // Generic
2816
+ /svix.*verify/i,
2817
+ // Svix (used by Clerk, Resend)
2818
+ /crypto\.timingSafeEqual\s*\(/,
2819
+ // Manual HMAC comparison
2820
+ /hmac/i,
2821
+ // HMAC verification
2822
+ /x-hub-signature/i,
2823
+ // GitHub webhooks
2824
+ /stripe-signature/i,
2825
+ // Stripe signature header
2826
+ /svix-signature/i,
2827
+ // Svix signature header
2828
+ /webhook-secret/i
2829
+ ];
2830
+ var missingWebhookVerificationRule = {
2831
+ id: "missing-webhook-verification",
2832
+ name: "Missing Webhook Verification",
2833
+ description: "Detects webhook endpoints without signature verification \u2014 anyone can send fake events",
2834
+ category: "security",
2835
+ severity: "critical",
2836
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2837
+ check(file, _project) {
2838
+ if (isTestFile(file.relativePath)) return [];
2839
+ if (!isApiRoute(file.relativePath)) return [];
2840
+ if (!WEBHOOK_PATH.test(file.relativePath)) return [];
2841
+ const hasVerification = VERIFICATION_PATTERNS.some((p) => p.test(file.content));
2842
+ if (hasVerification) return [];
2843
+ let handlerLine = 1;
2844
+ for (let i = 0; i < file.lines.length; i++) {
2845
+ if (/export\s+(async\s+)?function\s+POST\b/.test(file.lines[i])) {
2846
+ handlerLine = i + 1;
2847
+ break;
2848
+ }
2849
+ }
2850
+ return [{
2851
+ ruleId: "missing-webhook-verification",
2852
+ file: file.relativePath,
2853
+ line: handlerLine,
2854
+ column: 1,
2855
+ message: "Webhook endpoint has no signature verification \u2014 anyone can forge events to this route",
2856
+ severity: "critical",
2857
+ category: "security",
2858
+ fix: "Verify the webhook signature before processing. For Stripe: stripe.webhooks.constructEvent(body, sig, secret)"
2859
+ }];
2860
+ }
2861
+ };
2862
+
2863
+ // src/rules/server-action-auth.ts
2864
+ var USE_SERVER3 = /['"]use server['"]/;
2865
+ var AUTH_PATTERNS2 = [
2866
+ /getServerSession\s*\(/,
2867
+ /getSession\s*\(/,
2868
+ /\.auth\.getUser\s*\(/,
2869
+ /auth\(\)/,
2870
+ /authenticate\s*\(/,
2871
+ /isAuthenticated/,
2872
+ /requireAuth/,
2873
+ /withAuth/,
2874
+ /getToken\s*\(/,
2875
+ /verifyToken\s*\(/,
2876
+ /jwt\.verify\s*\(/,
2877
+ /createServerComponentClient/,
2878
+ /currentUser\s*\(/,
2879
+ /getAuth\s*\(/,
2880
+ /cookies\(\).*auth/s,
2881
+ /session/i
2882
+ ];
2883
+ var PUBLIC_ACTION_NAMES = [
2884
+ /contact/i,
2885
+ /subscribe/i,
2886
+ /newsletter/i,
2887
+ /feedback/i,
2888
+ /signup/i,
2889
+ /login/i,
2890
+ /register/i
2891
+ ];
2892
+ var serverActionAuthRule = {
2893
+ id: "server-action-auth",
2894
+ name: "Server Action Without Auth",
2895
+ description: "Detects server actions that perform mutations without any authentication check",
2896
+ category: "security",
2897
+ severity: "warning",
2898
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2899
+ check(file, project) {
2900
+ if (isTestFile(file.relativePath)) return [];
2901
+ if (!USE_SERVER3.test(file.content)) return [];
2902
+ if (project.hasAuthMiddleware) return [];
2903
+ for (const p of PUBLIC_ACTION_NAMES) {
2904
+ if (p.test(file.relativePath)) return [];
2905
+ }
2906
+ const hasAuth = AUTH_PATTERNS2.some((p) => p.test(file.content));
2907
+ if (hasAuth) return [];
2908
+ 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);
2909
+ if (!hasMutation) return [];
2910
+ let reportLine = 1;
2911
+ for (let i = 0; i < file.lines.length; i++) {
2912
+ if (USE_SERVER3.test(file.lines[i])) {
2913
+ reportLine = i + 1;
2914
+ break;
2915
+ }
2916
+ }
2917
+ return [{
2918
+ ruleId: "server-action-auth",
2919
+ file: file.relativePath,
2920
+ line: reportLine,
2921
+ column: 1,
2922
+ message: "Server action performs mutations without any authentication check \u2014 anyone can call this action",
2923
+ severity: "warning",
2924
+ category: "security",
2925
+ fix: 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
2926
+ }];
2927
+ }
2928
+ };
2929
+
2930
+ // src/rules/eval-injection.ts
2931
+ var EVAL_PATTERNS = [
2932
+ { pattern: /\beval\s*\(/, msg: "eval() executes arbitrary code \u2014 never use with dynamic input" },
2933
+ { pattern: /\bnew\s+Function\s*\(/, msg: "new Function() is equivalent to eval \u2014 avoid dynamic code execution" },
2934
+ { pattern: /\bsetTimeout\s*\(\s*['"`]/, msg: "setTimeout with a string argument is eval \u2014 pass a function instead" },
2935
+ { pattern: /\bsetInterval\s*\(\s*['"`]/, msg: "setInterval with a string argument is eval \u2014 pass a function instead" }
2936
+ ];
2937
+ var evalInjectionRule = {
2938
+ id: "eval-injection",
2939
+ name: "Eval / Code Injection",
2940
+ description: "Detects eval(), new Function(), and string arguments to setTimeout/setInterval",
2941
+ category: "security",
2942
+ severity: "critical",
2943
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
2944
+ check(file, _project) {
2945
+ if (isTestFile(file.relativePath)) return [];
2946
+ const findings = [];
2947
+ for (let i = 0; i < file.lines.length; i++) {
2948
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2949
+ const line = file.lines[i];
2950
+ for (const { pattern, msg } of EVAL_PATTERNS) {
2951
+ const match = pattern.exec(line);
2952
+ if (match) {
2953
+ findings.push({
2954
+ ruleId: "eval-injection",
2955
+ file: file.relativePath,
2956
+ line: i + 1,
2957
+ column: match.index + 1,
2958
+ message: msg,
2959
+ severity: "critical",
2960
+ category: "security"
2961
+ });
2962
+ break;
2963
+ }
2964
+ }
2965
+ }
2966
+ return findings;
2967
+ }
2968
+ };
2969
+
2970
+ // src/rules/missing-useeffect-cleanup.ts
2971
+ var NEEDS_CLEANUP = [
2972
+ /\bsetInterval\s*\(/,
2973
+ /\baddEventListener\s*\(/,
2974
+ /\.subscribe\s*\(/,
2975
+ /\.on\s*\(\s*['"`]/,
2976
+ /new\s+WebSocket\s*\(/,
2977
+ /new\s+EventSource\s*\(/,
2978
+ /new\s+IntersectionObserver\s*\(/,
2979
+ /new\s+ResizeObserver\s*\(/,
2980
+ /new\s+MutationObserver\s*\(/
2981
+ ];
2982
+ var missingUseEffectCleanupRule = {
2983
+ id: "missing-useeffect-cleanup",
2984
+ name: "Missing useEffect Cleanup",
2985
+ description: "Detects useEffect hooks with subscriptions or timers but no cleanup return function \u2014 causes memory leaks",
2986
+ category: "reliability",
2987
+ severity: "warning",
2988
+ fileExtensions: ["tsx", "jsx", "ts", "js"],
2989
+ check(file, _project) {
2990
+ if (isTestFile(file.relativePath)) return [];
2991
+ if (!isClientComponent(file.content)) return [];
2992
+ if (!/useEffect/.test(file.content)) return [];
2993
+ const findings = [];
2994
+ const lines = file.lines;
2995
+ for (let i = 0; i < lines.length; i++) {
2996
+ const line = lines[i];
2997
+ if (!/\buseEffect\s*\(/.test(line)) continue;
2998
+ let braceDepth = 0;
2999
+ let effectStart = -1;
3000
+ let effectEnd = -1;
3001
+ let started = false;
3002
+ for (let j = i; j < lines.length && j < i + 100; j++) {
3003
+ for (const ch of lines[j]) {
3004
+ if (ch === "(") {
3005
+ if (!started) {
3006
+ started = true;
3007
+ }
3008
+ braceDepth++;
3009
+ } else if (ch === ")") {
3010
+ braceDepth--;
3011
+ if (started && braceDepth === 0) {
3012
+ effectEnd = j;
3013
+ break;
3014
+ }
3015
+ } else if (ch === "{" && effectStart === -1 && started) {
3016
+ effectStart = j;
3017
+ }
3018
+ }
3019
+ if (effectEnd !== -1) break;
3020
+ }
3021
+ if (effectStart === -1 || effectEnd === -1) continue;
3022
+ const effectBody = lines.slice(effectStart, effectEnd + 1).join("\n");
3023
+ const needsCleanup = NEEDS_CLEANUP.some((p) => p.test(effectBody));
3024
+ if (!needsCleanup) continue;
3025
+ const hasReturn = /return\s*(?:\(\s*\)\s*=>|function|\(\))/.test(effectBody) || /return\s*\(\s*\)\s*\{/.test(effectBody) || /return\s+\w+\s*;?\s*$/.test(effectBody);
3026
+ if (!hasReturn) {
3027
+ const hasCleanupReturn = /return\s+(?:\(\)|(?:\(\s*\)\s*=>)|(?:function))/.test(effectBody);
3028
+ if (!hasCleanupReturn) {
3029
+ findings.push({
3030
+ ruleId: "missing-useeffect-cleanup",
3031
+ file: file.relativePath,
3032
+ line: i + 1,
3033
+ column: 1,
3034
+ message: "useEffect with subscription/timer but no cleanup return \u2014 will leak memory on unmount",
3035
+ severity: "warning",
3036
+ category: "reliability",
3037
+ fix: "Return a cleanup function: useEffect(() => { const id = setInterval(...); return () => clearInterval(id); }, [])"
3038
+ });
3039
+ }
3040
+ }
3041
+ }
3042
+ return findings;
3043
+ }
3044
+ };
3045
+
3046
+ // src/rules/next-public-sensitive.ts
3047
+ var SENSITIVE_PATTERN = /NEXT_PUBLIC_\w*(SECRET|PRIVATE|PASSWORD|DATABASE_URL|SERVICE_ROLE|SERVICE_KEY|ADMIN_KEY|sk_live|sk_test|SIGNING|ENCRYPTION)/i;
3048
+ var nextPublicSensitiveRule = {
3049
+ id: "next-public-sensitive",
3050
+ name: "Sensitive Env Var with NEXT_PUBLIC_ Prefix",
3051
+ description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
3052
+ category: "security",
3053
+ severity: "critical",
3054
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env", "env.local", "env.production"],
3055
+ check(file, _project) {
3056
+ if (isTestFile(file.relativePath)) return [];
3057
+ const findings = [];
3058
+ for (let i = 0; i < file.lines.length; i++) {
3059
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3060
+ const line = file.lines[i];
3061
+ const match = SENSITIVE_PATTERN.exec(line);
3062
+ if (match) {
3063
+ findings.push({
3064
+ ruleId: "next-public-sensitive",
3065
+ file: file.relativePath,
3066
+ line: i + 1,
3067
+ column: match.index + 1,
3068
+ message: `NEXT_PUBLIC_ prefix on a sensitive env var \u2014 this value will be embedded in the client-side JavaScript bundle`,
3069
+ severity: "critical",
3070
+ category: "security",
3071
+ fix: "Remove the NEXT_PUBLIC_ prefix. Access this value only in server components, API routes, or server actions."
3072
+ });
3073
+ }
3074
+ }
3075
+ return findings;
3076
+ }
3077
+ };
3078
+
3079
+ // src/rules/ssrf-risk.ts
3080
+ var USER_INPUT_IN_FETCH = [
3081
+ /fetch\s*\(\s*(?:req|request)\.(?:body|query|params|nextUrl)/,
3082
+ /fetch\s*\(\s*(?:url|href|endpoint|target|link|src)\s*[,)]/,
3083
+ /fetch\s*\(\s*searchParams\.get\s*\(/,
3084
+ /fetch\s*\(\s*formData\.get\s*\(/,
3085
+ /new\s+URL\s*\(\s*(?:req|request)\.(?:body|query)/,
3086
+ /axios\s*[.(]\s*(?:req|request)\.(?:body|query)/,
3087
+ /axios\.get\s*\(\s*(?:url|href|endpoint|target|link)\s*[,)]/
3088
+ ];
3089
+ var VALIDATION_PATTERNS3 = [
3090
+ /allowlist/i,
3091
+ /allowedUrls/i,
3092
+ /allowedHosts/i,
3093
+ /allowedDomains/i,
3094
+ /whitelist/i,
3095
+ /validUrl/i,
3096
+ /validateUrl/i,
3097
+ /URL\.canParse/,
3098
+ /new\s+URL\s*\(.*\)\.host/,
3099
+ /\.startsWith\s*\(\s*['"]https?:\/\//,
3100
+ /\.hostname\s*[!=]==?\s*['"`]/
3101
+ ];
3102
+ var ssrfRiskRule = {
3103
+ id: "ssrf-risk",
3104
+ name: "SSRF Risk",
3105
+ description: "Detects fetch/HTTP calls with user-controlled URLs without validation \u2014 allows attackers to probe internal services",
3106
+ category: "security",
3107
+ severity: "warning",
3108
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
3109
+ check(file, _project) {
3110
+ if (isTestFile(file.relativePath)) return [];
3111
+ if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
3112
+ const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
3113
+ if (hasValidation) return [];
3114
+ const findings = [];
3115
+ for (let i = 0; i < file.lines.length; i++) {
3116
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3117
+ const line = file.lines[i];
3118
+ for (const pattern of USER_INPUT_IN_FETCH) {
3119
+ const match = pattern.exec(line);
3120
+ if (match) {
3121
+ findings.push({
3122
+ ruleId: "ssrf-risk",
3123
+ file: file.relativePath,
3124
+ line: i + 1,
3125
+ column: match.index + 1,
3126
+ message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
3127
+ severity: "warning",
3128
+ category: "security",
3129
+ fix: "Validate the URL against an allowlist of permitted domains before making the request"
3130
+ });
3131
+ break;
3132
+ }
3133
+ }
3134
+ }
3135
+ return findings;
3136
+ }
3137
+ };
3138
+
3139
+ // src/rules/path-traversal.ts
3140
+ var FS_WITH_USER_INPUT = [
3141
+ { pattern: /(?:readFile|readFileSync|createReadStream)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File read with user-controlled path \u2014 allows reading arbitrary files" },
3142
+ { pattern: /(?:readFile|readFileSync|createReadStream)\s*\(\s*(?:filePath|fileName|path|file|name)\s*[,)]/, msg: "File read with potentially user-controlled path \u2014 validate before use" },
3143
+ { pattern: /(?:writeFile|writeFileSync|createWriteStream)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File write with user-controlled path \u2014 allows writing arbitrary files" },
3144
+ { pattern: /(?:unlink|unlinkSync|rm|rmSync)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File delete with user-controlled path \u2014 allows deleting arbitrary files" },
3145
+ { pattern: /path\.join\s*\([^)]*(?:req|request)\.(?:query|body|params)/, msg: "path.join with user input \u2014 still vulnerable to traversal with ../" },
3146
+ { pattern: /\.sendFile\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "express.sendFile with user-controlled path \u2014 validate against a base directory" }
3147
+ ];
3148
+ var SANITIZATION_PATTERNS = [
3149
+ /path\.resolve\s*\(.*\)\.startsWith/,
3150
+ /\.replace\s*\(\s*['"]\.\.['"],?\s*['"].*['"]\s*\)/,
3151
+ /\.includes\s*\(\s*['"]\.\.['"].*\)/,
3152
+ /normalize/,
3153
+ /sanitize/i,
3154
+ /realpath/
3155
+ ];
3156
+ var pathTraversalRule = {
3157
+ id: "path-traversal",
3158
+ name: "Path Traversal",
3159
+ description: "Detects filesystem operations with user-controlled paths \u2014 allows reading/writing arbitrary files via ../ sequences",
3160
+ category: "security",
3161
+ severity: "critical",
3162
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
3163
+ check(file, _project) {
3164
+ if (isTestFile(file.relativePath)) return [];
3165
+ const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
3166
+ if (hasSanitization) return [];
3167
+ const findings = [];
3168
+ for (let i = 0; i < file.lines.length; i++) {
3169
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3170
+ const line = file.lines[i];
3171
+ for (const { pattern, msg } of FS_WITH_USER_INPUT) {
3172
+ const match = pattern.exec(line);
3173
+ if (match) {
3174
+ const severity = /req|request/.test(match[0]) ? "critical" : "warning";
3175
+ findings.push({
3176
+ ruleId: "path-traversal",
3177
+ file: file.relativePath,
3178
+ line: i + 1,
3179
+ column: match.index + 1,
3180
+ message: msg,
3181
+ severity,
3182
+ category: "security",
3183
+ fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
3184
+ });
3185
+ break;
3186
+ }
3187
+ }
3188
+ }
3189
+ return findings;
3190
+ }
3191
+ };
3192
+
3193
+ // src/rules/hydration-mismatch.ts
3194
+ var BROWSER_ONLY_PATTERNS = [
3195
+ { pattern: /\bwindow\./, msg: "window access in server-rendered code \u2014 will differ between server and client" },
3196
+ { pattern: /\bdocument\./, msg: "document access in server-rendered code \u2014 undefined on the server" },
3197
+ { pattern: /\blocalStorage\b/, msg: "localStorage in render path \u2014 undefined on server, causes hydration mismatch" },
3198
+ { pattern: /\bsessionStorage\b/, msg: "sessionStorage in render path \u2014 undefined on server, causes hydration mismatch" },
3199
+ { pattern: /\bnavigator\./, msg: "navigator access in render path \u2014 undefined on server" }
3200
+ ];
3201
+ var NONDETERMINISTIC_PATTERNS = [
3202
+ { pattern: /\bnew\s+Date\s*\(\s*\)/, msg: "new Date() in render path \u2014 server and client will have different timestamps, causing hydration mismatch" },
3203
+ { pattern: /\bDate\.now\s*\(\s*\)/, msg: "Date.now() in render path \u2014 different on server vs client" },
3204
+ { pattern: /\bMath\.random\s*\(\s*\)/, msg: "Math.random() in render path \u2014 produces different values on server vs client" }
3205
+ ];
3206
+ var hydrationMismatchRule = {
3207
+ id: "hydration-mismatch",
3208
+ name: "Hydration Mismatch Risk",
3209
+ description: "Detects browser-only APIs and non-deterministic calls in server component render paths",
3210
+ category: "reliability",
3211
+ severity: "warning",
3212
+ fileExtensions: ["tsx", "jsx", "ts", "js"],
3213
+ check(file, _project) {
3214
+ if (isTestFile(file.relativePath)) return [];
3215
+ if (isClientComponent(file.content)) return [];
3216
+ if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
3217
+ if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
3218
+ const findings = [];
3219
+ let insideUseEffect = false;
3220
+ for (let i = 0; i < file.lines.length; i++) {
3221
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3222
+ const line = file.lines[i];
3223
+ if (/\buseEffect\s*\(/.test(line)) {
3224
+ insideUseEffect = true;
3225
+ }
3226
+ if (insideUseEffect) {
3227
+ if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
3228
+ insideUseEffect = false;
3229
+ }
3230
+ continue;
3231
+ }
3232
+ for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
3233
+ const match = pattern.exec(line);
3234
+ if (match) {
3235
+ findings.push({
3236
+ ruleId: "hydration-mismatch",
3237
+ file: file.relativePath,
3238
+ line: i + 1,
3239
+ column: match.index + 1,
3240
+ message: msg,
3241
+ severity: "warning",
3242
+ category: "reliability",
3243
+ fix: 'Move this to a useEffect hook, or add "use client" if this component needs browser APIs'
3244
+ });
3245
+ break;
3246
+ }
3247
+ }
3248
+ }
3249
+ return findings;
3250
+ }
3251
+ };
3252
+
3253
+ // src/rules/server-component-fetch-self.ts
3254
+ var SELF_FETCH_PATTERNS = [
3255
+ /fetch\s*\(\s*['"`]\/api\//,
3256
+ /fetch\s*\(\s*['"`]http:\/\/localhost/,
3257
+ /fetch\s*\(\s*['"`]https?:\/\/localhost/,
3258
+ /fetch\s*\(\s*`\$\{.*\}\/api\//,
3259
+ /fetch\s*\(\s*(?:process\.env\.\w+\s*\+\s*)?['"`]\/api\//
3260
+ ];
3261
+ var serverComponentFetchSelfRule = {
3262
+ id: "server-component-fetch-self",
3263
+ name: "Server Component Fetching Own API",
3264
+ description: "Detects server components that fetch their own API routes instead of calling data logic directly \u2014 unnecessary network roundtrip",
3265
+ category: "performance",
3266
+ severity: "info",
3267
+ fileExtensions: ["tsx", "jsx", "ts", "js"],
3268
+ check(file, _project) {
3269
+ if (isTestFile(file.relativePath)) return [];
3270
+ if (isClientComponent(file.content)) return [];
3271
+ if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
3272
+ if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
3273
+ const findings = [];
3274
+ for (let i = 0; i < file.lines.length; i++) {
3275
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3276
+ const line = file.lines[i];
3277
+ for (const pattern of SELF_FETCH_PATTERNS) {
3278
+ const match = pattern.exec(line);
3279
+ if (match) {
3280
+ findings.push({
3281
+ ruleId: "server-component-fetch-self",
3282
+ file: file.relativePath,
3283
+ line: i + 1,
3284
+ column: match.index + 1,
3285
+ message: "Server component fetches its own API route \u2014 call the data logic directly instead of making a network request to yourself",
3286
+ severity: "info",
3287
+ category: "performance",
3288
+ fix: 'Import and call the data function directly instead of fetch("/api/...")'
3289
+ });
3290
+ break;
3291
+ }
3292
+ }
3293
+ }
3294
+ return findings;
3295
+ }
3296
+ };
3297
+
3298
+ // src/rules/unsafe-file-upload.ts
3299
+ var UPLOAD_PATTERNS = [
3300
+ /\.get\s*\(\s*['"`]file['"`]\s*\)/,
3301
+ /\.get\s*\(\s*['"`]image['"`]\s*\)/,
3302
+ /\.get\s*\(\s*['"`]upload['"`]\s*\)/,
3303
+ /\.get\s*\(\s*['"`]attachment['"`]\s*\)/,
3304
+ /\.get\s*\(\s*['"`]document['"`]\s*\)/,
3305
+ /\.get\s*\(\s*['"`]avatar['"`]\s*\)/,
3306
+ /\.get\s*\(\s*['"`]photo['"`]\s*\)/,
3307
+ /\.type\s*===?\s*['"`]file['"`]/,
3308
+ /req\.file\b/,
3309
+ /multer/i,
3310
+ /busboy/i,
3311
+ /formidable/i
3312
+ ];
3313
+ var VALIDATION_PATTERNS4 = [
3314
+ /\.type\b.*(?:image|video|audio|pdf|text)\//,
3315
+ /content-type/i,
3316
+ /mime/i,
3317
+ /\.size\s*[><!]/,
3318
+ /maxFileSize/i,
3319
+ /maxSize/i,
3320
+ /fileSizeLimit/i,
3321
+ /allowedTypes/i,
3322
+ /acceptedTypes/i,
3323
+ /fileFilter/i,
3324
+ /\.endsWith\s*\(\s*['"`]\./,
3325
+ /\.extension/i
3326
+ ];
3327
+ var unsafeFileUploadRule = {
3328
+ id: "unsafe-file-upload",
3329
+ name: "Unsafe File Upload",
3330
+ description: "Detects file upload handlers without type or size validation",
3331
+ category: "security",
3332
+ severity: "warning",
3333
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
3334
+ check(file, _project) {
3335
+ if (isTestFile(file.relativePath)) return [];
3336
+ if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
3337
+ const hasUpload = UPLOAD_PATTERNS.some((p) => p.test(file.content));
3338
+ if (!hasUpload) return [];
3339
+ const hasValidation = VALIDATION_PATTERNS4.some((p) => p.test(file.content));
3340
+ if (hasValidation) return [];
3341
+ let reportLine = 1;
3342
+ for (let i = 0; i < file.lines.length; i++) {
3343
+ if (UPLOAD_PATTERNS.some((p) => p.test(file.lines[i]))) {
3344
+ reportLine = i + 1;
3345
+ break;
3346
+ }
3347
+ }
3348
+ return [{
3349
+ ruleId: "unsafe-file-upload",
3350
+ file: file.relativePath,
3351
+ line: reportLine,
3352
+ column: 1,
3353
+ message: "File upload without type or size validation \u2014 accepts any file type and size",
3354
+ severity: "warning",
3355
+ category: "security",
3356
+ fix: "Validate file type (check MIME type, not just extension) and enforce a size limit before processing"
3357
+ }];
3358
+ }
3359
+ };
3360
+
3361
+ // src/rules/supabase-missing-rls.ts
3362
+ var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
3363
+ var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
3364
+ var supabaseMissingRlsRule = {
3365
+ id: "supabase-missing-rls",
3366
+ name: "Missing Row-Level Security",
3367
+ description: "Detects SQL migrations that create tables without enabling Row-Level Security \u2014 all data is publicly accessible",
3368
+ category: "security",
3369
+ severity: "critical",
3370
+ fileExtensions: ["sql"],
3371
+ check(file, project) {
3372
+ if (isTestFile(file.relativePath)) return [];
3373
+ if (!/migration|supabase|schema/i.test(file.relativePath)) return [];
3374
+ const content = file.content;
3375
+ const findings = [];
3376
+ const tables = [];
3377
+ CREATE_TABLE.lastIndex = 0;
3378
+ let match;
3379
+ while ((match = CREATE_TABLE.exec(content)) !== null) {
3380
+ const name = match[1];
3381
+ if (name.startsWith("_") || name === "schema_migrations") continue;
3382
+ const beforeMatch = content.slice(0, match.index);
3383
+ const line = beforeMatch.split("\n").length;
3384
+ tables.push({ name, line });
3385
+ }
3386
+ if (tables.length === 0) return [];
3387
+ const rlsTables = /* @__PURE__ */ new Set();
3388
+ ENABLE_RLS.lastIndex = 0;
3389
+ while ((match = ENABLE_RLS.exec(content)) !== null) {
3390
+ rlsTables.add(match[1].toLowerCase());
3391
+ }
3392
+ for (const filePath of project.allFiles) {
3393
+ if (!filePath.endsWith(".sql")) continue;
3394
+ if (filePath === file.relativePath) continue;
3395
+ }
3396
+ for (const table of tables) {
3397
+ if (!rlsTables.has(table.name.toLowerCase())) {
3398
+ findings.push({
3399
+ ruleId: "supabase-missing-rls",
3400
+ file: file.relativePath,
3401
+ line: table.line,
3402
+ column: 1,
3403
+ message: `Table "${table.name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
3404
+ severity: "critical",
3405
+ category: "security",
3406
+ fix: `Add: ALTER TABLE ${table.name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
3407
+ });
3408
+ }
3409
+ }
3410
+ return findings;
3411
+ }
3412
+ };
3413
+
3414
+ // src/rules/deprecated-oauth-flow.ts
3415
+ var IMPLICIT_GRANT = /response_type\s*[=:]\s*['"`]?token['"`]?/;
3416
+ var deprecatedOauthFlowRule = {
3417
+ id: "deprecated-oauth-flow",
3418
+ name: "Deprecated OAuth Flow",
3419
+ description: "Detects OAuth Implicit Grant flow (response_type=token) \u2014 deprecated in OAuth 2.1, vulnerable to token interception",
3420
+ category: "security",
3421
+ severity: "warning",
3422
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
3423
+ check(file, _project) {
3424
+ if (isTestFile(file.relativePath)) return [];
3425
+ const findings = [];
3426
+ for (let i = 0; i < file.lines.length; i++) {
3427
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3428
+ const line = file.lines[i];
3429
+ const match = IMPLICIT_GRANT.exec(line);
3430
+ if (match) {
3431
+ findings.push({
3432
+ ruleId: "deprecated-oauth-flow",
3433
+ file: file.relativePath,
3434
+ line: i + 1,
3435
+ column: match.index + 1,
3436
+ message: "OAuth Implicit Grant flow (response_type=token) is deprecated \u2014 tokens are exposed in the URL fragment",
3437
+ severity: "warning",
3438
+ category: "security",
3439
+ fix: "Use Authorization Code flow with PKCE: response_type=code with code_challenge and code_verifier"
3440
+ });
3441
+ }
3442
+ }
3443
+ return findings;
3444
+ }
3445
+ };
3446
+
3447
+ // src/rules/jwt-no-expiry.ts
3448
+ var JWT_SIGN = /jwt\.sign\s*\(/;
3449
+ var HAS_EXPIRY = [
3450
+ /expiresIn/,
3451
+ /exp\s*:/,
3452
+ /expirationTime/,
3453
+ /maxAge/
3454
+ ];
3455
+ var jwtNoExpiryRule = {
3456
+ id: "jwt-no-expiry",
3457
+ name: "JWT Without Expiration",
3458
+ description: "Detects jwt.sign() calls without an expiresIn option \u2014 tokens never expire, compromised tokens are valid forever",
3459
+ category: "security",
3460
+ severity: "warning",
3461
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
3462
+ check(file, _project) {
3463
+ if (isTestFile(file.relativePath)) return [];
3464
+ if (!JWT_SIGN.test(file.content)) return [];
3465
+ const findings = [];
3466
+ for (let i = 0; i < file.lines.length; i++) {
3467
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3468
+ const line = file.lines[i];
3469
+ const match = JWT_SIGN.exec(line);
3470
+ if (!match) continue;
3471
+ const context = file.lines.slice(i, i + 6).join("\n");
3472
+ const hasExpiry = HAS_EXPIRY.some((p) => p.test(context));
3473
+ if (!hasExpiry) {
3474
+ findings.push({
3475
+ ruleId: "jwt-no-expiry",
3476
+ file: file.relativePath,
3477
+ line: i + 1,
3478
+ column: match.index + 1,
3479
+ message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
3480
+ severity: "warning",
3481
+ category: "security",
3482
+ fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
3483
+ });
3484
+ }
3485
+ }
3486
+ return findings;
3487
+ }
3488
+ };
3489
+
3490
+ // src/rules/client-side-auth-only.ts
3491
+ var CLIENT_AUTH_PATTERNS = [
3492
+ /localStorage\.(?:get|set)Item\s*\(\s*['"`](?:token|auth|session|jwt|access_token|user)['"`]/,
3493
+ /sessionStorage\.(?:get|set)Item\s*\(\s*['"`](?:token|auth|session|jwt|access_token|user)['"`]/
3494
+ ];
3495
+ var PASSWORD_CHECK = /(?:password|passwd)\s*[!=]==?\s*['"`]/;
3496
+ var clientSideAuthOnlyRule = {
3497
+ id: "client-side-auth-only",
3498
+ name: "Client-Side Auth Only",
3499
+ description: "Detects authentication logic implemented only in client-side code \u2014 easily bypassed via browser DevTools",
3500
+ category: "security",
3501
+ severity: "critical",
3502
+ fileExtensions: ["tsx", "jsx", "ts", "js"],
3503
+ check(file, _project) {
3504
+ if (isTestFile(file.relativePath)) return [];
3505
+ if (!isClientComponent(file.content)) return [];
3506
+ const findings = [];
3507
+ for (let i = 0; i < file.lines.length; i++) {
3508
+ const line = file.lines[i];
3509
+ const match = PASSWORD_CHECK.exec(line);
3510
+ if (match) {
3511
+ findings.push({
3512
+ ruleId: "client-side-auth-only",
3513
+ file: file.relativePath,
3514
+ line: i + 1,
3515
+ column: match.index + 1,
3516
+ message: "Password comparison in client-side code \u2014 the password is visible in the JavaScript bundle",
3517
+ severity: "critical",
3518
+ category: "security",
3519
+ fix: "Move authentication logic to a server action or API route"
3520
+ });
3521
+ }
3522
+ }
3523
+ for (let i = 0; i < file.lines.length; i++) {
3524
+ const line = file.lines[i];
3525
+ for (const pattern of CLIENT_AUTH_PATTERNS) {
3526
+ const match = pattern.exec(line);
3527
+ if (match) {
3528
+ findings.push({
3529
+ ruleId: "client-side-auth-only",
3530
+ file: file.relativePath,
3531
+ line: i + 1,
3532
+ column: match.index + 1,
3533
+ message: "Auth token in localStorage \u2014 accessible to any script on the page (XSS risk). Use httpOnly cookies instead.",
3534
+ severity: "warning",
3535
+ category: "security",
3536
+ fix: "Store auth tokens in httpOnly cookies set by the server, not in localStorage"
3537
+ });
3538
+ break;
3539
+ }
3540
+ }
3541
+ }
3542
+ return findings;
3543
+ }
3544
+ };
3545
+
3546
+ // src/rules/missing-abort-controller.ts
3547
+ var FETCH_CALL = /\bfetch\s*\(/;
3548
+ var HAS_TIMEOUT = [
3549
+ /AbortController/,
3550
+ /abort/i,
3551
+ /signal\s*:/,
3552
+ /timeout/i,
3553
+ /setTimeout.*abort/s
3554
+ ];
3555
+ var missingAbortControllerRule = {
3556
+ id: "missing-abort-controller",
3557
+ name: "Missing Abort Controller",
3558
+ description: "Detects fetch calls in API routes without timeout or AbortController \u2014 requests can hang indefinitely",
3559
+ category: "performance",
3560
+ severity: "info",
3561
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
3562
+ check(file, _project) {
3563
+ if (isTestFile(file.relativePath)) return [];
3564
+ if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
3565
+ if (!FETCH_CALL.test(file.content)) return [];
3566
+ const hasTimeout = HAS_TIMEOUT.some((p) => p.test(file.content));
3567
+ if (hasTimeout) return [];
3568
+ let reportLine = 1;
3569
+ for (let i = 0; i < file.lines.length; i++) {
3570
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
3571
+ if (FETCH_CALL.test(file.lines[i])) {
3572
+ reportLine = i + 1;
3573
+ break;
3574
+ }
3575
+ }
3576
+ return [{
3577
+ ruleId: "missing-abort-controller",
3578
+ file: file.relativePath,
3579
+ line: reportLine,
3580
+ column: 1,
3581
+ message: "fetch() without timeout or AbortController \u2014 request will hang indefinitely if the upstream server doesn't respond",
3582
+ severity: "info",
3583
+ category: "performance",
3584
+ fix: "Add a timeout: const controller = new AbortController(); setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal })"
3585
+ }];
3586
+ }
3587
+ };
3588
+
2002
3589
  // src/rules/index.ts
2003
3590
  var rules = [
2004
3591
  // Security
@@ -2012,6 +3599,23 @@ var rules = [
2012
3599
  openRedirectRule,
2013
3600
  rateLimitingRule,
2014
3601
  phantomDependencyRule,
3602
+ insecureCookieRule,
3603
+ leakedEnvInLogsRule,
3604
+ insecureRandomRule,
3605
+ nextServerActionValidationRule,
3606
+ envFallbackSecretRule,
3607
+ verboseErrorResponseRule,
3608
+ missingWebhookVerificationRule,
3609
+ serverActionAuthRule,
3610
+ evalInjectionRule,
3611
+ nextPublicSensitiveRule,
3612
+ ssrfRiskRule,
3613
+ pathTraversalRule,
3614
+ unsafeFileUploadRule,
3615
+ supabaseMissingRlsRule,
3616
+ deprecatedOauthFlowRule,
3617
+ jwtNoExpiryRule,
3618
+ clientSideAuthOnlyRule,
2015
3619
  // Reliability
2016
3620
  hallucinatedImportsRule,
2017
3621
  errorHandlingRule,
@@ -2019,11 +3623,18 @@ var rules = [
2019
3623
  shallowCatchRule,
2020
3624
  missingLoadingStateRule,
2021
3625
  missingErrorBoundaryRule,
3626
+ missingTransactionRule,
3627
+ redirectInTryCatchRule,
3628
+ missingRevalidationRule,
3629
+ missingUseEffectCleanupRule,
3630
+ hydrationMismatchRule,
2022
3631
  // Performance
2023
3632
  noSyncFsRule,
2024
3633
  noNPlusOneRule,
2025
3634
  noUnboundedQueryRule,
2026
3635
  noDynamicImportLoopRule,
3636
+ serverComponentFetchSelfRule,
3637
+ missingAbortControllerRule,
2027
3638
  // AI Quality
2028
3639
  aiSmellsRule,
2029
3640
  placeholderContentRule,
@@ -2031,7 +3642,8 @@ var rules = [
2031
3642
  staleFallbackRule,
2032
3643
  comprehensionDebtRule,
2033
3644
  codebaseConsistencyRule,
2034
- deadExportsRule
3645
+ deadExportsRule,
3646
+ useClientOveruseRule
2035
3647
  ];
2036
3648
 
2037
3649
  // src/scanner.ts