prodlint 0.1.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/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/cli.js +1173 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +1034 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
|
|
6
|
+
// src/utils/file-walker.ts
|
|
7
|
+
import fg from "fast-glob";
|
|
8
|
+
import { readFile, stat } from "fs/promises";
|
|
9
|
+
import { resolve, extname } from "path";
|
|
10
|
+
|
|
11
|
+
// src/utils/patterns.ts
|
|
12
|
+
function isApiRoute(relativePath) {
|
|
13
|
+
if (/app\/.*route\.(ts|js|tsx|jsx)$/.test(relativePath)) return true;
|
|
14
|
+
if (/pages\/api\//.test(relativePath)) return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function isClientComponent(content) {
|
|
18
|
+
const firstLines = content.slice(0, 500);
|
|
19
|
+
return /^(['"])use client\1/m.test(firstLines);
|
|
20
|
+
}
|
|
21
|
+
function buildCommentMap(lines) {
|
|
22
|
+
const map = new Array(lines.length).fill(false);
|
|
23
|
+
let inBlock = false;
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const line = lines[i];
|
|
26
|
+
if (inBlock) {
|
|
27
|
+
map[i] = true;
|
|
28
|
+
if (line.includes("*/")) {
|
|
29
|
+
inBlock = false;
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (trimmed.startsWith("/*")) {
|
|
35
|
+
map[i] = true;
|
|
36
|
+
if (!trimmed.includes("*/")) {
|
|
37
|
+
inBlock = true;
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (trimmed.startsWith("*")) {
|
|
42
|
+
map[i] = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return map;
|
|
46
|
+
}
|
|
47
|
+
function isCommentLine(lines, lineIndex, commentMap) {
|
|
48
|
+
if (commentMap[lineIndex]) return true;
|
|
49
|
+
const trimmed = lines[lineIndex]?.trim() ?? "";
|
|
50
|
+
return trimmed.startsWith("//");
|
|
51
|
+
}
|
|
52
|
+
function isLineSuppressed(lines, lineIndex, ruleId) {
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("/*")) {
|
|
56
|
+
const match = trimmed.match(/prodlint-disable\s+(.+)/);
|
|
57
|
+
if (match) {
|
|
58
|
+
const ids = match[1].split(/[\s,]+/).filter(Boolean);
|
|
59
|
+
if (ids.includes(ruleId)) return true;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (lineIndex > 0) {
|
|
66
|
+
const prevLine = lines[lineIndex - 1].trim();
|
|
67
|
+
const match = prevLine.match(/prodlint-disable-next-line\s+(.+)/);
|
|
68
|
+
if (match) {
|
|
69
|
+
const ids = match[1].split(/[\s,]+/).filter(Boolean);
|
|
70
|
+
if (ids.includes(ruleId)) return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
var NODE_BUILTINS = /* @__PURE__ */ new Set([
|
|
76
|
+
"assert",
|
|
77
|
+
"async_hooks",
|
|
78
|
+
"buffer",
|
|
79
|
+
"child_process",
|
|
80
|
+
"cluster",
|
|
81
|
+
"console",
|
|
82
|
+
"constants",
|
|
83
|
+
"crypto",
|
|
84
|
+
"dgram",
|
|
85
|
+
"diagnostics_channel",
|
|
86
|
+
"dns",
|
|
87
|
+
"domain",
|
|
88
|
+
"events",
|
|
89
|
+
"fs",
|
|
90
|
+
"http",
|
|
91
|
+
"http2",
|
|
92
|
+
"https",
|
|
93
|
+
"inspector",
|
|
94
|
+
"module",
|
|
95
|
+
"net",
|
|
96
|
+
"os",
|
|
97
|
+
"path",
|
|
98
|
+
"perf_hooks",
|
|
99
|
+
"process",
|
|
100
|
+
"punycode",
|
|
101
|
+
"querystring",
|
|
102
|
+
"readline",
|
|
103
|
+
"repl",
|
|
104
|
+
"stream",
|
|
105
|
+
"string_decoder",
|
|
106
|
+
"sys",
|
|
107
|
+
"timers",
|
|
108
|
+
"tls",
|
|
109
|
+
"trace_events",
|
|
110
|
+
"tty",
|
|
111
|
+
"url",
|
|
112
|
+
"util",
|
|
113
|
+
"v8",
|
|
114
|
+
"vm",
|
|
115
|
+
"wasi",
|
|
116
|
+
"worker_threads",
|
|
117
|
+
"zlib"
|
|
118
|
+
// node: prefixed are handled separately
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
// src/utils/file-walker.ts
|
|
122
|
+
var DEFAULT_IGNORES = [
|
|
123
|
+
"**/node_modules/**",
|
|
124
|
+
"**/dist/**",
|
|
125
|
+
"**/build/**",
|
|
126
|
+
"**/.next/**",
|
|
127
|
+
"**/.git/**",
|
|
128
|
+
"**/coverage/**",
|
|
129
|
+
"**/*.min.js",
|
|
130
|
+
"**/*.min.css",
|
|
131
|
+
"**/package-lock.json",
|
|
132
|
+
"**/yarn.lock",
|
|
133
|
+
"**/pnpm-lock.yaml",
|
|
134
|
+
"**/bun.lockb",
|
|
135
|
+
"**/*.map",
|
|
136
|
+
"**/*.d.ts"
|
|
137
|
+
];
|
|
138
|
+
var SCAN_EXTENSIONS = [
|
|
139
|
+
"ts",
|
|
140
|
+
"tsx",
|
|
141
|
+
"js",
|
|
142
|
+
"jsx",
|
|
143
|
+
"mjs",
|
|
144
|
+
"cjs",
|
|
145
|
+
"json"
|
|
146
|
+
];
|
|
147
|
+
var MAX_FILE_SIZE = 1024 * 1024;
|
|
148
|
+
async function walkFiles(root, extraIgnores = []) {
|
|
149
|
+
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
150
|
+
patterns.push("**/.env", "**/.env.*");
|
|
151
|
+
patterns.push("**/.gitignore");
|
|
152
|
+
const files = await fg(patterns, {
|
|
153
|
+
cwd: root,
|
|
154
|
+
ignore: [...DEFAULT_IGNORES, ...extraIgnores],
|
|
155
|
+
absolute: false,
|
|
156
|
+
dot: true
|
|
157
|
+
});
|
|
158
|
+
return files.sort();
|
|
159
|
+
}
|
|
160
|
+
async function readFileContext(root, relativePath) {
|
|
161
|
+
try {
|
|
162
|
+
const absolutePath = resolve(root, relativePath);
|
|
163
|
+
const fileStats = await stat(absolutePath);
|
|
164
|
+
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
165
|
+
const content = await readFile(absolutePath, "utf-8");
|
|
166
|
+
const lines = content.split("\n");
|
|
167
|
+
return {
|
|
168
|
+
absolutePath,
|
|
169
|
+
relativePath,
|
|
170
|
+
content,
|
|
171
|
+
lines,
|
|
172
|
+
ext: extname(relativePath).slice(1),
|
|
173
|
+
// remove leading dot
|
|
174
|
+
commentMap: buildCommentMap(lines)
|
|
175
|
+
};
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function buildProjectContext(root, files) {
|
|
181
|
+
let packageJson = null;
|
|
182
|
+
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
183
|
+
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
184
|
+
let hasAuthMiddleware = false;
|
|
185
|
+
let gitignoreContent = null;
|
|
186
|
+
let envInGitignore = false;
|
|
187
|
+
try {
|
|
188
|
+
const raw = await readFile(resolve(root, "package.json"), "utf-8");
|
|
189
|
+
packageJson = JSON.parse(raw);
|
|
190
|
+
const deps = {
|
|
191
|
+
...packageJson?.dependencies ?? {},
|
|
192
|
+
...packageJson?.devDependencies ?? {},
|
|
193
|
+
...packageJson?.peerDependencies ?? {}
|
|
194
|
+
};
|
|
195
|
+
declaredDependencies = new Set(Object.keys(deps));
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const raw = await readFile(resolve(root, "tsconfig.json"), "utf-8");
|
|
200
|
+
const stripped = raw.replace(/\/\/.*$/gm, "");
|
|
201
|
+
const tsconfig = JSON.parse(stripped);
|
|
202
|
+
const paths = tsconfig?.compilerOptions?.paths;
|
|
203
|
+
if (paths) {
|
|
204
|
+
for (const alias of Object.keys(paths)) {
|
|
205
|
+
const prefix = alias.replace(/\/?\*$/, "");
|
|
206
|
+
if (prefix) tsconfigPaths.add(prefix);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
|
|
213
|
+
try {
|
|
214
|
+
const content = await readFile(resolve(root, name), "utf-8");
|
|
215
|
+
const authPatterns = [
|
|
216
|
+
/getSession/i,
|
|
217
|
+
/getUser/i,
|
|
218
|
+
/auth\(\)/,
|
|
219
|
+
/withAuth/i,
|
|
220
|
+
/clerkMiddleware/i,
|
|
221
|
+
/authMiddleware/i,
|
|
222
|
+
/NextAuth/i,
|
|
223
|
+
/supabase.*auth/i,
|
|
224
|
+
/createMiddlewareClient/i,
|
|
225
|
+
/getToken/i,
|
|
226
|
+
/verifyToken/i,
|
|
227
|
+
/jwt/i,
|
|
228
|
+
/updateSession/i
|
|
229
|
+
];
|
|
230
|
+
if (authPatterns.some((p) => p.test(content))) {
|
|
231
|
+
hasAuthMiddleware = true;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
241
|
+
envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
root,
|
|
246
|
+
packageJson,
|
|
247
|
+
declaredDependencies,
|
|
248
|
+
tsconfigPaths,
|
|
249
|
+
hasAuthMiddleware,
|
|
250
|
+
gitignoreContent,
|
|
251
|
+
envInGitignore,
|
|
252
|
+
allFiles: files
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/utils/version.ts
|
|
257
|
+
import { readFileSync } from "fs";
|
|
258
|
+
import { fileURLToPath } from "url";
|
|
259
|
+
import { dirname, resolve as resolve2 } from "path";
|
|
260
|
+
function getVersion() {
|
|
261
|
+
try {
|
|
262
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
263
|
+
const pkg = JSON.parse(
|
|
264
|
+
readFileSync(resolve2(dir, "..", "package.json"), "utf-8")
|
|
265
|
+
);
|
|
266
|
+
return pkg.version ?? "0.0.0";
|
|
267
|
+
} catch {
|
|
268
|
+
return "0.0.0";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/scorer.ts
|
|
273
|
+
var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
|
|
274
|
+
var DEDUCTIONS = {
|
|
275
|
+
critical: 10,
|
|
276
|
+
warning: 3,
|
|
277
|
+
info: 1
|
|
278
|
+
};
|
|
279
|
+
function calculateScores(findings) {
|
|
280
|
+
const categoryScores = CATEGORIES.map((category) => {
|
|
281
|
+
const categoryFindings = findings.filter((f) => f.category === category);
|
|
282
|
+
let score = 100;
|
|
283
|
+
for (const f of categoryFindings) {
|
|
284
|
+
score -= DEDUCTIONS[f.severity] ?? 0;
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
category,
|
|
288
|
+
score: Math.max(0, score),
|
|
289
|
+
findingCount: categoryFindings.length
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
const overallScore = Math.round(
|
|
293
|
+
categoryScores.reduce((sum, c) => sum + c.score, 0) / CATEGORIES.length
|
|
294
|
+
);
|
|
295
|
+
return { overallScore, categoryScores };
|
|
296
|
+
}
|
|
297
|
+
function summarizeFindings(findings) {
|
|
298
|
+
return {
|
|
299
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
300
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
301
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/rules/secrets.ts
|
|
306
|
+
var SECRET_PATTERNS = [
|
|
307
|
+
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/ },
|
|
308
|
+
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
|
|
309
|
+
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
310
|
+
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
311
|
+
{ name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
|
|
312
|
+
{ name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
313
|
+
{ name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
|
|
314
|
+
{ name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
|
|
315
|
+
{ name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
|
|
316
|
+
{ name: "SendGrid API key", pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/ }
|
|
317
|
+
];
|
|
318
|
+
var secretsRule = {
|
|
319
|
+
id: "secrets",
|
|
320
|
+
name: "Hardcoded Secrets",
|
|
321
|
+
description: "Detects hardcoded API keys, tokens, and credentials in source code",
|
|
322
|
+
category: "security",
|
|
323
|
+
severity: "critical",
|
|
324
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs", "json"],
|
|
325
|
+
check(file, _project) {
|
|
326
|
+
const findings = [];
|
|
327
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
328
|
+
const line = file.lines[i];
|
|
329
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
330
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
331
|
+
const match = pattern.exec(line);
|
|
332
|
+
if (match) {
|
|
333
|
+
findings.push({
|
|
334
|
+
ruleId: "secrets",
|
|
335
|
+
file: file.relativePath,
|
|
336
|
+
line: i + 1,
|
|
337
|
+
column: match.index + 1,
|
|
338
|
+
message: `Hardcoded ${name} detected`,
|
|
339
|
+
severity: "critical",
|
|
340
|
+
category: "security"
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return findings;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// src/rules/hallucinated-imports.ts
|
|
350
|
+
var IMPLICIT_PACKAGES = /* @__PURE__ */ new Set([
|
|
351
|
+
"react",
|
|
352
|
+
"react-dom",
|
|
353
|
+
"react/jsx-runtime",
|
|
354
|
+
"react/jsx-dev-runtime",
|
|
355
|
+
"next",
|
|
356
|
+
"next/server",
|
|
357
|
+
"next/image",
|
|
358
|
+
"next/link",
|
|
359
|
+
"next/font",
|
|
360
|
+
"next/navigation",
|
|
361
|
+
"next/headers",
|
|
362
|
+
"next/dynamic",
|
|
363
|
+
"next/script",
|
|
364
|
+
"next/router",
|
|
365
|
+
"next/head",
|
|
366
|
+
"next/app",
|
|
367
|
+
"next/document"
|
|
368
|
+
]);
|
|
369
|
+
var LINE_IMPORT_PATTERNS = [
|
|
370
|
+
/from\s+['"]([^'"./][^'"]*)['"]/,
|
|
371
|
+
/require\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/,
|
|
372
|
+
/import\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/
|
|
373
|
+
];
|
|
374
|
+
function getPackageName(importPath) {
|
|
375
|
+
if (importPath.startsWith("@")) {
|
|
376
|
+
const parts = importPath.split("/");
|
|
377
|
+
return parts.slice(0, 2).join("/");
|
|
378
|
+
}
|
|
379
|
+
return importPath.split("/")[0];
|
|
380
|
+
}
|
|
381
|
+
function isNodeBuiltin(name) {
|
|
382
|
+
if (name.startsWith("node:")) return true;
|
|
383
|
+
return NODE_BUILTINS.has(name);
|
|
384
|
+
}
|
|
385
|
+
function isPathAlias(importPath, tsconfigPaths) {
|
|
386
|
+
if (importPath.startsWith("@/") || importPath === "@") return true;
|
|
387
|
+
if (importPath.startsWith("~/") || importPath.startsWith("#/")) return true;
|
|
388
|
+
for (const prefix of tsconfigPaths) {
|
|
389
|
+
if (importPath === prefix || importPath.startsWith(prefix + "/")) return true;
|
|
390
|
+
}
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
var hallucinatedImportsRule = {
|
|
394
|
+
id: "hallucinated-imports",
|
|
395
|
+
name: "Hallucinated Imports",
|
|
396
|
+
description: "Detects imports of packages not declared in package.json and not Node.js built-ins",
|
|
397
|
+
category: "reliability",
|
|
398
|
+
severity: "critical",
|
|
399
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
400
|
+
check(file, project) {
|
|
401
|
+
if (!project.packageJson) return [];
|
|
402
|
+
const findings = [];
|
|
403
|
+
const seen = /* @__PURE__ */ new Set();
|
|
404
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
405
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
406
|
+
const line = file.lines[i];
|
|
407
|
+
for (const pattern of LINE_IMPORT_PATTERNS) {
|
|
408
|
+
const match = pattern.exec(line);
|
|
409
|
+
if (!match) continue;
|
|
410
|
+
const importPath = match[1];
|
|
411
|
+
const pkgName = getPackageName(importPath);
|
|
412
|
+
if (seen.has(pkgName)) continue;
|
|
413
|
+
seen.add(pkgName);
|
|
414
|
+
if (isPathAlias(importPath, project.tsconfigPaths)) continue;
|
|
415
|
+
if (isNodeBuiltin(pkgName)) continue;
|
|
416
|
+
if (IMPLICIT_PACKAGES.has(importPath) || IMPLICIT_PACKAGES.has(pkgName)) continue;
|
|
417
|
+
if (project.declaredDependencies.has(pkgName)) continue;
|
|
418
|
+
findings.push({
|
|
419
|
+
ruleId: "hallucinated-imports",
|
|
420
|
+
file: file.relativePath,
|
|
421
|
+
line: i + 1,
|
|
422
|
+
column: match.index + 1,
|
|
423
|
+
message: `Package "${pkgName}" is imported but not in package.json`,
|
|
424
|
+
severity: "critical",
|
|
425
|
+
category: "reliability"
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return findings;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// src/rules/auth-checks.ts
|
|
434
|
+
var AUTH_EXEMPT_PATTERNS = [
|
|
435
|
+
/auth/i,
|
|
436
|
+
/login/i,
|
|
437
|
+
/signup/i,
|
|
438
|
+
/register/i,
|
|
439
|
+
/callback/i,
|
|
440
|
+
/webhook/i,
|
|
441
|
+
/health/i,
|
|
442
|
+
/ping/i,
|
|
443
|
+
/cron/i,
|
|
444
|
+
/inngest/i,
|
|
445
|
+
/stripe/i,
|
|
446
|
+
/public/i
|
|
447
|
+
];
|
|
448
|
+
var AUTH_PATTERNS = [
|
|
449
|
+
/getServerSession\s*\(/,
|
|
450
|
+
/getSession\s*\(/,
|
|
451
|
+
/\.auth\.getUser\s*\(/,
|
|
452
|
+
/auth\(\)/,
|
|
453
|
+
/authenticate\s*\(/,
|
|
454
|
+
/isAuthenticated/,
|
|
455
|
+
/requireAuth/,
|
|
456
|
+
/withAuth/,
|
|
457
|
+
/NextAuth/,
|
|
458
|
+
/getToken\s*\(/,
|
|
459
|
+
/verifyToken\s*\(/,
|
|
460
|
+
/jwt\.verify\s*\(/,
|
|
461
|
+
/createRouteHandlerClient/,
|
|
462
|
+
/createServerComponentClient/,
|
|
463
|
+
/authorization/i,
|
|
464
|
+
/bearer/i
|
|
465
|
+
];
|
|
466
|
+
var authChecksRule = {
|
|
467
|
+
id: "auth-checks",
|
|
468
|
+
name: "Missing Auth Checks",
|
|
469
|
+
description: "Detects API routes that lack authentication checks",
|
|
470
|
+
category: "security",
|
|
471
|
+
severity: "critical",
|
|
472
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
473
|
+
check(file, project) {
|
|
474
|
+
if (!isApiRoute(file.relativePath)) return [];
|
|
475
|
+
for (const pattern of AUTH_EXEMPT_PATTERNS) {
|
|
476
|
+
if (pattern.test(file.relativePath)) return [];
|
|
477
|
+
}
|
|
478
|
+
const severity = project.hasAuthMiddleware ? "info" : "critical";
|
|
479
|
+
for (const pattern of AUTH_PATTERNS) {
|
|
480
|
+
if (pattern.test(file.content)) return [];
|
|
481
|
+
}
|
|
482
|
+
let handlerLine = 1;
|
|
483
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
484
|
+
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
485
|
+
handlerLine = i + 1;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
|
|
490
|
+
return [{
|
|
491
|
+
ruleId: "auth-checks",
|
|
492
|
+
file: file.relativePath,
|
|
493
|
+
line: handlerLine,
|
|
494
|
+
column: 1,
|
|
495
|
+
message,
|
|
496
|
+
severity,
|
|
497
|
+
category: "security"
|
|
498
|
+
}];
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// src/rules/env-exposure.ts
|
|
503
|
+
var SERVER_ENV_PATTERN = /process\.env\.(?!NEXT_PUBLIC_)([A-Z][A-Z0-9_]*)/g;
|
|
504
|
+
var SENSITIVE_ENV_NAMES = /* @__PURE__ */ new Set([
|
|
505
|
+
"DATABASE_URL",
|
|
506
|
+
"SUPABASE_SERVICE_ROLE_KEY",
|
|
507
|
+
"STRIPE_SECRET_KEY",
|
|
508
|
+
"STRIPE_WEBHOOK_SECRET",
|
|
509
|
+
"OPENAI_API_KEY",
|
|
510
|
+
"ANTHROPIC_API_KEY",
|
|
511
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
512
|
+
"JWT_SECRET",
|
|
513
|
+
"SESSION_SECRET",
|
|
514
|
+
"REDIS_URL",
|
|
515
|
+
"SMTP_PASSWORD",
|
|
516
|
+
"SENDGRID_API_KEY"
|
|
517
|
+
]);
|
|
518
|
+
var envExposureRule = {
|
|
519
|
+
id: "env-exposure",
|
|
520
|
+
name: "Environment Variable Exposure",
|
|
521
|
+
description: "Detects server environment variables used in client components and .env files not in .gitignore",
|
|
522
|
+
category: "security",
|
|
523
|
+
severity: "critical",
|
|
524
|
+
fileExtensions: [],
|
|
525
|
+
check(file, project) {
|
|
526
|
+
const findings = [];
|
|
527
|
+
if (file.relativePath === ".gitignore") {
|
|
528
|
+
if (!project.envInGitignore) {
|
|
529
|
+
findings.push({
|
|
530
|
+
ruleId: "env-exposure",
|
|
531
|
+
file: file.relativePath,
|
|
532
|
+
line: 1,
|
|
533
|
+
column: 1,
|
|
534
|
+
message: ".env is not listed in .gitignore \u2014 secrets may be committed",
|
|
535
|
+
severity: "critical",
|
|
536
|
+
category: "security"
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return findings;
|
|
540
|
+
}
|
|
541
|
+
if (!["ts", "tsx", "js", "jsx"].includes(file.ext)) return findings;
|
|
542
|
+
if (!isClientComponent(file.content)) return findings;
|
|
543
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
544
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
545
|
+
const line = file.lines[i];
|
|
546
|
+
const regex = new RegExp(SERVER_ENV_PATTERN.source, SERVER_ENV_PATTERN.flags);
|
|
547
|
+
let match;
|
|
548
|
+
while ((match = regex.exec(line)) !== null) {
|
|
549
|
+
const envName = match[1];
|
|
550
|
+
const isSensitive = SENSITIVE_ENV_NAMES.has(envName);
|
|
551
|
+
findings.push({
|
|
552
|
+
ruleId: "env-exposure",
|
|
553
|
+
file: file.relativePath,
|
|
554
|
+
line: i + 1,
|
|
555
|
+
column: match.index + 1,
|
|
556
|
+
message: isSensitive ? `Sensitive server env var "${envName}" used in client component` : `Server env var "${envName}" used in client component (will be undefined at runtime)`,
|
|
557
|
+
severity: isSensitive ? "critical" : "warning",
|
|
558
|
+
category: "security"
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return findings;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// src/rules/error-handling.ts
|
|
567
|
+
var errorHandlingRule = {
|
|
568
|
+
id: "error-handling",
|
|
569
|
+
name: "Missing Error Handling",
|
|
570
|
+
description: "Detects API routes without try/catch and empty catch blocks",
|
|
571
|
+
category: "reliability",
|
|
572
|
+
severity: "warning",
|
|
573
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
574
|
+
check(file, _project) {
|
|
575
|
+
const findings = [];
|
|
576
|
+
if (isApiRoute(file.relativePath)) {
|
|
577
|
+
const hasTryCatch = /try\s*\{/.test(file.content);
|
|
578
|
+
if (!hasTryCatch) {
|
|
579
|
+
let handlerLine = 1;
|
|
580
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
581
|
+
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
582
|
+
handlerLine = i + 1;
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
findings.push({
|
|
587
|
+
ruleId: "error-handling",
|
|
588
|
+
file: file.relativePath,
|
|
589
|
+
line: handlerLine,
|
|
590
|
+
column: 1,
|
|
591
|
+
message: "API route handler has no try/catch block",
|
|
592
|
+
severity: "warning",
|
|
593
|
+
category: "reliability"
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
598
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
599
|
+
const line = file.lines[i];
|
|
600
|
+
if (/catch\s*(\([^)]*\))?\s*\{\s*\}/.test(line)) {
|
|
601
|
+
findings.push({
|
|
602
|
+
ruleId: "error-handling",
|
|
603
|
+
file: file.relativePath,
|
|
604
|
+
line: i + 1,
|
|
605
|
+
column: line.indexOf("catch") + 1,
|
|
606
|
+
message: "Empty catch block silently swallows errors",
|
|
607
|
+
severity: "warning",
|
|
608
|
+
category: "reliability"
|
|
609
|
+
});
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (/catch\s*(\([^)]*\))?\s*\{\s*$/.test(line)) {
|
|
613
|
+
const nextLine = file.lines[i + 1]?.trim();
|
|
614
|
+
if (nextLine === "}") {
|
|
615
|
+
findings.push({
|
|
616
|
+
ruleId: "error-handling",
|
|
617
|
+
file: file.relativePath,
|
|
618
|
+
line: i + 1,
|
|
619
|
+
column: line.indexOf("catch") + 1,
|
|
620
|
+
message: "Empty catch block silently swallows errors",
|
|
621
|
+
severity: "warning",
|
|
622
|
+
category: "reliability"
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return findings;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// src/rules/input-validation.ts
|
|
632
|
+
var VALIDATION_PATTERNS = [
|
|
633
|
+
/\.parse\s*\(/,
|
|
634
|
+
/\.safeParse\s*\(/,
|
|
635
|
+
/\.validate\s*\(/,
|
|
636
|
+
/\.validateSync\s*\(/,
|
|
637
|
+
/Joi\.object/,
|
|
638
|
+
/z\.object/,
|
|
639
|
+
/z\.string/,
|
|
640
|
+
/z\.number/,
|
|
641
|
+
/z\.array/,
|
|
642
|
+
/yup\.object/,
|
|
643
|
+
/ajv/i,
|
|
644
|
+
/typebox/i,
|
|
645
|
+
/valibot/i,
|
|
646
|
+
/typeof\s+.*body/
|
|
647
|
+
];
|
|
648
|
+
var BODY_ACCESS_PATTERNS = [
|
|
649
|
+
/req\.body/,
|
|
650
|
+
/request\.json\s*\(\)/,
|
|
651
|
+
/req\.json\s*\(\)/
|
|
652
|
+
];
|
|
653
|
+
var inputValidationRule = {
|
|
654
|
+
id: "input-validation",
|
|
655
|
+
name: "Missing Input Validation",
|
|
656
|
+
description: "Detects API routes that access request body without validation",
|
|
657
|
+
category: "security",
|
|
658
|
+
severity: "warning",
|
|
659
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
660
|
+
check(file, _project) {
|
|
661
|
+
if (!isApiRoute(file.relativePath)) return [];
|
|
662
|
+
const accessesBody = BODY_ACCESS_PATTERNS.some((p) => p.test(file.content));
|
|
663
|
+
if (!accessesBody) return [];
|
|
664
|
+
const hasValidation = VALIDATION_PATTERNS.some((p) => p.test(file.content));
|
|
665
|
+
if (hasValidation) return [];
|
|
666
|
+
let bodyLine = 1;
|
|
667
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
668
|
+
if (BODY_ACCESS_PATTERNS.some((p) => p.test(file.lines[i]))) {
|
|
669
|
+
bodyLine = i + 1;
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return [{
|
|
674
|
+
ruleId: "input-validation",
|
|
675
|
+
file: file.relativePath,
|
|
676
|
+
line: bodyLine,
|
|
677
|
+
column: 1,
|
|
678
|
+
message: "Request body accessed without validation (consider using Zod, Yup, or similar)",
|
|
679
|
+
severity: "warning",
|
|
680
|
+
category: "security"
|
|
681
|
+
}];
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
// src/rules/rate-limiting.ts
|
|
686
|
+
var RATE_LIMIT_PATTERNS = [
|
|
687
|
+
/rateLimit/i,
|
|
688
|
+
/rateLimiter/i,
|
|
689
|
+
/rate-limit/i,
|
|
690
|
+
/upstash.*ratelimit/i,
|
|
691
|
+
/Ratelimit/,
|
|
692
|
+
/@upstash\/ratelimit/,
|
|
693
|
+
/express-rate-limit/,
|
|
694
|
+
/limiter/i,
|
|
695
|
+
/throttle/i,
|
|
696
|
+
/slidingWindow/,
|
|
697
|
+
/fixedWindow/,
|
|
698
|
+
/tokenBucket/
|
|
699
|
+
];
|
|
700
|
+
var EXEMPT_PATTERNS = [
|
|
701
|
+
/health/i,
|
|
702
|
+
/ping/i,
|
|
703
|
+
/webhook/i,
|
|
704
|
+
/cron/i,
|
|
705
|
+
/inngest/i
|
|
706
|
+
];
|
|
707
|
+
var rateLimitingRule = {
|
|
708
|
+
id: "rate-limiting",
|
|
709
|
+
name: "Missing Rate Limiting",
|
|
710
|
+
description: "Detects API routes without rate limiting",
|
|
711
|
+
category: "security",
|
|
712
|
+
severity: "warning",
|
|
713
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
714
|
+
check(file, _project) {
|
|
715
|
+
if (!isApiRoute(file.relativePath)) return [];
|
|
716
|
+
for (const pattern of EXEMPT_PATTERNS) {
|
|
717
|
+
if (pattern.test(file.relativePath)) return [];
|
|
718
|
+
}
|
|
719
|
+
for (const pattern of RATE_LIMIT_PATTERNS) {
|
|
720
|
+
if (pattern.test(file.content)) return [];
|
|
721
|
+
}
|
|
722
|
+
let handlerLine = 1;
|
|
723
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
724
|
+
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
725
|
+
handlerLine = i + 1;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return [{
|
|
730
|
+
ruleId: "rate-limiting",
|
|
731
|
+
file: file.relativePath,
|
|
732
|
+
line: handlerLine,
|
|
733
|
+
column: 1,
|
|
734
|
+
message: "API route has no rate limiting",
|
|
735
|
+
severity: "warning",
|
|
736
|
+
category: "security"
|
|
737
|
+
}];
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// src/rules/cors-config.ts
|
|
742
|
+
var corsConfigRule = {
|
|
743
|
+
id: "cors-config",
|
|
744
|
+
name: "Permissive CORS",
|
|
745
|
+
description: "Detects overly permissive CORS configuration",
|
|
746
|
+
category: "security",
|
|
747
|
+
severity: "warning",
|
|
748
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
749
|
+
check(file, _project) {
|
|
750
|
+
const findings = [];
|
|
751
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
752
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
753
|
+
const line = file.lines[i];
|
|
754
|
+
if (/['"]Access-Control-Allow-Origin['"]\s*[,:]\s*['"]\*['"]/.test(line)) {
|
|
755
|
+
findings.push({
|
|
756
|
+
ruleId: "cors-config",
|
|
757
|
+
file: file.relativePath,
|
|
758
|
+
line: i + 1,
|
|
759
|
+
column: line.indexOf("Access-Control") + 1,
|
|
760
|
+
message: 'Access-Control-Allow-Origin set to "*" allows any domain',
|
|
761
|
+
severity: "warning",
|
|
762
|
+
category: "security"
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
if (/cors\(\s*\)/.test(line)) {
|
|
766
|
+
findings.push({
|
|
767
|
+
ruleId: "cors-config",
|
|
768
|
+
file: file.relativePath,
|
|
769
|
+
line: i + 1,
|
|
770
|
+
column: line.indexOf("cors(") + 1,
|
|
771
|
+
message: "cors() called without config allows all origins",
|
|
772
|
+
severity: "warning",
|
|
773
|
+
category: "security"
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (/origin\s*:\s*['"]\*['"]/.test(line)) {
|
|
777
|
+
findings.push({
|
|
778
|
+
ruleId: "cors-config",
|
|
779
|
+
file: file.relativePath,
|
|
780
|
+
line: i + 1,
|
|
781
|
+
column: line.indexOf("origin") + 1,
|
|
782
|
+
message: 'CORS origin set to "*" allows any domain',
|
|
783
|
+
severity: "warning",
|
|
784
|
+
category: "security"
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
if (/origin\s*:\s*true/.test(line)) {
|
|
788
|
+
findings.push({
|
|
789
|
+
ruleId: "cors-config",
|
|
790
|
+
file: file.relativePath,
|
|
791
|
+
line: i + 1,
|
|
792
|
+
column: line.indexOf("origin") + 1,
|
|
793
|
+
message: "CORS origin set to true mirrors any requesting origin",
|
|
794
|
+
severity: "warning",
|
|
795
|
+
category: "security"
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return findings;
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
// src/rules/ai-smells.ts
|
|
804
|
+
var CONSOLE_LOG_THRESHOLD = 5;
|
|
805
|
+
var ANY_TYPE_THRESHOLD = 5;
|
|
806
|
+
var COMMENTED_CODE_THRESHOLD = 3;
|
|
807
|
+
var aiSmellsRule = {
|
|
808
|
+
id: "ai-smells",
|
|
809
|
+
name: "AI Code Smells",
|
|
810
|
+
description: "Detects TODOs, placeholder functions, excessive console.log, any types, and commented-out code",
|
|
811
|
+
category: "ai-quality",
|
|
812
|
+
severity: "info",
|
|
813
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
814
|
+
check(file, _project) {
|
|
815
|
+
const findings = [];
|
|
816
|
+
let consoleLogCount = 0;
|
|
817
|
+
let anyTypeCount = 0;
|
|
818
|
+
let commentedCodeRun = 0;
|
|
819
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
820
|
+
const line = file.lines[i];
|
|
821
|
+
const trimmed = line.trim();
|
|
822
|
+
if (file.commentMap[i]) {
|
|
823
|
+
commentedCodeRun = 0;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (trimmed.startsWith("//")) {
|
|
827
|
+
const todoMatch = trimmed.match(/\/\/\s*(TODO|FIXME|HACK|XXX)\b(.*)/);
|
|
828
|
+
if (todoMatch) {
|
|
829
|
+
findings.push({
|
|
830
|
+
ruleId: "ai-smells",
|
|
831
|
+
file: file.relativePath,
|
|
832
|
+
line: i + 1,
|
|
833
|
+
column: line.indexOf(todoMatch[1]) + 1,
|
|
834
|
+
message: `${todoMatch[1]} comment: ${todoMatch[2].trim() || "(no description)"}`,
|
|
835
|
+
severity: "info",
|
|
836
|
+
category: "ai-quality"
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
const commentContent = trimmed.slice(2).trim();
|
|
840
|
+
const looksLikeCode = /^(import |export |const |let |var |function |if |for |while |return |await |async |class |switch )/.test(commentContent) || /[{};=]$/.test(commentContent) || /^\w+\(/.test(commentContent);
|
|
841
|
+
if (looksLikeCode) {
|
|
842
|
+
commentedCodeRun++;
|
|
843
|
+
if (commentedCodeRun === COMMENTED_CODE_THRESHOLD) {
|
|
844
|
+
findings.push({
|
|
845
|
+
ruleId: "ai-smells",
|
|
846
|
+
file: file.relativePath,
|
|
847
|
+
line: i + 1 - COMMENTED_CODE_THRESHOLD + 1,
|
|
848
|
+
column: 1,
|
|
849
|
+
message: `${COMMENTED_CODE_THRESHOLD}+ consecutive lines of commented-out code`,
|
|
850
|
+
severity: "info",
|
|
851
|
+
category: "ai-quality"
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
} else {
|
|
855
|
+
commentedCodeRun = 0;
|
|
856
|
+
}
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
commentedCodeRun = 0;
|
|
860
|
+
if (/(?:throw new Error|throw Error)\s*\(\s*['"]not implemented['"]/i.test(line)) {
|
|
861
|
+
findings.push({
|
|
862
|
+
ruleId: "ai-smells",
|
|
863
|
+
file: file.relativePath,
|
|
864
|
+
line: i + 1,
|
|
865
|
+
column: 1,
|
|
866
|
+
message: 'Placeholder "not implemented" function',
|
|
867
|
+
severity: "warning",
|
|
868
|
+
category: "ai-quality"
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
if (/console\.log\s*\(/.test(line)) {
|
|
872
|
+
consoleLogCount++;
|
|
873
|
+
}
|
|
874
|
+
if (/:\s*any\b/.test(line) || /\bas\s+any\b/.test(line) || /<any>/.test(line)) {
|
|
875
|
+
anyTypeCount++;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (consoleLogCount > CONSOLE_LOG_THRESHOLD) {
|
|
879
|
+
findings.push({
|
|
880
|
+
ruleId: "ai-smells",
|
|
881
|
+
file: file.relativePath,
|
|
882
|
+
line: 1,
|
|
883
|
+
column: 1,
|
|
884
|
+
message: `${consoleLogCount} console.log statements (consider a proper logger)`,
|
|
885
|
+
severity: "warning",
|
|
886
|
+
category: "ai-quality"
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
if (anyTypeCount > ANY_TYPE_THRESHOLD) {
|
|
890
|
+
findings.push({
|
|
891
|
+
ruleId: "ai-smells",
|
|
892
|
+
file: file.relativePath,
|
|
893
|
+
line: 1,
|
|
894
|
+
column: 1,
|
|
895
|
+
message: `${anyTypeCount} uses of "any" type (consider proper typing)`,
|
|
896
|
+
severity: "warning",
|
|
897
|
+
category: "ai-quality"
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return findings;
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
// src/rules/unsafe-html.ts
|
|
905
|
+
var unsafeHtmlRule = {
|
|
906
|
+
id: "unsafe-html",
|
|
907
|
+
name: "Unsafe HTML Rendering",
|
|
908
|
+
description: "Detects dangerouslySetInnerHTML and other XSS vectors in JSX/DOM code",
|
|
909
|
+
category: "security",
|
|
910
|
+
severity: "critical",
|
|
911
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
912
|
+
check(file, _project) {
|
|
913
|
+
const findings = [];
|
|
914
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
915
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
916
|
+
const line = file.lines[i];
|
|
917
|
+
if (/dangerouslySetInnerHTML\s*=/.test(line) || /dangerouslySetInnerHTML\s*:/.test(line)) {
|
|
918
|
+
findings.push({
|
|
919
|
+
ruleId: "unsafe-html",
|
|
920
|
+
file: file.relativePath,
|
|
921
|
+
line: i + 1,
|
|
922
|
+
column: line.indexOf("dangerouslySetInnerHTML") + 1,
|
|
923
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
924
|
+
severity: "critical",
|
|
925
|
+
category: "security"
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
if (/\w\.innerHTML\s*=/.test(line)) {
|
|
929
|
+
findings.push({
|
|
930
|
+
ruleId: "unsafe-html",
|
|
931
|
+
file: file.relativePath,
|
|
932
|
+
line: i + 1,
|
|
933
|
+
column: line.indexOf(".innerHTML") + 1,
|
|
934
|
+
message: "Direct innerHTML assignment is an XSS risk",
|
|
935
|
+
severity: "critical",
|
|
936
|
+
category: "security"
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return findings;
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// src/rules/sql-injection.ts
|
|
945
|
+
var SQL_INJECTION_PATTERNS = [
|
|
946
|
+
// Template literals with SQL keywords and interpolation
|
|
947
|
+
{ pattern: /`\s*(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^`]*\$\{/, message: "SQL query built with template literal interpolation \u2014 use parameterized queries" },
|
|
948
|
+
// String concatenation with SQL
|
|
949
|
+
{ pattern: /(?:SELECT|INSERT|UPDATE|DELETE|DROP)\b.*['"]?\s*\+\s*\w/, message: "SQL query built with string concatenation \u2014 use parameterized queries" },
|
|
950
|
+
// .query() or .execute() with template literal
|
|
951
|
+
{ pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
|
|
952
|
+
];
|
|
953
|
+
var sqlInjectionRule = {
|
|
954
|
+
id: "sql-injection",
|
|
955
|
+
name: "SQL Injection Risk",
|
|
956
|
+
description: "Detects SQL queries built with string interpolation or concatenation",
|
|
957
|
+
category: "security",
|
|
958
|
+
severity: "critical",
|
|
959
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
960
|
+
check(file, _project) {
|
|
961
|
+
const findings = [];
|
|
962
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
963
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
964
|
+
const line = file.lines[i];
|
|
965
|
+
for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
|
|
966
|
+
if (pattern.test(line)) {
|
|
967
|
+
findings.push({
|
|
968
|
+
ruleId: "sql-injection",
|
|
969
|
+
file: file.relativePath,
|
|
970
|
+
line: i + 1,
|
|
971
|
+
column: 1,
|
|
972
|
+
message,
|
|
973
|
+
severity: "critical",
|
|
974
|
+
category: "security"
|
|
975
|
+
});
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return findings;
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// src/rules/index.ts
|
|
985
|
+
var rules = [
|
|
986
|
+
secretsRule,
|
|
987
|
+
hallucinatedImportsRule,
|
|
988
|
+
authChecksRule,
|
|
989
|
+
envExposureRule,
|
|
990
|
+
errorHandlingRule,
|
|
991
|
+
inputValidationRule,
|
|
992
|
+
rateLimitingRule,
|
|
993
|
+
corsConfigRule,
|
|
994
|
+
aiSmellsRule,
|
|
995
|
+
unsafeHtmlRule,
|
|
996
|
+
sqlInjectionRule
|
|
997
|
+
];
|
|
998
|
+
|
|
999
|
+
// src/scanner.ts
|
|
1000
|
+
import { resolve as resolve3 } from "path";
|
|
1001
|
+
async function scan(options) {
|
|
1002
|
+
const start = performance.now();
|
|
1003
|
+
const root = resolve3(options.path);
|
|
1004
|
+
const filePaths = await walkFiles(root, options.ignore);
|
|
1005
|
+
const project = await buildProjectContext(root, filePaths);
|
|
1006
|
+
const findings = [];
|
|
1007
|
+
for (const relativePath of filePaths) {
|
|
1008
|
+
const file = await readFileContext(root, relativePath);
|
|
1009
|
+
if (!file) continue;
|
|
1010
|
+
for (const rule of rules) {
|
|
1011
|
+
if (rule.fileExtensions.length > 0 && !rule.fileExtensions.includes(file.ext)) {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const ruleFindings = rule.check(file, project);
|
|
1015
|
+
for (const finding of ruleFindings) {
|
|
1016
|
+
if (!isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
|
|
1017
|
+
findings.push(finding);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const { overallScore, categoryScores } = calculateScores(findings);
|
|
1023
|
+
const summary = summarizeFindings(findings);
|
|
1024
|
+
return {
|
|
1025
|
+
version: getVersion(),
|
|
1026
|
+
scannedPath: root,
|
|
1027
|
+
filesScanned: filePaths.length,
|
|
1028
|
+
scanDurationMs: Math.round(performance.now() - start),
|
|
1029
|
+
findings,
|
|
1030
|
+
overallScore,
|
|
1031
|
+
categoryScores,
|
|
1032
|
+
summary
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// src/reporter.ts
|
|
1037
|
+
import pc from "picocolors";
|
|
1038
|
+
var SEVERITY_COLORS = {
|
|
1039
|
+
critical: pc.red,
|
|
1040
|
+
warning: pc.yellow,
|
|
1041
|
+
info: pc.blue
|
|
1042
|
+
};
|
|
1043
|
+
var SEVERITY_LABELS = {
|
|
1044
|
+
critical: "CRIT",
|
|
1045
|
+
warning: "WARN",
|
|
1046
|
+
info: "INFO"
|
|
1047
|
+
};
|
|
1048
|
+
function scoreColor(score) {
|
|
1049
|
+
if (score >= 80) return pc.green;
|
|
1050
|
+
if (score >= 50) return pc.yellow;
|
|
1051
|
+
return pc.red;
|
|
1052
|
+
}
|
|
1053
|
+
function groupByFile(findings) {
|
|
1054
|
+
const map = /* @__PURE__ */ new Map();
|
|
1055
|
+
for (const f of findings) {
|
|
1056
|
+
const group = map.get(f.file) ?? [];
|
|
1057
|
+
group.push(f);
|
|
1058
|
+
map.set(f.file, group);
|
|
1059
|
+
}
|
|
1060
|
+
return map;
|
|
1061
|
+
}
|
|
1062
|
+
function reportPretty(result) {
|
|
1063
|
+
const lines = [];
|
|
1064
|
+
lines.push("");
|
|
1065
|
+
lines.push(pc.bold(" prodlint") + pc.dim(` v${result.version}`));
|
|
1066
|
+
lines.push(pc.dim(` Scanned ${result.filesScanned} files in ${result.scanDurationMs}ms`));
|
|
1067
|
+
lines.push("");
|
|
1068
|
+
if (result.findings.length > 0) {
|
|
1069
|
+
const grouped = groupByFile(result.findings);
|
|
1070
|
+
for (const [file, findings] of grouped) {
|
|
1071
|
+
lines.push(pc.underline(file));
|
|
1072
|
+
for (const f of findings) {
|
|
1073
|
+
const color = SEVERITY_COLORS[f.severity];
|
|
1074
|
+
const label = SEVERITY_LABELS[f.severity];
|
|
1075
|
+
lines.push(
|
|
1076
|
+
` ${pc.dim(`${f.line}:${f.column}`)} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
lines.push("");
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
lines.push(pc.bold(" Scores"));
|
|
1083
|
+
for (const cs of result.categoryScores) {
|
|
1084
|
+
const color = scoreColor(cs.score);
|
|
1085
|
+
const bar = renderBar(cs.score);
|
|
1086
|
+
lines.push(` ${cs.category.padEnd(14)} ${color(String(cs.score).padStart(3))} ${bar} ${pc.dim(`(${cs.findingCount} issues)`)}`);
|
|
1087
|
+
}
|
|
1088
|
+
lines.push("");
|
|
1089
|
+
const overallColor = scoreColor(result.overallScore);
|
|
1090
|
+
lines.push(pc.bold(` Overall: ${overallColor(String(result.overallScore))}/100`));
|
|
1091
|
+
lines.push("");
|
|
1092
|
+
const { critical, warning, info } = result.summary;
|
|
1093
|
+
const parts = [];
|
|
1094
|
+
if (critical > 0) parts.push(pc.red(`${critical} critical`));
|
|
1095
|
+
if (warning > 0) parts.push(pc.yellow(`${warning} warnings`));
|
|
1096
|
+
if (info > 0) parts.push(pc.blue(`${info} info`));
|
|
1097
|
+
if (parts.length === 0) {
|
|
1098
|
+
lines.push(pc.green(" No issues found!"));
|
|
1099
|
+
} else {
|
|
1100
|
+
lines.push(` ${parts.join(pc.dim(" \xB7 "))}`);
|
|
1101
|
+
}
|
|
1102
|
+
lines.push("");
|
|
1103
|
+
return lines.join("\n");
|
|
1104
|
+
}
|
|
1105
|
+
function reportJson(result) {
|
|
1106
|
+
return JSON.stringify(result, null, 2);
|
|
1107
|
+
}
|
|
1108
|
+
function renderBar(score) {
|
|
1109
|
+
const width = 20;
|
|
1110
|
+
const filled = Math.round(score / 100 * width);
|
|
1111
|
+
const empty = width - filled;
|
|
1112
|
+
const color = scoreColor(score);
|
|
1113
|
+
return color("\u2588".repeat(filled)) + pc.dim("\u2591".repeat(empty));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// src/cli.ts
|
|
1117
|
+
async function main() {
|
|
1118
|
+
const { values, positionals } = parseArgs({
|
|
1119
|
+
allowPositionals: true,
|
|
1120
|
+
options: {
|
|
1121
|
+
json: { type: "boolean", default: false },
|
|
1122
|
+
ignore: { type: "string", multiple: true, default: [] },
|
|
1123
|
+
help: { type: "boolean", short: "h", default: false },
|
|
1124
|
+
version: { type: "boolean", short: "v", default: false }
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
if (values.help) {
|
|
1128
|
+
printHelp();
|
|
1129
|
+
process.exit(0);
|
|
1130
|
+
}
|
|
1131
|
+
if (values.version) {
|
|
1132
|
+
console.log(getVersion());
|
|
1133
|
+
process.exit(0);
|
|
1134
|
+
}
|
|
1135
|
+
const targetPath = positionals[0] ?? ".";
|
|
1136
|
+
const result = await scan({
|
|
1137
|
+
path: targetPath,
|
|
1138
|
+
ignore: values.ignore
|
|
1139
|
+
});
|
|
1140
|
+
if (values.json) {
|
|
1141
|
+
console.log(reportJson(result));
|
|
1142
|
+
} else {
|
|
1143
|
+
console.log(reportPretty(result));
|
|
1144
|
+
}
|
|
1145
|
+
if (result.summary.critical > 0) {
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
function printHelp() {
|
|
1150
|
+
console.log(`
|
|
1151
|
+
prodlint - Scan AI-generated projects for production readiness issues
|
|
1152
|
+
|
|
1153
|
+
Usage:
|
|
1154
|
+
npx prodlint [path] [options]
|
|
1155
|
+
|
|
1156
|
+
Options:
|
|
1157
|
+
--json Output results as JSON
|
|
1158
|
+
--ignore <pattern> Glob patterns to ignore (can be repeated)
|
|
1159
|
+
-h, --help Show this help message
|
|
1160
|
+
-v, --version Show version
|
|
1161
|
+
|
|
1162
|
+
Examples:
|
|
1163
|
+
npx prodlint Scan current directory
|
|
1164
|
+
npx prodlint ./my-app Scan specific path
|
|
1165
|
+
npx prodlint --json JSON output
|
|
1166
|
+
npx prodlint --ignore "*.test" Ignore test files
|
|
1167
|
+
`);
|
|
1168
|
+
}
|
|
1169
|
+
main().catch((err) => {
|
|
1170
|
+
console.error(err);
|
|
1171
|
+
process.exit(2);
|
|
1172
|
+
});
|
|
1173
|
+
//# sourceMappingURL=cli.js.map
|