@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 CHANGED
@@ -16,6 +16,7 @@ type ScanReport = {
16
16
  apiRoutes: number;
17
17
  aiFiles: number;
18
18
  largeFiles: number;
19
+ durationMs: number;
19
20
  };
20
21
  };
21
22
  declare function scanProject(projectPath: string): Promise<ScanReport>;
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 file.includes(`${path.sep}app${path.sep}api${path.sep}`) || file.includes(`${path.sep}pages${path.sep}api${path.sep}`);
173
+ return isApiRoute(file);
106
174
  });
107
175
  const apiRouteSet = new Set(apiRoutes);
108
- const readableFiles = /* @__PURE__ */ new Map();
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
- readableFiles.set(file, content);
127
- if (content.length > 15e3) {
216
+ if (statResult.size > LARGE_FILE_WARNING_BYTES) {
128
217
  largeFiles++;
129
- issues.push({
130
- severity: "info",
131
- title: "Large file detected",
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 (!envExampleVariables.has(variableName)) {
157
- const warningKey = `${relativeFile}:${variableName}`;
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 route of apiRoutes) {
192
- const content = readableFiles.get(route);
193
- if (!content) {
194
- continue;
195
- }
196
- const relativeFile = normalizePath(path.relative(resolvedProjectPath, route));
197
- const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
198
- if (!hasAuth) {
199
- issues.push({
200
- severity: "warning",
201
- title: "API route may be missing authentication",
202
- message: "This API route does not appear to contain an auth/session check.",
203
- file: relativeFile,
204
- suggestion: "Confirm the route is public, or add an auth/session check before handling user data."
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 score = Math.max(0, 100 - criticalCount * 20 - warningCount * 8);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qodfy/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Scanner engine for Qodfy, an open-source launch readiness scanner for AI-built apps.",
5
5
  "keywords": [
6
6
  "qodfy",