@qodfy/core 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +275 -45
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -9,7 +9,27 @@ var ignoredPaths = [
|
|
|
9
9
|
"dist/**",
|
|
10
10
|
"build/**",
|
|
11
11
|
".turbo/**",
|
|
12
|
-
".vercel/**"
|
|
12
|
+
".vercel/**",
|
|
13
|
+
"coverage/**",
|
|
14
|
+
"**/coverage/**",
|
|
15
|
+
".cache/**",
|
|
16
|
+
"**/.cache/**",
|
|
17
|
+
".output/**",
|
|
18
|
+
"**/.output/**",
|
|
19
|
+
".open-next/**",
|
|
20
|
+
"**/.open-next/**",
|
|
21
|
+
"storybook-static/**",
|
|
22
|
+
"**/storybook-static/**",
|
|
23
|
+
"playwright-report/**",
|
|
24
|
+
"**/playwright-report/**",
|
|
25
|
+
"test-results/**",
|
|
26
|
+
"**/test-results/**",
|
|
27
|
+
"**/*.d.ts",
|
|
28
|
+
"**/*.map",
|
|
29
|
+
"generated/**",
|
|
30
|
+
"**/generated/**",
|
|
31
|
+
"__generated__/**",
|
|
32
|
+
"**/__generated__/**"
|
|
13
33
|
];
|
|
14
34
|
var aiKeywords = [
|
|
15
35
|
"openai",
|
|
@@ -21,7 +41,55 @@ var aiKeywords = [
|
|
|
21
41
|
"streamText",
|
|
22
42
|
"useChat"
|
|
23
43
|
];
|
|
44
|
+
var ignoredEnvVariables = /* @__PURE__ */ new Set([
|
|
45
|
+
"CI",
|
|
46
|
+
"HOME",
|
|
47
|
+
"NODE_ENV",
|
|
48
|
+
"PORT",
|
|
49
|
+
"PWD",
|
|
50
|
+
"VERCEL",
|
|
51
|
+
"VERCEL_ENV",
|
|
52
|
+
"VERCEL_URL"
|
|
53
|
+
]);
|
|
54
|
+
var hardcodedSecretPatterns = [
|
|
55
|
+
{
|
|
56
|
+
label: "OpenAI API key",
|
|
57
|
+
pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: "Stripe secret key",
|
|
61
|
+
pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: "Stripe webhook secret",
|
|
65
|
+
pattern: /\bwhsec_[A-Za-z0-9]{16,}\b/g
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
label: "GitHub token",
|
|
69
|
+
pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{30,}\b/g
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: "GitHub fine-grained token",
|
|
73
|
+
pattern: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: "Google API key",
|
|
77
|
+
pattern: /\bAIza[A-Za-z0-9_-]{20,}\b/g
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: "Slack token",
|
|
81
|
+
pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
label: "private key",
|
|
85
|
+
pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g
|
|
86
|
+
}
|
|
87
|
+
];
|
|
88
|
+
var LARGE_FILE_WARNING_BYTES = 40 * 1024;
|
|
89
|
+
var LARGE_FILE_REPORT_LIMIT = 10;
|
|
90
|
+
var MAX_FILE_SIZE_BYTES = 500 * 1024;
|
|
24
91
|
async function scanProject(projectPath) {
|
|
92
|
+
const startTime = Date.now();
|
|
25
93
|
const resolvedProjectPath = path.resolve(projectPath);
|
|
26
94
|
const issues = [];
|
|
27
95
|
const packageJsonPath = path.join(resolvedProjectPath, "package.json");
|
|
@@ -102,17 +170,39 @@ async function scanProject(projectPath) {
|
|
|
102
170
|
}
|
|
103
171
|
const files = await getSourceFiles(resolvedProjectPath, issues);
|
|
104
172
|
const apiRoutes = files.filter((file) => {
|
|
105
|
-
return
|
|
173
|
+
return isApiRoute(file);
|
|
106
174
|
});
|
|
107
175
|
const apiRouteSet = new Set(apiRoutes);
|
|
108
|
-
const
|
|
109
|
-
const envExampleWarningKeys = /* @__PURE__ */ new Set();
|
|
176
|
+
const missingEnvUsages = /* @__PURE__ */ new Map();
|
|
110
177
|
const clientSecretWarningKeys = /* @__PURE__ */ new Set();
|
|
178
|
+
const hardcodedSecretWarningKeys = /* @__PURE__ */ new Set();
|
|
179
|
+
const largeFileCandidates = [];
|
|
111
180
|
let aiFiles = 0;
|
|
112
181
|
let largeFiles = 0;
|
|
113
182
|
for (const file of files) {
|
|
114
|
-
const fileResult = await safeReadFile(file);
|
|
115
183
|
const relativeFile = normalizePath(path.relative(resolvedProjectPath, file));
|
|
184
|
+
const statResult = await safeStatFile(file);
|
|
185
|
+
if (!statResult.ok) {
|
|
186
|
+
issues.push({
|
|
187
|
+
severity: "info",
|
|
188
|
+
title: "File could not be checked",
|
|
189
|
+
message: statResult.reason,
|
|
190
|
+
file: relativeFile
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (statResult.size > MAX_FILE_SIZE_BYTES) {
|
|
195
|
+
largeFiles++;
|
|
196
|
+
issues.push({
|
|
197
|
+
severity: "info",
|
|
198
|
+
title: "Large file skipped from deep scan",
|
|
199
|
+
message: "This file is larger than 500KB and was skipped from deep content checks.",
|
|
200
|
+
file: relativeFile,
|
|
201
|
+
suggestion: "Review large generated or bundled files manually."
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const fileResult = await safeReadFile(file);
|
|
116
206
|
if (!fileResult.ok) {
|
|
117
207
|
issues.push({
|
|
118
208
|
severity: "info",
|
|
@@ -123,15 +213,11 @@ async function scanProject(projectPath) {
|
|
|
123
213
|
continue;
|
|
124
214
|
}
|
|
125
215
|
const content = fileResult.content;
|
|
126
|
-
|
|
127
|
-
if (content.length > 15e3) {
|
|
216
|
+
if (statResult.size > LARGE_FILE_WARNING_BYTES) {
|
|
128
217
|
largeFiles++;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
message: "Large files are harder to maintain and often appear in AI-generated codebases.",
|
|
133
|
-
file: relativeFile,
|
|
134
|
-
suggestion: "Consider splitting this file into smaller modules if it mixes unrelated responsibilities."
|
|
218
|
+
largeFileCandidates.push({
|
|
219
|
+
relativeFile,
|
|
220
|
+
size: statResult.size
|
|
135
221
|
});
|
|
136
222
|
}
|
|
137
223
|
const usesAI = aiKeywords.some(
|
|
@@ -150,28 +236,68 @@ async function scanProject(projectPath) {
|
|
|
150
236
|
});
|
|
151
237
|
}
|
|
152
238
|
}
|
|
239
|
+
const isStripeWebhook = isStripeWebhookRoute(relativeFile, content);
|
|
240
|
+
const hasStripeSignatureVerification = hasStripeWebhookSignatureVerification(content);
|
|
241
|
+
const isClerkWebhook = isClerkWebhookRoute(relativeFile, content);
|
|
242
|
+
const hasClerkSignatureVerification = hasClerkWebhookSignatureVerification(content);
|
|
243
|
+
if (isStripeWebhook && !hasStripeSignatureVerification) {
|
|
244
|
+
issues.push({
|
|
245
|
+
severity: "critical",
|
|
246
|
+
title: "Stripe webhook may be missing signature verification",
|
|
247
|
+
message: "This Stripe webhook route does not appear to verify the Stripe signature before handling the event.",
|
|
248
|
+
file: relativeFile,
|
|
249
|
+
suggestion: "Use stripe.webhooks.constructEvent with the raw request body, Stripe-Signature header, and STRIPE_WEBHOOK_SECRET."
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (isClerkWebhook && !hasClerkSignatureVerification) {
|
|
253
|
+
issues.push({
|
|
254
|
+
severity: "critical",
|
|
255
|
+
title: "Clerk webhook may be missing signature verification",
|
|
256
|
+
message: "This Clerk webhook route does not appear to verify the webhook signature before handling the event.",
|
|
257
|
+
file: relativeFile,
|
|
258
|
+
suggestion: "Use svix Webhook(...).verify(...) with CLERK_WEBHOOK_SECRET and Clerk webhook headers."
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
for (const secretMatch of getHardcodedSecretMatches(content)) {
|
|
262
|
+
const warningKey = `${relativeFile}:${secretMatch.label}`;
|
|
263
|
+
if (hardcodedSecretWarningKeys.has(warningKey)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
hardcodedSecretWarningKeys.add(warningKey);
|
|
267
|
+
issues.push({
|
|
268
|
+
severity: "critical",
|
|
269
|
+
title: "Possible hardcoded secret",
|
|
270
|
+
message: `A string literal in ${relativeFile} matches the pattern for ${secretMatch.label}. Qodfy does not print possible secret values.`,
|
|
271
|
+
file: relativeFile,
|
|
272
|
+
suggestion: "Move secrets into environment variables and rotate the value if this is a real secret."
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if (apiRouteSet.has(file)) {
|
|
276
|
+
const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
|
|
277
|
+
if (!hasAuth && !isStripeWebhook && !isClerkWebhook) {
|
|
278
|
+
issues.push({
|
|
279
|
+
severity: "warning",
|
|
280
|
+
title: "API route may be missing authentication",
|
|
281
|
+
message: "This API route does not appear to contain an auth/session check.",
|
|
282
|
+
file: relativeFile,
|
|
283
|
+
suggestion: "Confirm the route is public, or add an auth/session check before handling user data."
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
153
287
|
const usedEnvVariables = getUsedEnvVariables(content);
|
|
154
288
|
if (envExampleVariables) {
|
|
155
289
|
for (const variableName of usedEnvVariables) {
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
if (envExampleWarningKeys.has(warningKey)) {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
envExampleWarningKeys.add(warningKey);
|
|
162
|
-
issues.push({
|
|
163
|
-
severity: "warning",
|
|
164
|
-
title: "Environment variable missing from .env.example",
|
|
165
|
-
message: `${variableName} is used in ${relativeFile} but is not documented in .env.example.`,
|
|
166
|
-
file: relativeFile,
|
|
167
|
-
suggestion: `Add ${variableName}= to .env.example without including a real value.`
|
|
168
|
-
});
|
|
290
|
+
if (shouldIgnoreEnvVariable(variableName) || envExampleVariables.has(variableName)) {
|
|
291
|
+
continue;
|
|
169
292
|
}
|
|
293
|
+
const filesUsingVariable = missingEnvUsages.get(variableName) ?? /* @__PURE__ */ new Set();
|
|
294
|
+
filesUsingVariable.add(relativeFile);
|
|
295
|
+
missingEnvUsages.set(variableName, filesUsingVariable);
|
|
170
296
|
}
|
|
171
297
|
}
|
|
172
298
|
if (isClientSideFile(relativeFile, content)) {
|
|
173
299
|
for (const variableName of usedEnvVariables) {
|
|
174
|
-
if (!variableName.startsWith("NEXT_PUBLIC_")) {
|
|
300
|
+
if (!variableName.startsWith("NEXT_PUBLIC_") && !shouldIgnoreEnvVariable(variableName)) {
|
|
175
301
|
const warningKey = `${relativeFile}:${variableName}`;
|
|
176
302
|
if (clientSecretWarningKeys.has(warningKey)) {
|
|
177
303
|
continue;
|
|
@@ -188,26 +314,29 @@ async function scanProject(projectPath) {
|
|
|
188
314
|
}
|
|
189
315
|
}
|
|
190
316
|
}
|
|
191
|
-
for (const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
317
|
+
for (const largeFile of getReportedLargeFiles(largeFileCandidates)) {
|
|
318
|
+
issues.push({
|
|
319
|
+
severity: "info",
|
|
320
|
+
title: "Large file detected",
|
|
321
|
+
message: "Large files are harder to maintain and often appear in AI-generated codebases.",
|
|
322
|
+
file: largeFile.relativeFile,
|
|
323
|
+
suggestion: "Consider splitting this file into smaller modules if it mixes unrelated responsibilities."
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
for (const [variableName, filesUsingVariable] of getSortedMissingEnvUsages(missingEnvUsages)) {
|
|
327
|
+
const files2 = [...filesUsingVariable].sort();
|
|
328
|
+
issues.push({
|
|
329
|
+
severity: "warning",
|
|
330
|
+
title: "Environment variable missing from .env.example",
|
|
331
|
+
message: getMissingEnvMessage(variableName, files2),
|
|
332
|
+
file: files2.length === 1 ? files2[0] : void 0,
|
|
333
|
+
suggestion: `Add ${variableName}= to .env.example without including a real value.`
|
|
334
|
+
});
|
|
207
335
|
}
|
|
208
336
|
const criticalCount = issues.filter((issue) => issue.severity === "critical").length;
|
|
209
337
|
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
|
210
|
-
const
|
|
338
|
+
const warningPenalty = Math.min(warningCount * 5, 50);
|
|
339
|
+
const score = Math.max(0, 100 - criticalCount * 20 - warningPenalty);
|
|
211
340
|
return {
|
|
212
341
|
projectPath: resolvedProjectPath,
|
|
213
342
|
isNextProject,
|
|
@@ -217,7 +346,8 @@ async function scanProject(projectPath) {
|
|
|
217
346
|
totalFiles: files.length,
|
|
218
347
|
apiRoutes: apiRoutes.length,
|
|
219
348
|
aiFiles,
|
|
220
|
-
largeFiles
|
|
349
|
+
largeFiles,
|
|
350
|
+
durationMs: Date.now() - startTime
|
|
221
351
|
}
|
|
222
352
|
};
|
|
223
353
|
}
|
|
@@ -258,6 +388,36 @@ async function safeReadFile(filePath) {
|
|
|
258
388
|
};
|
|
259
389
|
}
|
|
260
390
|
}
|
|
391
|
+
async function safeStatFile(filePath) {
|
|
392
|
+
try {
|
|
393
|
+
const stats = await fs.stat(filePath);
|
|
394
|
+
return {
|
|
395
|
+
ok: true,
|
|
396
|
+
size: stats.size
|
|
397
|
+
};
|
|
398
|
+
} catch (error) {
|
|
399
|
+
const code = getErrorCode(error);
|
|
400
|
+
if (code === "ENOENT") {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
code,
|
|
404
|
+
reason: "The file disappeared while Qodfy was scanning it."
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
code,
|
|
411
|
+
reason: "Qodfy does not have permission to check this file."
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
code,
|
|
417
|
+
reason: "Qodfy could not check this file."
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
261
421
|
async function safeReadJson(filePath) {
|
|
262
422
|
const fileResult = await safeReadFile(filePath);
|
|
263
423
|
if (!fileResult.ok) {
|
|
@@ -312,18 +472,88 @@ function getUsedEnvVariables(content) {
|
|
|
312
472
|
const variables = /* @__PURE__ */ new Set();
|
|
313
473
|
const dotAccessPattern = /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
314
474
|
const bracketAccessPattern = /\bprocess\.env\[['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\]/g;
|
|
475
|
+
const destructuredEnvPattern = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*process\.env\b/g;
|
|
315
476
|
for (const match of content.matchAll(dotAccessPattern)) {
|
|
316
477
|
variables.add(match[1]);
|
|
317
478
|
}
|
|
318
479
|
for (const match of content.matchAll(bracketAccessPattern)) {
|
|
319
480
|
variables.add(match[1]);
|
|
320
481
|
}
|
|
482
|
+
for (const match of content.matchAll(destructuredEnvPattern)) {
|
|
483
|
+
for (const variableName of parseDestructuredEnvNames(match[1])) {
|
|
484
|
+
variables.add(variableName);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
321
487
|
return variables;
|
|
322
488
|
}
|
|
489
|
+
function shouldIgnoreEnvVariable(variableName) {
|
|
490
|
+
return ignoredEnvVariables.has(variableName);
|
|
491
|
+
}
|
|
492
|
+
function getReportedLargeFiles(largeFileCandidates) {
|
|
493
|
+
return [...largeFileCandidates].sort((a, b) => b.size - a.size).slice(0, LARGE_FILE_REPORT_LIMIT);
|
|
494
|
+
}
|
|
495
|
+
function getSortedMissingEnvUsages(missingEnvUsages) {
|
|
496
|
+
return [...missingEnvUsages.entries()].sort(
|
|
497
|
+
([leftVariable], [rightVariable]) => leftVariable.localeCompare(rightVariable)
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
function getMissingEnvMessage(variableName, files) {
|
|
501
|
+
if (files.length === 1) {
|
|
502
|
+
return `${variableName} is used in ${files[0]} but is not documented in .env.example.`;
|
|
503
|
+
}
|
|
504
|
+
return `${variableName} is used in ${files.length} files but is not documented in .env.example. Files: ${formatFileList(files)}.`;
|
|
505
|
+
}
|
|
506
|
+
function formatFileList(files) {
|
|
507
|
+
const filesToShow = files.slice(0, 5);
|
|
508
|
+
const remainingCount = files.length - filesToShow.length;
|
|
509
|
+
if (remainingCount <= 0) {
|
|
510
|
+
return filesToShow.join(", ");
|
|
511
|
+
}
|
|
512
|
+
return `${filesToShow.join(", ")} and ${remainingCount} more`;
|
|
513
|
+
}
|
|
514
|
+
function parseDestructuredEnvNames(destructuredContent) {
|
|
515
|
+
const variables = [];
|
|
516
|
+
for (const part of destructuredContent.split(",")) {
|
|
517
|
+
const variableName = part.trim().split(":")[0].split("=")[0].trim();
|
|
518
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(variableName)) {
|
|
519
|
+
variables.push(variableName);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return variables;
|
|
523
|
+
}
|
|
524
|
+
function getHardcodedSecretMatches(content) {
|
|
525
|
+
const matches = [];
|
|
526
|
+
for (const secretPattern of hardcodedSecretPatterns) {
|
|
527
|
+
secretPattern.pattern.lastIndex = 0;
|
|
528
|
+
if (secretPattern.pattern.test(content)) {
|
|
529
|
+
matches.push({ label: secretPattern.label });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return matches;
|
|
533
|
+
}
|
|
323
534
|
function isClientSideFile(relativeFile, content) {
|
|
324
535
|
const fileName = path.basename(relativeFile);
|
|
325
536
|
return fileName.includes(".client.") || /(^|\n)\s*["']use client["'];?/.test(content);
|
|
326
537
|
}
|
|
538
|
+
function isApiRoute(filePath) {
|
|
539
|
+
return filePath.includes(`${path.sep}app${path.sep}api${path.sep}`) || filePath.includes(`${path.sep}pages${path.sep}api${path.sep}`);
|
|
540
|
+
}
|
|
541
|
+
function isStripeWebhookRoute(relativeFile, content) {
|
|
542
|
+
const normalizedFile = relativeFile.toLowerCase();
|
|
543
|
+
const normalizedContent = content.toLowerCase();
|
|
544
|
+
return normalizedContent.includes("stripe") && (normalizedFile.includes("webhook") || normalizedContent.includes("webhook") || normalizedContent.includes("stripe.webhooks"));
|
|
545
|
+
}
|
|
546
|
+
function hasStripeWebhookSignatureVerification(content) {
|
|
547
|
+
return content.includes("stripe.webhooks.constructEvent") || content.includes("webhooks.constructEvent") || content.includes("constructEvent(");
|
|
548
|
+
}
|
|
549
|
+
function isClerkWebhookRoute(relativeFile, content) {
|
|
550
|
+
const normalizedFile = relativeFile.toLowerCase();
|
|
551
|
+
const normalizedContent = content.toLowerCase();
|
|
552
|
+
return (normalizedContent.includes("clerk") || normalizedContent.includes("clerk_webhook_secret")) && (normalizedFile.includes("webhook") || normalizedContent.includes("webhook"));
|
|
553
|
+
}
|
|
554
|
+
function hasClerkWebhookSignatureVerification(content) {
|
|
555
|
+
return content.includes("new Webhook(") || content.includes(".verify(") || content.includes("svix");
|
|
556
|
+
}
|
|
327
557
|
function isPackageJsonObject(data) {
|
|
328
558
|
return typeof data === "object" && data !== null && !Array.isArray(data);
|
|
329
559
|
}
|