@qodfy/core 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +149 -36
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -41,6 +41,16 @@ var aiKeywords = [
|
|
|
41
41
|
"streamText",
|
|
42
42
|
"useChat"
|
|
43
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
|
+
]);
|
|
44
54
|
var hardcodedSecretPatterns = [
|
|
45
55
|
{
|
|
46
56
|
label: "OpenAI API key",
|
|
@@ -75,7 +85,8 @@ var hardcodedSecretPatterns = [
|
|
|
75
85
|
pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g
|
|
76
86
|
}
|
|
77
87
|
];
|
|
78
|
-
var LARGE_FILE_WARNING_BYTES =
|
|
88
|
+
var LARGE_FILE_WARNING_BYTES = 40 * 1024;
|
|
89
|
+
var LARGE_FILE_REPORT_LIMIT = 10;
|
|
79
90
|
var MAX_FILE_SIZE_BYTES = 500 * 1024;
|
|
80
91
|
async function scanProject(projectPath) {
|
|
81
92
|
const startTime = Date.now();
|
|
@@ -162,9 +173,10 @@ async function scanProject(projectPath) {
|
|
|
162
173
|
return isApiRoute(file);
|
|
163
174
|
});
|
|
164
175
|
const apiRouteSet = new Set(apiRoutes);
|
|
165
|
-
const
|
|
176
|
+
const missingEnvUsages = /* @__PURE__ */ new Map();
|
|
166
177
|
const clientSecretWarningKeys = /* @__PURE__ */ new Set();
|
|
167
178
|
const hardcodedSecretWarningKeys = /* @__PURE__ */ new Set();
|
|
179
|
+
const largeFileCandidates = [];
|
|
168
180
|
let aiFiles = 0;
|
|
169
181
|
let largeFiles = 0;
|
|
170
182
|
for (const file of files) {
|
|
@@ -203,12 +215,9 @@ async function scanProject(projectPath) {
|
|
|
203
215
|
const content = fileResult.content;
|
|
204
216
|
if (statResult.size > LARGE_FILE_WARNING_BYTES) {
|
|
205
217
|
largeFiles++;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
message: "Large files are harder to maintain and often appear in AI-generated codebases.",
|
|
210
|
-
file: relativeFile,
|
|
211
|
-
suggestion: "Consider splitting this file into smaller modules if it mixes unrelated responsibilities."
|
|
218
|
+
largeFileCandidates.push({
|
|
219
|
+
relativeFile,
|
|
220
|
+
size: statResult.size
|
|
212
221
|
});
|
|
213
222
|
}
|
|
214
223
|
const usesAI = aiKeywords.some(
|
|
@@ -227,15 +236,14 @@ async function scanProject(projectPath) {
|
|
|
227
236
|
});
|
|
228
237
|
}
|
|
229
238
|
}
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
if (isStripeWebhook && !hasStripeSignatureVerification) {
|
|
239
|
+
const webhookRouteInfo = apiRouteSet.has(file) ? getWebhookRouteInfo(relativeFile, content) : null;
|
|
240
|
+
if (webhookRouteInfo && !hasWebhookSignatureVerification(content, webhookRouteInfo.provider)) {
|
|
233
241
|
issues.push({
|
|
234
|
-
severity: "critical",
|
|
235
|
-
title: "
|
|
236
|
-
message: "This
|
|
242
|
+
severity: webhookRouteInfo.confidence === "high" ? "critical" : "warning",
|
|
243
|
+
title: "Webhook route may be missing signature verification",
|
|
244
|
+
message: "This webhook route appears to handle external events, but Qodfy could not find signature verification before the event is handled.",
|
|
237
245
|
file: relativeFile,
|
|
238
|
-
suggestion:
|
|
246
|
+
suggestion: getWebhookSignatureSuggestion(webhookRouteInfo.provider)
|
|
239
247
|
});
|
|
240
248
|
}
|
|
241
249
|
for (const secretMatch of getHardcodedSecretMatches(content)) {
|
|
@@ -254,7 +262,7 @@ async function scanProject(projectPath) {
|
|
|
254
262
|
}
|
|
255
263
|
if (apiRouteSet.has(file)) {
|
|
256
264
|
const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
|
|
257
|
-
if (!hasAuth && !
|
|
265
|
+
if (!hasAuth && !webhookRouteInfo) {
|
|
258
266
|
issues.push({
|
|
259
267
|
severity: "warning",
|
|
260
268
|
title: "API route may be missing authentication",
|
|
@@ -267,25 +275,17 @@ async function scanProject(projectPath) {
|
|
|
267
275
|
const usedEnvVariables = getUsedEnvVariables(content);
|
|
268
276
|
if (envExampleVariables) {
|
|
269
277
|
for (const variableName of usedEnvVariables) {
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
if (envExampleWarningKeys.has(warningKey)) {
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
envExampleWarningKeys.add(warningKey);
|
|
276
|
-
issues.push({
|
|
277
|
-
severity: "warning",
|
|
278
|
-
title: "Environment variable missing from .env.example",
|
|
279
|
-
message: `${variableName} is used in ${relativeFile} but is not documented in .env.example.`,
|
|
280
|
-
file: relativeFile,
|
|
281
|
-
suggestion: `Add ${variableName}= to .env.example without including a real value.`
|
|
282
|
-
});
|
|
278
|
+
if (shouldIgnoreEnvVariable(variableName) || envExampleVariables.has(variableName)) {
|
|
279
|
+
continue;
|
|
283
280
|
}
|
|
281
|
+
const filesUsingVariable = missingEnvUsages.get(variableName) ?? /* @__PURE__ */ new Set();
|
|
282
|
+
filesUsingVariable.add(relativeFile);
|
|
283
|
+
missingEnvUsages.set(variableName, filesUsingVariable);
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
if (isClientSideFile(relativeFile, content)) {
|
|
287
287
|
for (const variableName of usedEnvVariables) {
|
|
288
|
-
if (!variableName.startsWith("NEXT_PUBLIC_")) {
|
|
288
|
+
if (!variableName.startsWith("NEXT_PUBLIC_") && !shouldIgnoreEnvVariable(variableName)) {
|
|
289
289
|
const warningKey = `${relativeFile}:${variableName}`;
|
|
290
290
|
if (clientSecretWarningKeys.has(warningKey)) {
|
|
291
291
|
continue;
|
|
@@ -302,9 +302,29 @@ async function scanProject(projectPath) {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
|
+
for (const largeFile of getReportedLargeFiles(largeFileCandidates)) {
|
|
306
|
+
issues.push({
|
|
307
|
+
severity: "info",
|
|
308
|
+
title: "Large file detected",
|
|
309
|
+
message: "Large files are harder to maintain and often appear in AI-generated codebases.",
|
|
310
|
+
file: largeFile.relativeFile,
|
|
311
|
+
suggestion: "Consider splitting this file into smaller modules if it mixes unrelated responsibilities."
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
for (const [variableName, filesUsingVariable] of getSortedMissingEnvUsages(missingEnvUsages)) {
|
|
315
|
+
const files2 = [...filesUsingVariable].sort();
|
|
316
|
+
issues.push({
|
|
317
|
+
severity: "warning",
|
|
318
|
+
title: "Environment variable missing from .env.example",
|
|
319
|
+
message: getMissingEnvMessage(variableName, files2),
|
|
320
|
+
file: files2.length === 1 ? files2[0] : void 0,
|
|
321
|
+
suggestion: `Add ${variableName}= to .env.example without including a real value.`
|
|
322
|
+
});
|
|
323
|
+
}
|
|
305
324
|
const criticalCount = issues.filter((issue) => issue.severity === "critical").length;
|
|
306
325
|
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
|
307
|
-
const
|
|
326
|
+
const warningPenalty = Math.min(warningCount * 5, 50);
|
|
327
|
+
const score = Math.max(0, 100 - criticalCount * 20 - warningPenalty);
|
|
308
328
|
return {
|
|
309
329
|
projectPath: resolvedProjectPath,
|
|
310
330
|
isNextProject,
|
|
@@ -454,6 +474,31 @@ function getUsedEnvVariables(content) {
|
|
|
454
474
|
}
|
|
455
475
|
return variables;
|
|
456
476
|
}
|
|
477
|
+
function shouldIgnoreEnvVariable(variableName) {
|
|
478
|
+
return ignoredEnvVariables.has(variableName);
|
|
479
|
+
}
|
|
480
|
+
function getReportedLargeFiles(largeFileCandidates) {
|
|
481
|
+
return [...largeFileCandidates].sort((a, b) => b.size - a.size).slice(0, LARGE_FILE_REPORT_LIMIT);
|
|
482
|
+
}
|
|
483
|
+
function getSortedMissingEnvUsages(missingEnvUsages) {
|
|
484
|
+
return [...missingEnvUsages.entries()].sort(
|
|
485
|
+
([leftVariable], [rightVariable]) => leftVariable.localeCompare(rightVariable)
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
function getMissingEnvMessage(variableName, files) {
|
|
489
|
+
if (files.length === 1) {
|
|
490
|
+
return `${variableName} is used in ${files[0]} but is not documented in .env.example.`;
|
|
491
|
+
}
|
|
492
|
+
return `${variableName} is used in ${files.length} files but is not documented in .env.example. Files: ${formatFileList(files)}.`;
|
|
493
|
+
}
|
|
494
|
+
function formatFileList(files) {
|
|
495
|
+
const filesToShow = files.slice(0, 5);
|
|
496
|
+
const remainingCount = files.length - filesToShow.length;
|
|
497
|
+
if (remainingCount <= 0) {
|
|
498
|
+
return filesToShow.join(", ");
|
|
499
|
+
}
|
|
500
|
+
return `${filesToShow.join(", ")} and ${remainingCount} more`;
|
|
501
|
+
}
|
|
457
502
|
function parseDestructuredEnvNames(destructuredContent) {
|
|
458
503
|
const variables = [];
|
|
459
504
|
for (const part of destructuredContent.split(",")) {
|
|
@@ -479,15 +524,83 @@ function isClientSideFile(relativeFile, content) {
|
|
|
479
524
|
return fileName.includes(".client.") || /(^|\n)\s*["']use client["'];?/.test(content);
|
|
480
525
|
}
|
|
481
526
|
function isApiRoute(filePath) {
|
|
482
|
-
|
|
527
|
+
const normalizedFile = normalizePath(filePath);
|
|
528
|
+
const sourceFileExtension = "(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)";
|
|
529
|
+
return new RegExp(`/app/api(?:/.+)?/route\\.${sourceFileExtension}$`).test(normalizedFile) || new RegExp(`/pages/api/.+\\.${sourceFileExtension}$`).test(normalizedFile);
|
|
483
530
|
}
|
|
484
|
-
function
|
|
531
|
+
function getWebhookRouteInfo(relativeFile, content) {
|
|
485
532
|
const normalizedFile = relativeFile.toLowerCase();
|
|
486
533
|
const normalizedContent = content.toLowerCase();
|
|
487
|
-
|
|
534
|
+
const normalizedRouteContext = `${normalizedFile}
|
|
535
|
+
${normalizedContent}`;
|
|
536
|
+
const pathLooksLikeWebhook = normalizedFile.includes("webhook") || normalizedFile.includes("callback");
|
|
537
|
+
const contentStronglySuggestsWebhook = normalizedContent.includes("stripe.webhooks") || normalizedContent.includes("constructevent(") || normalizedContent.includes("stripe-signature") || normalizedContent.includes("stripe_webhook_secret") || normalizedContent.includes("clerk_webhook_secret") || normalizedContent.includes("svix") || normalizedContent.includes("x-github-event") || normalizedContent.includes("x-hub-signature") || normalizedContent.includes("x-shopify-hmac-sha256") || normalizedContent.includes("resend") && normalizedContent.includes("webhook") || normalizedContent.includes("webhook_secret") || normalizedContent.includes("webhooksecret") || normalizedContent.includes("webhook") && (normalizedContent.includes("signature") || normalizedContent.includes("secret") || normalizedContent.includes("event"));
|
|
538
|
+
if (!pathLooksLikeWebhook && !contentStronglySuggestsWebhook) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
const provider = getWebhookProvider(normalizedRouteContext);
|
|
542
|
+
return {
|
|
543
|
+
provider,
|
|
544
|
+
confidence: provider === "unknown" ? "likely" : "high"
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function getWebhookProvider(normalizedRouteContext) {
|
|
548
|
+
if (normalizedRouteContext.includes("stripe-signature") || normalizedRouteContext.includes("stripe_webhook_secret") || normalizedRouteContext.includes("stripe.webhooks") || normalizedRouteContext.includes("constructevent(") || normalizedRouteContext.includes("stripe") && normalizedRouteContext.includes("webhook")) {
|
|
549
|
+
return "stripe";
|
|
550
|
+
}
|
|
551
|
+
if (normalizedRouteContext.includes("resend") && normalizedRouteContext.includes("webhook")) {
|
|
552
|
+
return "resend";
|
|
553
|
+
}
|
|
554
|
+
if (normalizedRouteContext.includes("clerk_webhook_secret") || normalizedRouteContext.includes("clerk") && normalizedRouteContext.includes("webhook")) {
|
|
555
|
+
return "clerk";
|
|
556
|
+
}
|
|
557
|
+
if (normalizedRouteContext.includes("x-github-event") || normalizedRouteContext.includes("x-hub-signature")) {
|
|
558
|
+
return "github";
|
|
559
|
+
}
|
|
560
|
+
if (normalizedRouteContext.includes("x-shopify-hmac-sha256")) {
|
|
561
|
+
return "shopify";
|
|
562
|
+
}
|
|
563
|
+
return "unknown";
|
|
564
|
+
}
|
|
565
|
+
function hasWebhookSignatureVerification(content, provider) {
|
|
566
|
+
const normalizedContent = content.toLowerCase();
|
|
567
|
+
if (provider === "stripe") {
|
|
568
|
+
return normalizedContent.includes("stripe.webhooks.constructevent") || normalizedContent.includes("webhooks.constructevent") || normalizedContent.includes("constructevent(");
|
|
569
|
+
}
|
|
570
|
+
if (provider === "clerk") {
|
|
571
|
+
return (normalizedContent.includes("new webhook(") || normalizedContent.includes("webhook(") || normalizedContent.includes("svix")) && (normalizedContent.includes(".verify(") || normalizedContent.includes("verify(") || normalizedContent.includes("verifywebhook"));
|
|
572
|
+
}
|
|
573
|
+
if (provider === "github") {
|
|
574
|
+
return (normalizedContent.includes("x-hub-signature") || normalizedContent.includes("x-hub-signature-256")) && hasHmacOrVerifyCall(normalizedContent);
|
|
575
|
+
}
|
|
576
|
+
if (provider === "shopify") {
|
|
577
|
+
return normalizedContent.includes("x-shopify-hmac-sha256") && hasHmacOrVerifyCall(normalizedContent);
|
|
578
|
+
}
|
|
579
|
+
if (provider === "resend") {
|
|
580
|
+
return normalizedContent.includes("verifywebhook") || (normalizedContent.includes("new webhook(") || normalizedContent.includes("webhook(") || normalizedContent.includes("svix")) && hasHmacOrVerifyCall(normalizedContent);
|
|
581
|
+
}
|
|
582
|
+
return normalizedContent.includes("constructevent(") || normalizedContent.includes("verifywebhook") || normalizedContent.includes("signature") && hasHmacOrVerifyCall(normalizedContent) || normalizedContent.includes("webhook") && normalizedContent.includes("verify(");
|
|
488
583
|
}
|
|
489
|
-
function
|
|
490
|
-
return
|
|
584
|
+
function hasHmacOrVerifyCall(normalizedContent) {
|
|
585
|
+
return normalizedContent.includes("verify(") || normalizedContent.includes(".verify(") || normalizedContent.includes("verifywebhook") || normalizedContent.includes("createhmac") || normalizedContent.includes("timingsafeequal") || normalizedContent.includes("subtle.verify");
|
|
586
|
+
}
|
|
587
|
+
function getWebhookSignatureSuggestion(provider) {
|
|
588
|
+
if (provider === "stripe") {
|
|
589
|
+
return "Use stripe.webhooks.constructEvent(...) with the Stripe signature header before handling the event.";
|
|
590
|
+
}
|
|
591
|
+
if (provider === "clerk") {
|
|
592
|
+
return "Verify the event with Svix before handling it.";
|
|
593
|
+
}
|
|
594
|
+
if (provider === "github") {
|
|
595
|
+
return "Verify the GitHub signature using the raw request body, X-Hub-Signature-256 header, and webhook secret.";
|
|
596
|
+
}
|
|
597
|
+
if (provider === "shopify") {
|
|
598
|
+
return "Verify the Shopify HMAC using the raw request body, X-Shopify-Hmac-Sha256 header, and webhook secret.";
|
|
599
|
+
}
|
|
600
|
+
if (provider === "resend") {
|
|
601
|
+
return "Verify the Resend webhook signature before handling the event.";
|
|
602
|
+
}
|
|
603
|
+
return "Verify the provider signature using the raw request body and signature header before trusting the event.";
|
|
491
604
|
}
|
|
492
605
|
function isPackageJsonObject(data) {
|
|
493
606
|
return typeof data === "object" && data !== null && !Array.isArray(data);
|