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