@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.
Files changed (2) hide show
  1. package/dist/index.js +149 -36
  2. 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 = 15e3;
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 envExampleWarningKeys = /* @__PURE__ */ new Set();
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
- issues.push({
207
- severity: "info",
208
- title: "Large file detected",
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 isStripeWebhook = isStripeWebhookRoute(relativeFile, content);
231
- const hasStripeSignatureVerification = hasStripeWebhookSignatureVerification(content);
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: "Stripe webhook may be missing signature verification",
236
- message: "This Stripe webhook route does not appear to verify the Stripe signature before handling the event.",
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: "Use stripe.webhooks.constructEvent with the raw request body, Stripe-Signature header, and STRIPE_WEBHOOK_SECRET."
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 && !isStripeWebhook) {
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 (!envExampleVariables.has(variableName)) {
271
- const warningKey = `${relativeFile}:${variableName}`;
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 score = Math.max(0, 100 - criticalCount * 20 - warningCount * 8);
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
- return filePath.includes(`${path.sep}app${path.sep}api${path.sep}`) || filePath.includes(`${path.sep}pages${path.sep}api${path.sep}`);
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 isStripeWebhookRoute(relativeFile, content) {
531
+ function getWebhookRouteInfo(relativeFile, content) {
485
532
  const normalizedFile = relativeFile.toLowerCase();
486
533
  const normalizedContent = content.toLowerCase();
487
- return normalizedContent.includes("stripe") && (normalizedFile.includes("webhook") || normalizedContent.includes("webhook") || normalizedContent.includes("stripe.webhooks"));
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 hasStripeWebhookSignatureVerification(content) {
490
- return content.includes("stripe.webhooks.constructEvent") || content.includes("webhooks.constructEvent") || content.includes("constructEvent(");
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qodfy/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Scanner engine for Qodfy, an open-source launch readiness scanner for AI-built apps.",
5
5
  "keywords": [
6
6
  "qodfy",