@qodfy/core 0.1.1 → 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 +2 -0
- package/dist/index.js +430 -63
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ type Issue = {
|
|
|
4
4
|
title: string;
|
|
5
5
|
message: string;
|
|
6
6
|
file?: string;
|
|
7
|
+
suggestion?: string;
|
|
7
8
|
};
|
|
8
9
|
type ScanReport = {
|
|
9
10
|
projectPath: string;
|
|
@@ -15,6 +16,7 @@ type ScanReport = {
|
|
|
15
16
|
apiRoutes: number;
|
|
16
17
|
aiFiles: number;
|
|
17
18
|
largeFiles: number;
|
|
19
|
+
durationMs: number;
|
|
18
20
|
};
|
|
19
21
|
};
|
|
20
22
|
declare function scanProject(projectPath: string): Promise<ScanReport>;
|
package/dist/index.js
CHANGED
|
@@ -1,56 +1,155 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import path from "path";
|
|
3
2
|
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
4
|
import fg from "fast-glob";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
var sourceFilePatterns = ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"];
|
|
6
|
+
var ignoredPaths = [
|
|
7
|
+
"node_modules/**",
|
|
8
|
+
".next/**",
|
|
9
|
+
"dist/**",
|
|
10
|
+
"build/**",
|
|
11
|
+
".turbo/**",
|
|
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__/**"
|
|
33
|
+
];
|
|
34
|
+
var aiKeywords = [
|
|
35
|
+
"openai",
|
|
36
|
+
"@ai-sdk",
|
|
37
|
+
"ai/react",
|
|
38
|
+
"anthropic",
|
|
39
|
+
"gemini",
|
|
40
|
+
"generateText",
|
|
41
|
+
"streamText",
|
|
42
|
+
"useChat"
|
|
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
|
|
11
76
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return JSON.parse(content);
|
|
16
|
-
}
|
|
77
|
+
];
|
|
78
|
+
var LARGE_FILE_WARNING_BYTES = 15e3;
|
|
79
|
+
var MAX_FILE_SIZE_BYTES = 500 * 1024;
|
|
17
80
|
async function scanProject(projectPath) {
|
|
81
|
+
const startTime = Date.now();
|
|
82
|
+
const resolvedProjectPath = path.resolve(projectPath);
|
|
18
83
|
const issues = [];
|
|
19
|
-
const packageJsonPath = path.join(
|
|
84
|
+
const packageJsonPath = path.join(resolvedProjectPath, "package.json");
|
|
20
85
|
const hasPackageJson = await fileExists(packageJsonPath);
|
|
86
|
+
let isNextProject = false;
|
|
21
87
|
if (!hasPackageJson) {
|
|
22
88
|
issues.push({
|
|
23
89
|
severity: "critical",
|
|
24
90
|
title: "Missing package.json",
|
|
25
|
-
message: "Qodfy could not find a package.json file in this project."
|
|
91
|
+
message: "Qodfy could not find a package.json file in this project.",
|
|
92
|
+
suggestion: "Run Qodfy from the project root or pass --path to the app folder."
|
|
26
93
|
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const packageJson = await readJson(packageJsonPath);
|
|
31
|
-
const deps = {
|
|
32
|
-
...packageJson.dependencies,
|
|
33
|
-
...packageJson.devDependencies
|
|
34
|
-
};
|
|
35
|
-
isNextProject = Boolean(deps.next);
|
|
36
|
-
if (!isNextProject) {
|
|
94
|
+
} else {
|
|
95
|
+
const packageJsonResult = await safeReadJson(packageJsonPath);
|
|
96
|
+
if (!packageJsonResult.ok) {
|
|
37
97
|
issues.push({
|
|
38
|
-
severity: "
|
|
39
|
-
title: "
|
|
40
|
-
message:
|
|
98
|
+
severity: "critical",
|
|
99
|
+
title: "Could not read package.json",
|
|
100
|
+
message: packageJsonResult.reason,
|
|
101
|
+
file: "package.json",
|
|
102
|
+
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
|
|
41
103
|
});
|
|
104
|
+
} else if (!isPackageJsonObject(packageJsonResult.data)) {
|
|
105
|
+
issues.push({
|
|
106
|
+
severity: "critical",
|
|
107
|
+
title: "Invalid package.json",
|
|
108
|
+
message: "package.json must contain a JSON object at the top level.",
|
|
109
|
+
file: "package.json",
|
|
110
|
+
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
const deps = {
|
|
114
|
+
...packageJsonResult.data.dependencies,
|
|
115
|
+
...packageJsonResult.data.devDependencies
|
|
116
|
+
};
|
|
117
|
+
isNextProject = Boolean(deps.next);
|
|
118
|
+
if (!isNextProject) {
|
|
119
|
+
issues.push({
|
|
120
|
+
severity: "warning",
|
|
121
|
+
title: "Next.js not detected",
|
|
122
|
+
message: "This first version of Qodfy is optimized for Next.js projects.",
|
|
123
|
+
suggestion: "If this is a monorepo, scan the Next.js app folder directly."
|
|
124
|
+
});
|
|
125
|
+
}
|
|
42
126
|
}
|
|
43
127
|
}
|
|
44
|
-
const envExamplePath = path.join(
|
|
128
|
+
const envExamplePath = path.join(resolvedProjectPath, ".env.example");
|
|
45
129
|
const hasEnvExample = await fileExists(envExamplePath);
|
|
130
|
+
let envExampleVariables = null;
|
|
46
131
|
if (!hasEnvExample) {
|
|
47
132
|
issues.push({
|
|
48
133
|
severity: "warning",
|
|
49
134
|
title: "Missing .env.example",
|
|
50
|
-
message: "Add a .env.example file so future developers know which environment variables are required."
|
|
135
|
+
message: "Add a .env.example file so future developers know which environment variables are required.",
|
|
136
|
+
suggestion: "Document required variable names only, never real secret values."
|
|
51
137
|
});
|
|
138
|
+
} else {
|
|
139
|
+
const envExampleResult = await safeReadFile(envExamplePath);
|
|
140
|
+
if (!envExampleResult.ok) {
|
|
141
|
+
issues.push({
|
|
142
|
+
severity: "warning",
|
|
143
|
+
title: "Could not read .env.example",
|
|
144
|
+
message: envExampleResult.reason,
|
|
145
|
+
file: ".env.example",
|
|
146
|
+
suggestion: "Make sure .env.example is readable and contains variable names without real secret values."
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
envExampleVariables = getEnvExampleVariables(envExampleResult.content);
|
|
150
|
+
}
|
|
52
151
|
}
|
|
53
|
-
const hasReadme = await fileExists(path.join(
|
|
152
|
+
const hasReadme = await fileExists(path.join(resolvedProjectPath, "README.md"));
|
|
54
153
|
if (!hasReadme) {
|
|
55
154
|
issues.push({
|
|
56
155
|
severity: "info",
|
|
@@ -58,35 +157,58 @@ async function scanProject(projectPath) {
|
|
|
58
157
|
message: "A README helps other developers understand how to run and maintain the project."
|
|
59
158
|
});
|
|
60
159
|
}
|
|
61
|
-
const files = await
|
|
62
|
-
cwd: projectPath,
|
|
63
|
-
ignore: ["node_modules/**", ".next/**", "dist/**", "build/**"],
|
|
64
|
-
absolute: true
|
|
65
|
-
});
|
|
160
|
+
const files = await getSourceFiles(resolvedProjectPath, issues);
|
|
66
161
|
const apiRoutes = files.filter((file) => {
|
|
67
|
-
return
|
|
162
|
+
return isApiRoute(file);
|
|
68
163
|
});
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"anthropic",
|
|
74
|
-
"gemini",
|
|
75
|
-
"generateText",
|
|
76
|
-
"streamText"
|
|
77
|
-
];
|
|
164
|
+
const apiRouteSet = new Set(apiRoutes);
|
|
165
|
+
const envExampleWarningKeys = /* @__PURE__ */ new Set();
|
|
166
|
+
const clientSecretWarningKeys = /* @__PURE__ */ new Set();
|
|
167
|
+
const hardcodedSecretWarningKeys = /* @__PURE__ */ new Set();
|
|
78
168
|
let aiFiles = 0;
|
|
79
169
|
let largeFiles = 0;
|
|
80
170
|
for (const file of files) {
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
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);
|
|
194
|
+
if (!fileResult.ok) {
|
|
195
|
+
issues.push({
|
|
196
|
+
severity: "info",
|
|
197
|
+
title: "File could not be read",
|
|
198
|
+
message: fileResult.reason,
|
|
199
|
+
file: relativeFile
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const content = fileResult.content;
|
|
204
|
+
if (statResult.size > LARGE_FILE_WARNING_BYTES) {
|
|
84
205
|
largeFiles++;
|
|
85
206
|
issues.push({
|
|
86
207
|
severity: "info",
|
|
87
208
|
title: "Large file detected",
|
|
88
209
|
message: "Large files are harder to maintain and often appear in AI-generated codebases.",
|
|
89
|
-
file: relativeFile
|
|
210
|
+
file: relativeFile,
|
|
211
|
+
suggestion: "Consider splitting this file into smaller modules if it mixes unrelated responsibilities."
|
|
90
212
|
});
|
|
91
213
|
}
|
|
92
214
|
const usesAI = aiKeywords.some(
|
|
@@ -95,34 +217,96 @@ async function scanProject(projectPath) {
|
|
|
95
217
|
if (usesAI) {
|
|
96
218
|
aiFiles++;
|
|
97
219
|
const hasRateLimit = content.includes("rateLimit") || content.includes("ratelimit") || content.includes("upstash") || content.includes("limiter");
|
|
98
|
-
if (!hasRateLimit) {
|
|
220
|
+
if (apiRouteSet.has(file) && !hasRateLimit) {
|
|
99
221
|
issues.push({
|
|
100
222
|
severity: "critical",
|
|
101
223
|
title: "AI route may be missing rate limiting",
|
|
102
224
|
message: "AI routes can create real API costs. Add rate limiting or usage limits before launch.",
|
|
103
|
-
file: relativeFile
|
|
225
|
+
file: relativeFile,
|
|
226
|
+
suggestion: "Add rate limiting, usage limits, or per-user quotas before launch."
|
|
104
227
|
});
|
|
105
228
|
}
|
|
106
229
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const relativeFile = path.relative(projectPath, route);
|
|
111
|
-
const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
|
|
112
|
-
if (!hasAuth) {
|
|
230
|
+
const isStripeWebhook = isStripeWebhookRoute(relativeFile, content);
|
|
231
|
+
const hasStripeSignatureVerification = hasStripeWebhookSignatureVerification(content);
|
|
232
|
+
if (isStripeWebhook && !hasStripeSignatureVerification) {
|
|
113
233
|
issues.push({
|
|
114
|
-
severity: "
|
|
115
|
-
title: "
|
|
116
|
-
message: "This
|
|
117
|
-
file: relativeFile
|
|
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."
|
|
118
239
|
});
|
|
119
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
|
+
}
|
|
267
|
+
const usedEnvVariables = getUsedEnvVariables(content);
|
|
268
|
+
if (envExampleVariables) {
|
|
269
|
+
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
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (isClientSideFile(relativeFile, content)) {
|
|
287
|
+
for (const variableName of usedEnvVariables) {
|
|
288
|
+
if (!variableName.startsWith("NEXT_PUBLIC_")) {
|
|
289
|
+
const warningKey = `${relativeFile}:${variableName}`;
|
|
290
|
+
if (clientSecretWarningKeys.has(warningKey)) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
clientSecretWarningKeys.add(warningKey);
|
|
294
|
+
issues.push({
|
|
295
|
+
severity: "warning",
|
|
296
|
+
title: "Possible server secret used in client-side code",
|
|
297
|
+
message: `${variableName} appears in a client-side file. Server secrets should not be exposed to the browser.`,
|
|
298
|
+
file: relativeFile,
|
|
299
|
+
suggestion: "Move server-only environment variable access to a server component, API route, or server action."
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
120
304
|
}
|
|
121
305
|
const criticalCount = issues.filter((issue) => issue.severity === "critical").length;
|
|
122
306
|
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
|
123
307
|
const score = Math.max(0, 100 - criticalCount * 20 - warningCount * 8);
|
|
124
308
|
return {
|
|
125
|
-
projectPath,
|
|
309
|
+
projectPath: resolvedProjectPath,
|
|
126
310
|
isNextProject,
|
|
127
311
|
score,
|
|
128
312
|
issues,
|
|
@@ -130,10 +314,193 @@ async function scanProject(projectPath) {
|
|
|
130
314
|
totalFiles: files.length,
|
|
131
315
|
apiRoutes: apiRoutes.length,
|
|
132
316
|
aiFiles,
|
|
133
|
-
largeFiles
|
|
317
|
+
largeFiles,
|
|
318
|
+
durationMs: Date.now() - startTime
|
|
134
319
|
}
|
|
135
320
|
};
|
|
136
321
|
}
|
|
322
|
+
async function fileExists(filePath) {
|
|
323
|
+
try {
|
|
324
|
+
await fs.access(filePath);
|
|
325
|
+
return true;
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async function safeReadFile(filePath) {
|
|
331
|
+
try {
|
|
332
|
+
return {
|
|
333
|
+
ok: true,
|
|
334
|
+
content: await fs.readFile(filePath, "utf-8")
|
|
335
|
+
};
|
|
336
|
+
} catch (error) {
|
|
337
|
+
const code = getErrorCode(error);
|
|
338
|
+
if (code === "ENOENT") {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
code,
|
|
342
|
+
reason: "The file disappeared while Qodfy was scanning it."
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
code,
|
|
349
|
+
reason: "Qodfy does not have permission to read this file."
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
code,
|
|
355
|
+
reason: "Qodfy could not read this file."
|
|
356
|
+
};
|
|
357
|
+
}
|
|
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
|
+
}
|
|
389
|
+
async function safeReadJson(filePath) {
|
|
390
|
+
const fileResult = await safeReadFile(filePath);
|
|
391
|
+
if (!fileResult.ok) {
|
|
392
|
+
return fileResult;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
return {
|
|
396
|
+
ok: true,
|
|
397
|
+
data: JSON.parse(fileResult.content)
|
|
398
|
+
};
|
|
399
|
+
} catch {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
reason: "package.json is not valid JSON."
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function getSourceFiles(projectPath, issues) {
|
|
407
|
+
try {
|
|
408
|
+
return await fg(sourceFilePatterns, {
|
|
409
|
+
cwd: projectPath,
|
|
410
|
+
ignore: ignoredPaths,
|
|
411
|
+
absolute: true,
|
|
412
|
+
onlyFiles: true,
|
|
413
|
+
dot: false
|
|
414
|
+
});
|
|
415
|
+
} catch {
|
|
416
|
+
issues.push({
|
|
417
|
+
severity: "critical",
|
|
418
|
+
title: "Could not scan source files",
|
|
419
|
+
message: "Qodfy could not list source files in this project.",
|
|
420
|
+
suggestion: "Check that the project path exists and is readable."
|
|
421
|
+
});
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function getEnvExampleVariables(content) {
|
|
426
|
+
const variables = /* @__PURE__ */ new Set();
|
|
427
|
+
for (const line of content.split(/\r?\n/)) {
|
|
428
|
+
const trimmedLine = line.trim();
|
|
429
|
+
if (!trimmedLine || trimmedLine.startsWith("#")) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const match = trimmedLine.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|$)/);
|
|
433
|
+
if (match) {
|
|
434
|
+
variables.add(match[1]);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return variables;
|
|
438
|
+
}
|
|
439
|
+
function getUsedEnvVariables(content) {
|
|
440
|
+
const variables = /* @__PURE__ */ new Set();
|
|
441
|
+
const dotAccessPattern = /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
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;
|
|
444
|
+
for (const match of content.matchAll(dotAccessPattern)) {
|
|
445
|
+
variables.add(match[1]);
|
|
446
|
+
}
|
|
447
|
+
for (const match of content.matchAll(bracketAccessPattern)) {
|
|
448
|
+
variables.add(match[1]);
|
|
449
|
+
}
|
|
450
|
+
for (const match of content.matchAll(destructuredEnvPattern)) {
|
|
451
|
+
for (const variableName of parseDestructuredEnvNames(match[1])) {
|
|
452
|
+
variables.add(variableName);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return variables;
|
|
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
|
+
}
|
|
477
|
+
function isClientSideFile(relativeFile, content) {
|
|
478
|
+
const fileName = path.basename(relativeFile);
|
|
479
|
+
return fileName.includes(".client.") || /(^|\n)\s*["']use client["'];?/.test(content);
|
|
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
|
+
}
|
|
492
|
+
function isPackageJsonObject(data) {
|
|
493
|
+
return typeof data === "object" && data !== null && !Array.isArray(data);
|
|
494
|
+
}
|
|
495
|
+
function normalizePath(filePath) {
|
|
496
|
+
return filePath.split(path.sep).join("/");
|
|
497
|
+
}
|
|
498
|
+
function getErrorCode(error) {
|
|
499
|
+
if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
|
|
500
|
+
return error.code;
|
|
501
|
+
}
|
|
502
|
+
return void 0;
|
|
503
|
+
}
|
|
137
504
|
export {
|
|
138
505
|
scanProject
|
|
139
506
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qodfy/core",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
|
-
"dist"
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md"
|
|
37
38
|
],
|
|
38
39
|
"license": "MIT",
|
|
39
40
|
"engines": {
|