@qodfy/core 0.1.2 → 0.1.4

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,44 @@ var aiKeywords = [
21
41
  "streamText",
22
42
  "useChat"
23
43
  ];
44
+ var hardcodedSecretPatterns = [
45
+ {
46
+ label: "OpenAI API key",
47
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g
48
+ },
49
+ {
50
+ label: "Stripe secret key",
51
+ pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g
52
+ },
53
+ {
54
+ label: "Stripe webhook secret",
55
+ pattern: /\bwhsec_[A-Za-z0-9]{16,}\b/g
56
+ },
57
+ {
58
+ label: "GitHub token",
59
+ pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{30,}\b/g
60
+ },
61
+ {
62
+ label: "GitHub fine-grained token",
63
+ pattern: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g
64
+ },
65
+ {
66
+ label: "Google API key",
67
+ pattern: /\bAIza[A-Za-z0-9_-]{20,}\b/g
68
+ },
69
+ {
70
+ label: "Slack token",
71
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g
72
+ },
73
+ {
74
+ label: "private key",
75
+ pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g
76
+ }
77
+ ];
78
+ var LARGE_FILE_WARNING_BYTES = 15e3;
79
+ var MAX_FILE_SIZE_BYTES = 500 * 1024;
24
80
  async function scanProject(projectPath) {
81
+ const startTime = Date.now();
25
82
  const resolvedProjectPath = path.resolve(projectPath);
26
83
  const issues = [];
27
84
  const packageJsonPath = path.join(resolvedProjectPath, "package.json");
@@ -102,17 +159,38 @@ async function scanProject(projectPath) {
102
159
  }
103
160
  const files = await getSourceFiles(resolvedProjectPath, issues);
104
161
  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}`);
162
+ return isApiRoute(file);
106
163
  });
107
164
  const apiRouteSet = new Set(apiRoutes);
108
- const readableFiles = /* @__PURE__ */ new Map();
109
165
  const envExampleWarningKeys = /* @__PURE__ */ new Set();
110
166
  const clientSecretWarningKeys = /* @__PURE__ */ new Set();
167
+ const hardcodedSecretWarningKeys = /* @__PURE__ */ new Set();
111
168
  let aiFiles = 0;
112
169
  let largeFiles = 0;
113
170
  for (const file of files) {
114
- const fileResult = await safeReadFile(file);
115
171
  const relativeFile = normalizePath(path.relative(resolvedProjectPath, file));
172
+ const statResult = await safeStatFile(file);
173
+ if (!statResult.ok) {
174
+ issues.push({
175
+ severity: "info",
176
+ title: "File could not be checked",
177
+ message: statResult.reason,
178
+ file: relativeFile
179
+ });
180
+ continue;
181
+ }
182
+ if (statResult.size > MAX_FILE_SIZE_BYTES) {
183
+ largeFiles++;
184
+ issues.push({
185
+ severity: "info",
186
+ title: "Large file skipped from deep scan",
187
+ message: "This file is larger than 500KB and was skipped from deep content checks.",
188
+ file: relativeFile,
189
+ suggestion: "Review large generated or bundled files manually."
190
+ });
191
+ continue;
192
+ }
193
+ const fileResult = await safeReadFile(file);
116
194
  if (!fileResult.ok) {
117
195
  issues.push({
118
196
  severity: "info",
@@ -123,8 +201,7 @@ async function scanProject(projectPath) {
123
201
  continue;
124
202
  }
125
203
  const content = fileResult.content;
126
- readableFiles.set(file, content);
127
- if (content.length > 15e3) {
204
+ if (statResult.size > LARGE_FILE_WARNING_BYTES) {
128
205
  largeFiles++;
129
206
  issues.push({
130
207
  severity: "info",
@@ -150,6 +227,43 @@ async function scanProject(projectPath) {
150
227
  });
151
228
  }
152
229
  }
230
+ const isStripeWebhook = isStripeWebhookRoute(relativeFile, content);
231
+ const hasStripeSignatureVerification = hasStripeWebhookSignatureVerification(content);
232
+ if (isStripeWebhook && !hasStripeSignatureVerification) {
233
+ 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.",
237
+ file: relativeFile,
238
+ suggestion: "Use stripe.webhooks.constructEvent with the raw request body, Stripe-Signature header, and STRIPE_WEBHOOK_SECRET."
239
+ });
240
+ }
241
+ for (const secretMatch of getHardcodedSecretMatches(content)) {
242
+ const warningKey = `${relativeFile}:${secretMatch.label}`;
243
+ if (hardcodedSecretWarningKeys.has(warningKey)) {
244
+ continue;
245
+ }
246
+ hardcodedSecretWarningKeys.add(warningKey);
247
+ issues.push({
248
+ severity: "critical",
249
+ title: "Possible hardcoded secret",
250
+ message: `A string literal in ${relativeFile} matches the pattern for ${secretMatch.label}. Qodfy does not print possible secret values.`,
251
+ file: relativeFile,
252
+ suggestion: "Move secrets into environment variables and rotate the value if this is a real secret."
253
+ });
254
+ }
255
+ if (apiRouteSet.has(file)) {
256
+ const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
257
+ if (!hasAuth && !isStripeWebhook) {
258
+ issues.push({
259
+ severity: "warning",
260
+ title: "API route may be missing authentication",
261
+ message: "This API route does not appear to contain an auth/session check.",
262
+ file: relativeFile,
263
+ suggestion: "Confirm the route is public, or add an auth/session check before handling user data."
264
+ });
265
+ }
266
+ }
153
267
  const usedEnvVariables = getUsedEnvVariables(content);
154
268
  if (envExampleVariables) {
155
269
  for (const variableName of usedEnvVariables) {
@@ -188,23 +302,6 @@ async function scanProject(projectPath) {
188
302
  }
189
303
  }
190
304
  }
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
- }
207
- }
208
305
  const criticalCount = issues.filter((issue) => issue.severity === "critical").length;
209
306
  const warningCount = issues.filter((issue) => issue.severity === "warning").length;
210
307
  const score = Math.max(0, 100 - criticalCount * 20 - warningCount * 8);
@@ -217,7 +314,8 @@ async function scanProject(projectPath) {
217
314
  totalFiles: files.length,
218
315
  apiRoutes: apiRoutes.length,
219
316
  aiFiles,
220
- largeFiles
317
+ largeFiles,
318
+ durationMs: Date.now() - startTime
221
319
  }
222
320
  };
223
321
  }
@@ -258,6 +356,36 @@ async function safeReadFile(filePath) {
258
356
  };
259
357
  }
260
358
  }
359
+ async function safeStatFile(filePath) {
360
+ try {
361
+ const stats = await fs.stat(filePath);
362
+ return {
363
+ ok: true,
364
+ size: stats.size
365
+ };
366
+ } catch (error) {
367
+ const code = getErrorCode(error);
368
+ if (code === "ENOENT") {
369
+ return {
370
+ ok: false,
371
+ code,
372
+ reason: "The file disappeared while Qodfy was scanning it."
373
+ };
374
+ }
375
+ if (code === "EACCES" || code === "EPERM") {
376
+ return {
377
+ ok: false,
378
+ code,
379
+ reason: "Qodfy does not have permission to check this file."
380
+ };
381
+ }
382
+ return {
383
+ ok: false,
384
+ code,
385
+ reason: "Qodfy could not check this file."
386
+ };
387
+ }
388
+ }
261
389
  async function safeReadJson(filePath) {
262
390
  const fileResult = await safeReadFile(filePath);
263
391
  if (!fileResult.ok) {
@@ -312,18 +440,55 @@ function getUsedEnvVariables(content) {
312
440
  const variables = /* @__PURE__ */ new Set();
313
441
  const dotAccessPattern = /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
314
442
  const bracketAccessPattern = /\bprocess\.env\[['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\]/g;
443
+ const destructuredEnvPattern = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*process\.env\b/g;
315
444
  for (const match of content.matchAll(dotAccessPattern)) {
316
445
  variables.add(match[1]);
317
446
  }
318
447
  for (const match of content.matchAll(bracketAccessPattern)) {
319
448
  variables.add(match[1]);
320
449
  }
450
+ for (const match of content.matchAll(destructuredEnvPattern)) {
451
+ for (const variableName of parseDestructuredEnvNames(match[1])) {
452
+ variables.add(variableName);
453
+ }
454
+ }
321
455
  return variables;
322
456
  }
457
+ function parseDestructuredEnvNames(destructuredContent) {
458
+ const variables = [];
459
+ for (const part of destructuredContent.split(",")) {
460
+ const variableName = part.trim().split(":")[0].split("=")[0].trim();
461
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(variableName)) {
462
+ variables.push(variableName);
463
+ }
464
+ }
465
+ return variables;
466
+ }
467
+ function getHardcodedSecretMatches(content) {
468
+ const matches = [];
469
+ for (const secretPattern of hardcodedSecretPatterns) {
470
+ secretPattern.pattern.lastIndex = 0;
471
+ if (secretPattern.pattern.test(content)) {
472
+ matches.push({ label: secretPattern.label });
473
+ }
474
+ }
475
+ return matches;
476
+ }
323
477
  function isClientSideFile(relativeFile, content) {
324
478
  const fileName = path.basename(relativeFile);
325
479
  return fileName.includes(".client.") || /(^|\n)\s*["']use client["'];?/.test(content);
326
480
  }
481
+ 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}`);
483
+ }
484
+ function isStripeWebhookRoute(relativeFile, content) {
485
+ const normalizedFile = relativeFile.toLowerCase();
486
+ const normalizedContent = content.toLowerCase();
487
+ return normalizedContent.includes("stripe") && (normalizedFile.includes("webhook") || normalizedContent.includes("webhook") || normalizedContent.includes("stripe.webhooks"));
488
+ }
489
+ function hasStripeWebhookSignatureVerification(content) {
490
+ return content.includes("stripe.webhooks.constructEvent") || content.includes("webhooks.constructEvent") || content.includes("constructEvent(");
491
+ }
327
492
  function isPackageJsonObject(data) {
328
493
  return typeof data === "object" && data !== null && !Array.isArray(data);
329
494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qodfy/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Scanner engine for Qodfy, an open-source launch readiness scanner for AI-built apps.",
5
5
  "keywords": [
6
6
  "qodfy",