@qodfy/core 0.1.6 → 0.2.1
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 +14 -2
- package/dist/index.js +448 -89
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
type IssueSeverity = "critical" | "warning" | "info";
|
|
2
|
+
type IssueCategory = "security" | "environment" | "api" | "webhook" | "ai" | "maintainability" | "project";
|
|
3
|
+
declare const validScanChecks: readonly ["project", "api", "environment", "ai", "webhook", "maintainability", "security"];
|
|
4
|
+
type ScanCheck = typeof validScanChecks[number];
|
|
5
|
+
declare const recommendedScanChecks: ScanCheck[];
|
|
2
6
|
type Issue = {
|
|
7
|
+
id: string;
|
|
8
|
+
ruleId: string;
|
|
9
|
+
category: IssueCategory;
|
|
3
10
|
severity: IssueSeverity;
|
|
4
11
|
title: string;
|
|
5
12
|
message: string;
|
|
6
13
|
file?: string;
|
|
7
14
|
suggestion?: string;
|
|
15
|
+
fixPrompt?: string;
|
|
8
16
|
};
|
|
9
17
|
type ScanReport = {
|
|
10
18
|
projectPath: string;
|
|
@@ -19,6 +27,10 @@ type ScanReport = {
|
|
|
19
27
|
durationMs: number;
|
|
20
28
|
};
|
|
21
29
|
};
|
|
22
|
-
|
|
30
|
+
type ScanOptions = {
|
|
31
|
+
projectPath: string;
|
|
32
|
+
checks?: ScanCheck[];
|
|
33
|
+
};
|
|
34
|
+
declare function scanProject(input: string | ScanOptions): Promise<ScanReport>;
|
|
23
35
|
|
|
24
|
-
export { type Issue, type IssueSeverity, type ScanReport, scanProject };
|
|
36
|
+
export { type Issue, type IssueCategory, type IssueSeverity, type ScanCheck, type ScanOptions, type ScanReport, recommendedScanChecks, scanProject, validScanChecks };
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
import fs from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import fg from "fast-glob";
|
|
5
|
+
var validScanChecks = [
|
|
6
|
+
"project",
|
|
7
|
+
"api",
|
|
8
|
+
"environment",
|
|
9
|
+
"ai",
|
|
10
|
+
"webhook",
|
|
11
|
+
"maintainability",
|
|
12
|
+
"security"
|
|
13
|
+
];
|
|
14
|
+
var recommendedScanChecks = [
|
|
15
|
+
"project",
|
|
16
|
+
"api",
|
|
17
|
+
"environment",
|
|
18
|
+
"ai",
|
|
19
|
+
"webhook",
|
|
20
|
+
"maintainability"
|
|
21
|
+
];
|
|
5
22
|
var sourceFilePatterns = ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"];
|
|
6
23
|
var ignoredPaths = [
|
|
7
24
|
"node_modules/**",
|
|
@@ -88,37 +105,77 @@ var hardcodedSecretPatterns = [
|
|
|
88
105
|
var LARGE_FILE_WARNING_BYTES = 40 * 1024;
|
|
89
106
|
var LARGE_FILE_REPORT_LIMIT = 10;
|
|
90
107
|
var MAX_FILE_SIZE_BYTES = 500 * 1024;
|
|
91
|
-
|
|
108
|
+
var issueIdPrefixes = {
|
|
109
|
+
"project-missing-package-json": "project-missing-package-json",
|
|
110
|
+
"project-invalid-package-json": "project-invalid-package-json",
|
|
111
|
+
"project-next-not-detected": "project-next-not-detected",
|
|
112
|
+
"project-missing-readme": "project-missing-readme",
|
|
113
|
+
"environment-missing-env-example": "environment-missing-env-example",
|
|
114
|
+
"environment-variable-missing-from-example": "environment-variable-missing-from-example",
|
|
115
|
+
"security-client-side-secret": "security-client-side-secret",
|
|
116
|
+
"security-hardcoded-secret": "security-hardcoded-secret",
|
|
117
|
+
"api-route-missing-auth": "security-api-auth",
|
|
118
|
+
"ai-route-missing-rate-limit": "ai-route-rate-limit",
|
|
119
|
+
"maintainability-large-file": "maintainability-large-file",
|
|
120
|
+
"maintainability-large-file-skipped": "maintainability-large-file-skipped",
|
|
121
|
+
"maintainability-file-unreadable": "maintainability-file-unreadable",
|
|
122
|
+
"project-source-files-unreadable": "project-source-files-unreadable",
|
|
123
|
+
"webhook-missing-signature-verification": "webhook-signature-verification"
|
|
124
|
+
};
|
|
125
|
+
async function scanProject(input) {
|
|
92
126
|
const startTime = Date.now();
|
|
127
|
+
const projectPath = typeof input === "string" ? input : input.projectPath;
|
|
128
|
+
const enabledChecks = getEnabledChecks(
|
|
129
|
+
typeof input === "string" ? void 0 : input.checks
|
|
130
|
+
);
|
|
93
131
|
const resolvedProjectPath = path.resolve(projectPath);
|
|
94
132
|
const issues = [];
|
|
133
|
+
const addIssue = createIssueFactory(issues);
|
|
134
|
+
const runProjectChecks = hasCheck(enabledChecks, "project");
|
|
135
|
+
const runEnvironmentChecks = hasCheck(enabledChecks, "environment");
|
|
136
|
+
const runApiChecks = hasCheck(enabledChecks, "api") || hasCheck(enabledChecks, "security");
|
|
137
|
+
const runAiChecks = hasCheck(enabledChecks, "ai");
|
|
138
|
+
const runWebhookChecks = hasCheck(enabledChecks, "webhook") || hasCheck(enabledChecks, "security");
|
|
139
|
+
const runMaintainabilityChecks = hasCheck(enabledChecks, "maintainability");
|
|
140
|
+
const runSecurityChecks = hasCheck(enabledChecks, "security");
|
|
141
|
+
const shouldScanSourceFiles = enabledChecks.size > 0 && !onlyHasCheck(enabledChecks, "project");
|
|
142
|
+
const shouldReadSourceContent = runEnvironmentChecks || runApiChecks || runAiChecks || runWebhookChecks || runSecurityChecks;
|
|
95
143
|
const packageJsonPath = path.join(resolvedProjectPath, "package.json");
|
|
96
144
|
const hasPackageJson = await fileExists(packageJsonPath);
|
|
97
145
|
let isNextProject = false;
|
|
98
|
-
if (!hasPackageJson) {
|
|
99
|
-
|
|
146
|
+
if (runProjectChecks && !hasPackageJson) {
|
|
147
|
+
addIssue({
|
|
148
|
+
ruleId: "project-missing-package-json",
|
|
149
|
+
category: "project",
|
|
100
150
|
severity: "critical",
|
|
101
151
|
title: "Missing package.json",
|
|
102
152
|
message: "Qodfy could not find a package.json file in this project.",
|
|
103
|
-
suggestion: "Run Qodfy from the project root or pass --path to the app folder."
|
|
153
|
+
suggestion: "Run Qodfy from the project root or pass --path to the app folder.",
|
|
154
|
+
fixPrompt: createProjectRootFixPrompt()
|
|
104
155
|
});
|
|
105
|
-
} else {
|
|
156
|
+
} else if (runProjectChecks) {
|
|
106
157
|
const packageJsonResult = await safeReadJson(packageJsonPath);
|
|
107
158
|
if (!packageJsonResult.ok) {
|
|
108
|
-
|
|
159
|
+
addIssue({
|
|
160
|
+
ruleId: "project-invalid-package-json",
|
|
161
|
+
category: "project",
|
|
109
162
|
severity: "critical",
|
|
110
163
|
title: "Could not read package.json",
|
|
111
164
|
message: packageJsonResult.reason,
|
|
112
165
|
file: "package.json",
|
|
113
|
-
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
|
|
166
|
+
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies.",
|
|
167
|
+
fixPrompt: createPackageJsonFixPrompt()
|
|
114
168
|
});
|
|
115
169
|
} else if (!isPackageJsonObject(packageJsonResult.data)) {
|
|
116
|
-
|
|
170
|
+
addIssue({
|
|
171
|
+
ruleId: "project-invalid-package-json",
|
|
172
|
+
category: "project",
|
|
117
173
|
severity: "critical",
|
|
118
174
|
title: "Invalid package.json",
|
|
119
175
|
message: "package.json must contain a JSON object at the top level.",
|
|
120
176
|
file: "package.json",
|
|
121
|
-
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies."
|
|
177
|
+
suggestion: "Fix package.json so Qodfy can detect the framework and dependencies.",
|
|
178
|
+
fixPrompt: createPackageJsonFixPrompt()
|
|
122
179
|
});
|
|
123
180
|
} else {
|
|
124
181
|
const deps = {
|
|
@@ -127,48 +184,60 @@ async function scanProject(projectPath) {
|
|
|
127
184
|
};
|
|
128
185
|
isNextProject = Boolean(deps.next);
|
|
129
186
|
if (!isNextProject) {
|
|
130
|
-
|
|
187
|
+
addIssue({
|
|
188
|
+
ruleId: "project-next-not-detected",
|
|
189
|
+
category: "project",
|
|
131
190
|
severity: "warning",
|
|
132
191
|
title: "Next.js not detected",
|
|
133
192
|
message: "This first version of Qodfy is optimized for Next.js projects.",
|
|
134
|
-
suggestion: "If this is a monorepo, scan the Next.js app folder directly."
|
|
193
|
+
suggestion: "If this is a monorepo, scan the Next.js app folder directly.",
|
|
194
|
+
fixPrompt: createNextNotDetectedFixPrompt()
|
|
135
195
|
});
|
|
136
196
|
}
|
|
137
197
|
}
|
|
138
198
|
}
|
|
139
199
|
const envExamplePath = path.join(resolvedProjectPath, ".env.example");
|
|
140
|
-
const hasEnvExample = await fileExists(envExamplePath);
|
|
200
|
+
const hasEnvExample = runEnvironmentChecks ? await fileExists(envExamplePath) : false;
|
|
141
201
|
let envExampleVariables = null;
|
|
142
|
-
if (!hasEnvExample) {
|
|
143
|
-
|
|
202
|
+
if (runEnvironmentChecks && !hasEnvExample) {
|
|
203
|
+
addIssue({
|
|
204
|
+
ruleId: "environment-missing-env-example",
|
|
205
|
+
category: "environment",
|
|
144
206
|
severity: "warning",
|
|
145
207
|
title: "Missing .env.example",
|
|
146
208
|
message: "Add a .env.example file so future developers know which environment variables are required.",
|
|
147
|
-
suggestion: "Document required variable names only, never real secret values."
|
|
209
|
+
suggestion: "Document required variable names only, never real secret values.",
|
|
210
|
+
fixPrompt: createMissingEnvExampleFixPrompt()
|
|
148
211
|
});
|
|
149
|
-
} else {
|
|
212
|
+
} else if (runEnvironmentChecks) {
|
|
150
213
|
const envExampleResult = await safeReadFile(envExamplePath);
|
|
151
214
|
if (!envExampleResult.ok) {
|
|
152
|
-
|
|
215
|
+
addIssue({
|
|
216
|
+
ruleId: "environment-missing-env-example",
|
|
217
|
+
category: "environment",
|
|
153
218
|
severity: "warning",
|
|
154
219
|
title: "Could not read .env.example",
|
|
155
220
|
message: envExampleResult.reason,
|
|
156
221
|
file: ".env.example",
|
|
157
|
-
suggestion: "Make sure .env.example is readable and contains variable names without real secret values."
|
|
222
|
+
suggestion: "Make sure .env.example is readable and contains variable names without real secret values.",
|
|
223
|
+
fixPrompt: createMissingEnvExampleFixPrompt()
|
|
158
224
|
});
|
|
159
225
|
} else {
|
|
160
226
|
envExampleVariables = getEnvExampleVariables(envExampleResult.content);
|
|
161
227
|
}
|
|
162
228
|
}
|
|
163
|
-
const hasReadme = await fileExists(path.join(resolvedProjectPath, "README.md"));
|
|
164
|
-
if (!hasReadme) {
|
|
165
|
-
|
|
229
|
+
const hasReadme = runProjectChecks ? await fileExists(path.join(resolvedProjectPath, "README.md")) : true;
|
|
230
|
+
if (runProjectChecks && !hasReadme) {
|
|
231
|
+
addIssue({
|
|
232
|
+
ruleId: "project-missing-readme",
|
|
233
|
+
category: "project",
|
|
166
234
|
severity: "info",
|
|
167
235
|
title: "Missing README.md",
|
|
168
|
-
message: "A README helps other developers understand how to run and maintain the project."
|
|
236
|
+
message: "A README helps other developers understand how to run and maintain the project.",
|
|
237
|
+
fixPrompt: createReadmeFixPrompt()
|
|
169
238
|
});
|
|
170
239
|
}
|
|
171
|
-
const files = await getSourceFiles(resolvedProjectPath,
|
|
240
|
+
const files = shouldScanSourceFiles ? await getSourceFiles(resolvedProjectPath, addIssue) : [];
|
|
172
241
|
const apiRoutes = files.filter((file) => {
|
|
173
242
|
return isApiRoute(file);
|
|
174
243
|
});
|
|
@@ -183,97 +252,129 @@ async function scanProject(projectPath) {
|
|
|
183
252
|
const relativeFile = normalizePath(path.relative(resolvedProjectPath, file));
|
|
184
253
|
const statResult = await safeStatFile(file);
|
|
185
254
|
if (!statResult.ok) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
255
|
+
if (runMaintainabilityChecks) {
|
|
256
|
+
addIssue({
|
|
257
|
+
ruleId: "maintainability-file-unreadable",
|
|
258
|
+
category: "maintainability",
|
|
259
|
+
severity: "info",
|
|
260
|
+
title: "File could not be checked",
|
|
261
|
+
message: statResult.reason,
|
|
262
|
+
file: relativeFile,
|
|
263
|
+
suggestion: "Check file permissions if this file should be included in launch-readiness scans."
|
|
264
|
+
});
|
|
265
|
+
}
|
|
192
266
|
continue;
|
|
193
267
|
}
|
|
194
268
|
if (statResult.size > MAX_FILE_SIZE_BYTES) {
|
|
269
|
+
if (runMaintainabilityChecks) {
|
|
270
|
+
largeFiles++;
|
|
271
|
+
addIssue({
|
|
272
|
+
ruleId: "maintainability-large-file-skipped",
|
|
273
|
+
category: "maintainability",
|
|
274
|
+
severity: "info",
|
|
275
|
+
title: "Large file skipped from deep scan",
|
|
276
|
+
message: "This file is larger than 500KB and was skipped from deep content checks.",
|
|
277
|
+
file: relativeFile,
|
|
278
|
+
suggestion: "Review large generated or bundled files manually.",
|
|
279
|
+
fixPrompt: createLargeFileFixPrompt(relativeFile)
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (runMaintainabilityChecks && statResult.size > LARGE_FILE_WARNING_BYTES) {
|
|
195
285
|
largeFiles++;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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."
|
|
286
|
+
largeFileCandidates.push({
|
|
287
|
+
relativeFile,
|
|
288
|
+
size: statResult.size
|
|
202
289
|
});
|
|
290
|
+
}
|
|
291
|
+
if (!shouldReadSourceContent) {
|
|
203
292
|
continue;
|
|
204
293
|
}
|
|
205
294
|
const fileResult = await safeReadFile(file);
|
|
206
295
|
if (!fileResult.ok) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
296
|
+
if (runMaintainabilityChecks) {
|
|
297
|
+
addIssue({
|
|
298
|
+
ruleId: "maintainability-file-unreadable",
|
|
299
|
+
category: "maintainability",
|
|
300
|
+
severity: "info",
|
|
301
|
+
title: "File could not be read",
|
|
302
|
+
message: fileResult.reason,
|
|
303
|
+
file: relativeFile,
|
|
304
|
+
suggestion: "Check file permissions if this file should be included in launch-readiness scans."
|
|
305
|
+
});
|
|
306
|
+
}
|
|
213
307
|
continue;
|
|
214
308
|
}
|
|
215
309
|
const content = fileResult.content;
|
|
216
|
-
if (statResult.size > LARGE_FILE_WARNING_BYTES) {
|
|
217
|
-
largeFiles++;
|
|
218
|
-
largeFileCandidates.push({
|
|
219
|
-
relativeFile,
|
|
220
|
-
size: statResult.size
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
310
|
const usesAI = aiKeywords.some(
|
|
224
311
|
(keyword) => content.toLowerCase().includes(keyword.toLowerCase())
|
|
225
312
|
);
|
|
226
|
-
if (usesAI) {
|
|
313
|
+
if (runAiChecks && usesAI) {
|
|
227
314
|
aiFiles++;
|
|
228
315
|
const hasRateLimit = content.includes("rateLimit") || content.includes("ratelimit") || content.includes("upstash") || content.includes("limiter");
|
|
229
316
|
if (apiRouteSet.has(file) && !hasRateLimit) {
|
|
230
|
-
|
|
317
|
+
addIssue({
|
|
318
|
+
ruleId: "ai-route-missing-rate-limit",
|
|
319
|
+
category: "ai",
|
|
231
320
|
severity: "critical",
|
|
232
321
|
title: "AI route may be missing rate limiting",
|
|
233
322
|
message: "AI routes can create real API costs. Add rate limiting or usage limits before launch.",
|
|
234
323
|
file: relativeFile,
|
|
235
|
-
suggestion: "Add rate limiting, usage limits, or per-user quotas before launch."
|
|
324
|
+
suggestion: "Add rate limiting, usage limits, or per-user quotas before launch.",
|
|
325
|
+
fixPrompt: createAiRateLimitFixPrompt(relativeFile)
|
|
236
326
|
});
|
|
237
327
|
}
|
|
238
328
|
}
|
|
239
|
-
const webhookRouteInfo = apiRouteSet.has(file) ? getWebhookRouteInfo(relativeFile, content) : null;
|
|
329
|
+
const webhookRouteInfo = runWebhookChecks && apiRouteSet.has(file) ? getWebhookRouteInfo(relativeFile, content) : null;
|
|
240
330
|
if (webhookRouteInfo && !hasWebhookSignatureVerification(content, webhookRouteInfo.provider)) {
|
|
241
|
-
|
|
331
|
+
addIssue({
|
|
332
|
+
ruleId: "webhook-missing-signature-verification",
|
|
333
|
+
category: "webhook",
|
|
242
334
|
severity: webhookRouteInfo.confidence === "high" ? "critical" : "warning",
|
|
243
335
|
title: "Webhook route may be missing signature verification",
|
|
244
336
|
message: "This webhook route appears to handle external events, but Qodfy could not find signature verification before the event is handled.",
|
|
245
337
|
file: relativeFile,
|
|
246
|
-
suggestion: getWebhookSignatureSuggestion(webhookRouteInfo.provider)
|
|
338
|
+
suggestion: getWebhookSignatureSuggestion(webhookRouteInfo.provider),
|
|
339
|
+
fixPrompt: createWebhookSignatureFixPrompt(relativeFile)
|
|
247
340
|
});
|
|
248
341
|
}
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
342
|
+
if (runSecurityChecks) {
|
|
343
|
+
for (const secretMatch of getHardcodedSecretMatches(content)) {
|
|
344
|
+
const warningKey = `${relativeFile}:${secretMatch.label}`;
|
|
345
|
+
if (hardcodedSecretWarningKeys.has(warningKey)) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
hardcodedSecretWarningKeys.add(warningKey);
|
|
349
|
+
addIssue({
|
|
350
|
+
ruleId: "security-hardcoded-secret",
|
|
351
|
+
category: "security",
|
|
352
|
+
severity: "critical",
|
|
353
|
+
title: "Possible hardcoded secret",
|
|
354
|
+
message: `A string literal in ${relativeFile} matches the pattern for ${secretMatch.label}. Qodfy does not print possible secret values.`,
|
|
355
|
+
file: relativeFile,
|
|
356
|
+
suggestion: "Move secrets into environment variables and rotate the value if this is a real secret.",
|
|
357
|
+
fixPrompt: createHardcodedSecretFixPrompt(relativeFile, secretMatch.label)
|
|
358
|
+
});
|
|
253
359
|
}
|
|
254
|
-
hardcodedSecretWarningKeys.add(warningKey);
|
|
255
|
-
issues.push({
|
|
256
|
-
severity: "critical",
|
|
257
|
-
title: "Possible hardcoded secret",
|
|
258
|
-
message: `A string literal in ${relativeFile} matches the pattern for ${secretMatch.label}. Qodfy does not print possible secret values.`,
|
|
259
|
-
file: relativeFile,
|
|
260
|
-
suggestion: "Move secrets into environment variables and rotate the value if this is a real secret."
|
|
261
|
-
});
|
|
262
360
|
}
|
|
263
|
-
if (apiRouteSet.has(file)) {
|
|
361
|
+
if (runApiChecks && apiRouteSet.has(file)) {
|
|
264
362
|
const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
|
|
265
363
|
if (!hasAuth && !webhookRouteInfo) {
|
|
266
|
-
|
|
364
|
+
addIssue({
|
|
365
|
+
ruleId: "api-route-missing-auth",
|
|
366
|
+
category: "api",
|
|
267
367
|
severity: "warning",
|
|
268
368
|
title: "API route may be missing authentication",
|
|
269
369
|
message: "This API route does not appear to contain an auth/session check.",
|
|
270
370
|
file: relativeFile,
|
|
271
|
-
suggestion: "Confirm the route is public, or add an auth/session check before handling user data."
|
|
371
|
+
suggestion: "Confirm the route is public, or add an auth/session check before handling user data.",
|
|
372
|
+
fixPrompt: createApiAuthFixPrompt(relativeFile)
|
|
272
373
|
});
|
|
273
374
|
}
|
|
274
375
|
}
|
|
275
|
-
const usedEnvVariables = getUsedEnvVariables(content);
|
|
276
|
-
if (envExampleVariables) {
|
|
376
|
+
const usedEnvVariables = runEnvironmentChecks || runSecurityChecks ? getUsedEnvVariables(content) : /* @__PURE__ */ new Set();
|
|
377
|
+
if (runEnvironmentChecks && envExampleVariables) {
|
|
277
378
|
for (const variableName of usedEnvVariables) {
|
|
278
379
|
if (shouldIgnoreEnvVariable(variableName) || envExampleVariables.has(variableName)) {
|
|
279
380
|
continue;
|
|
@@ -283,7 +384,7 @@ async function scanProject(projectPath) {
|
|
|
283
384
|
missingEnvUsages.set(variableName, filesUsingVariable);
|
|
284
385
|
}
|
|
285
386
|
}
|
|
286
|
-
if (isClientSideFile(relativeFile, content)) {
|
|
387
|
+
if (runSecurityChecks && isClientSideFile(relativeFile, content)) {
|
|
287
388
|
for (const variableName of usedEnvVariables) {
|
|
288
389
|
if (!variableName.startsWith("NEXT_PUBLIC_") && !shouldIgnoreEnvVariable(variableName)) {
|
|
289
390
|
const warningKey = `${relativeFile}:${variableName}`;
|
|
@@ -291,34 +392,43 @@ async function scanProject(projectPath) {
|
|
|
291
392
|
continue;
|
|
292
393
|
}
|
|
293
394
|
clientSecretWarningKeys.add(warningKey);
|
|
294
|
-
|
|
395
|
+
addIssue({
|
|
396
|
+
ruleId: "security-client-side-secret",
|
|
397
|
+
category: "security",
|
|
295
398
|
severity: "warning",
|
|
296
399
|
title: "Possible server secret used in client-side code",
|
|
297
400
|
message: `${variableName} appears in a client-side file. Server secrets should not be exposed to the browser.`,
|
|
298
401
|
file: relativeFile,
|
|
299
|
-
suggestion: "Move server-only environment variable access to a server component, API route, or server action."
|
|
402
|
+
suggestion: "Move server-only environment variable access to a server component, API route, or server action.",
|
|
403
|
+
fixPrompt: createClientSideSecretFixPrompt(relativeFile, variableName)
|
|
300
404
|
});
|
|
301
405
|
}
|
|
302
406
|
}
|
|
303
407
|
}
|
|
304
408
|
}
|
|
305
409
|
for (const largeFile of getReportedLargeFiles(largeFileCandidates)) {
|
|
306
|
-
|
|
410
|
+
addIssue({
|
|
411
|
+
ruleId: "maintainability-large-file",
|
|
412
|
+
category: "maintainability",
|
|
307
413
|
severity: "info",
|
|
308
414
|
title: "Large file detected",
|
|
309
|
-
message: "Large files
|
|
415
|
+
message: "This file is larger than the recommended maintainability threshold. Large files can be harder to review, test, and safely modify.",
|
|
310
416
|
file: largeFile.relativeFile,
|
|
311
|
-
suggestion: "
|
|
417
|
+
suggestion: "Review whether this file mixes UI, state, data fetching, validation, or business logic. If so, split it into smaller components, hooks, or utilities.",
|
|
418
|
+
fixPrompt: createLargeFileFixPrompt(largeFile.relativeFile)
|
|
312
419
|
});
|
|
313
420
|
}
|
|
314
421
|
for (const [variableName, filesUsingVariable] of getSortedMissingEnvUsages(missingEnvUsages)) {
|
|
315
422
|
const files2 = [...filesUsingVariable].sort();
|
|
316
|
-
|
|
423
|
+
addIssue({
|
|
424
|
+
ruleId: "environment-variable-missing-from-example",
|
|
425
|
+
category: "environment",
|
|
317
426
|
severity: "warning",
|
|
318
427
|
title: "Environment variable missing from .env.example",
|
|
319
428
|
message: getMissingEnvMessage(variableName, files2),
|
|
320
429
|
file: files2.length === 1 ? files2[0] : void 0,
|
|
321
|
-
suggestion: `Add ${variableName}= to .env.example without including a real value
|
|
430
|
+
suggestion: `Add ${variableName}= to .env.example without including a real value.`,
|
|
431
|
+
fixPrompt: createMissingEnvVariableFixPrompt(variableName, files2)
|
|
322
432
|
});
|
|
323
433
|
}
|
|
324
434
|
const criticalCount = issues.filter((issue) => issue.severity === "critical").length;
|
|
@@ -339,6 +449,30 @@ async function scanProject(projectPath) {
|
|
|
339
449
|
}
|
|
340
450
|
};
|
|
341
451
|
}
|
|
452
|
+
function createIssueFactory(issues) {
|
|
453
|
+
const issueCounts = /* @__PURE__ */ new Map();
|
|
454
|
+
return (issue) => {
|
|
455
|
+
const currentCount = (issueCounts.get(issue.ruleId) ?? 0) + 1;
|
|
456
|
+
issueCounts.set(issue.ruleId, currentCount);
|
|
457
|
+
issues.push({
|
|
458
|
+
id: `${getIssueIdPrefix(issue.ruleId, issue.category)}-${currentCount}`,
|
|
459
|
+
...issue
|
|
460
|
+
});
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function getIssueIdPrefix(ruleId, category) {
|
|
464
|
+
return issueIdPrefixes[ruleId] ?? `${category}-${ruleId}`;
|
|
465
|
+
}
|
|
466
|
+
function getEnabledChecks(checks) {
|
|
467
|
+
const checksToEnable = checks && checks.length > 0 ? checks : recommendedScanChecks;
|
|
468
|
+
return new Set(checksToEnable);
|
|
469
|
+
}
|
|
470
|
+
function hasCheck(enabledChecks, check) {
|
|
471
|
+
return enabledChecks.has(check);
|
|
472
|
+
}
|
|
473
|
+
function onlyHasCheck(enabledChecks, check) {
|
|
474
|
+
return enabledChecks.size === 1 && enabledChecks.has(check);
|
|
475
|
+
}
|
|
342
476
|
async function fileExists(filePath) {
|
|
343
477
|
try {
|
|
344
478
|
await fs.access(filePath);
|
|
@@ -423,21 +557,27 @@ async function safeReadJson(filePath) {
|
|
|
423
557
|
};
|
|
424
558
|
}
|
|
425
559
|
}
|
|
426
|
-
async function getSourceFiles(projectPath,
|
|
560
|
+
async function getSourceFiles(projectPath, addIssue) {
|
|
427
561
|
try {
|
|
428
|
-
|
|
562
|
+
const files = await fg(sourceFilePatterns, {
|
|
429
563
|
cwd: projectPath,
|
|
430
564
|
ignore: ignoredPaths,
|
|
431
565
|
absolute: true,
|
|
432
566
|
onlyFiles: true,
|
|
433
567
|
dot: false
|
|
434
568
|
});
|
|
569
|
+
return files.sort(
|
|
570
|
+
(leftFile, rightFile) => normalizePath(leftFile).localeCompare(normalizePath(rightFile))
|
|
571
|
+
);
|
|
435
572
|
} catch {
|
|
436
|
-
|
|
573
|
+
addIssue({
|
|
574
|
+
ruleId: "project-source-files-unreadable",
|
|
575
|
+
category: "project",
|
|
437
576
|
severity: "critical",
|
|
438
577
|
title: "Could not scan source files",
|
|
439
578
|
message: "Qodfy could not list source files in this project.",
|
|
440
|
-
suggestion: "Check that the project path exists and is readable."
|
|
579
|
+
suggestion: "Check that the project path exists and is readable.",
|
|
580
|
+
fixPrompt: createProjectRootFixPrompt()
|
|
441
581
|
});
|
|
442
582
|
return [];
|
|
443
583
|
}
|
|
@@ -533,8 +673,8 @@ function getWebhookRouteInfo(relativeFile, content) {
|
|
|
533
673
|
const normalizedContent = content.toLowerCase();
|
|
534
674
|
const normalizedRouteContext = `${normalizedFile}
|
|
535
675
|
${normalizedContent}`;
|
|
536
|
-
const pathLooksLikeWebhook =
|
|
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
|
|
676
|
+
const pathLooksLikeWebhook = /(^|[\/._-])(webhook|webhooks|callback)([\/._-]|$)/.test(normalizedFile);
|
|
677
|
+
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") || /\bresend\b/.test(normalizedContent) && /\bwebhook\b/.test(normalizedContent) || /\bwebhook_secret\b/.test(normalizedContent) || /\bwebhooksecret\b/.test(normalizedContent) || /\bwebhook\b/.test(normalizedContent) && /\b(signature|secret|event|payload)\b/.test(normalizedContent);
|
|
538
678
|
if (!pathLooksLikeWebhook && !contentStronglySuggestsWebhook) {
|
|
539
679
|
return null;
|
|
540
680
|
}
|
|
@@ -545,13 +685,13 @@ ${normalizedContent}`;
|
|
|
545
685
|
};
|
|
546
686
|
}
|
|
547
687
|
function getWebhookProvider(normalizedRouteContext) {
|
|
548
|
-
if (normalizedRouteContext.includes("stripe-signature") || normalizedRouteContext.includes("stripe_webhook_secret") || normalizedRouteContext.includes("stripe.webhooks") || normalizedRouteContext.includes("constructevent(") || normalizedRouteContext
|
|
688
|
+
if (normalizedRouteContext.includes("stripe-signature") || normalizedRouteContext.includes("stripe_webhook_secret") || normalizedRouteContext.includes("stripe.webhooks") || normalizedRouteContext.includes("constructevent(") || /\bstripe\b/.test(normalizedRouteContext) && /\bwebhook\b/.test(normalizedRouteContext)) {
|
|
549
689
|
return "stripe";
|
|
550
690
|
}
|
|
551
|
-
if (normalizedRouteContext
|
|
691
|
+
if (/\bresend\b/.test(normalizedRouteContext) && /\bwebhook\b/.test(normalizedRouteContext)) {
|
|
552
692
|
return "resend";
|
|
553
693
|
}
|
|
554
|
-
if (normalizedRouteContext.includes("clerk_webhook_secret") || normalizedRouteContext
|
|
694
|
+
if (normalizedRouteContext.includes("clerk_webhook_secret") || /\bclerk\b/.test(normalizedRouteContext) && /\bwebhook\b/.test(normalizedRouteContext)) {
|
|
555
695
|
return "clerk";
|
|
556
696
|
}
|
|
557
697
|
if (normalizedRouteContext.includes("x-github-event") || normalizedRouteContext.includes("x-hub-signature")) {
|
|
@@ -602,6 +742,223 @@ function getWebhookSignatureSuggestion(provider) {
|
|
|
602
742
|
}
|
|
603
743
|
return "Verify the provider signature using the raw request body and signature header before trusting the event.";
|
|
604
744
|
}
|
|
745
|
+
function createApiAuthFixPrompt(file) {
|
|
746
|
+
return `Review the API route at ${file}.
|
|
747
|
+
|
|
748
|
+
Goal:
|
|
749
|
+
Determine whether this route should be public or protected.
|
|
750
|
+
|
|
751
|
+
Instructions:
|
|
752
|
+
- Inspect the existing authentication/session pattern used in this project.
|
|
753
|
+
- If this route handles private data, user-specific data, uploads, writes, or admin actions, add the existing auth/session check.
|
|
754
|
+
- Do not introduce a new auth provider.
|
|
755
|
+
- Do not refactor unrelated code.
|
|
756
|
+
- Keep the current behavior unchanged.
|
|
757
|
+
- If this route is intentionally public, add a short comment explaining why.
|
|
758
|
+
|
|
759
|
+
Return:
|
|
760
|
+
- A short explanation of what you changed.
|
|
761
|
+
- The updated code.
|
|
762
|
+
- Any edge cases I should test.`;
|
|
763
|
+
}
|
|
764
|
+
function createMissingEnvVariableFixPrompt(variableName, files) {
|
|
765
|
+
return `Update the environment documentation for this project.
|
|
766
|
+
|
|
767
|
+
The variable ${variableName} is used in ${formatPromptFileList(files)} but is missing from .env.example.
|
|
768
|
+
|
|
769
|
+
Instructions:
|
|
770
|
+
- Add ${variableName}= to .env.example.
|
|
771
|
+
- Do not add a real secret value.
|
|
772
|
+
- Check if related environment variables used in the same file should also be documented.
|
|
773
|
+
- Keep comments clear and safe for public repos.
|
|
774
|
+
|
|
775
|
+
Return:
|
|
776
|
+
- The updated .env.example lines.
|
|
777
|
+
- A short explanation.`;
|
|
778
|
+
}
|
|
779
|
+
function createLargeFileFixPrompt(file) {
|
|
780
|
+
return `Review ${file}.
|
|
781
|
+
|
|
782
|
+
Qodfy detected this as a large file.
|
|
783
|
+
|
|
784
|
+
Goal:
|
|
785
|
+
Suggest a safe refactor plan without changing behavior.
|
|
786
|
+
|
|
787
|
+
Instructions:
|
|
788
|
+
- Identify the main responsibilities inside the file.
|
|
789
|
+
- Suggest smaller components, hooks, or utility files that can be extracted.
|
|
790
|
+
- Do not rewrite the whole file at once.
|
|
791
|
+
- Do not change UI behavior.
|
|
792
|
+
- Do not change business logic.
|
|
793
|
+
- Prioritize low-risk extractions first.
|
|
794
|
+
|
|
795
|
+
Return:
|
|
796
|
+
- A short responsibility breakdown.
|
|
797
|
+
- A step-by-step refactor plan.
|
|
798
|
+
- The safest first extraction.`;
|
|
799
|
+
}
|
|
800
|
+
function createAiRateLimitFixPrompt(file) {
|
|
801
|
+
return `Review the AI-related API route at ${file}.
|
|
802
|
+
|
|
803
|
+
Goal:
|
|
804
|
+
Add cost and abuse protection safely.
|
|
805
|
+
|
|
806
|
+
Instructions:
|
|
807
|
+
- Check the existing project patterns for auth, usage limits, or rate limiting.
|
|
808
|
+
- If this route can be called by users, add rate limiting or per-user usage protection.
|
|
809
|
+
- Do not introduce a new service unless necessary.
|
|
810
|
+
- Do not change the AI provider or model behavior.
|
|
811
|
+
- Keep the current response format unchanged.
|
|
812
|
+
- If the route is intentionally public, explain why and recommend a safe limit.
|
|
813
|
+
|
|
814
|
+
Return:
|
|
815
|
+
- The safest protection approach.
|
|
816
|
+
- The updated code.
|
|
817
|
+
- Any environment variables required.`;
|
|
818
|
+
}
|
|
819
|
+
function createWebhookSignatureFixPrompt(file) {
|
|
820
|
+
return `Review the webhook API route at ${file}.
|
|
821
|
+
|
|
822
|
+
Goal:
|
|
823
|
+
Verify that webhook signature validation happens before the event is handled.
|
|
824
|
+
|
|
825
|
+
Instructions:
|
|
826
|
+
- Detect which provider this webhook belongs to based on imports, headers, and environment variables.
|
|
827
|
+
- Use the provider's existing verification pattern if already present.
|
|
828
|
+
- Do not process the webhook event before verification unless required by the provider.
|
|
829
|
+
- Do not introduce unrelated changes.
|
|
830
|
+
- If verification already exists, explain where it happens.
|
|
831
|
+
|
|
832
|
+
Return:
|
|
833
|
+
- Whether signature verification exists.
|
|
834
|
+
- If missing, the safest code change.
|
|
835
|
+
- Any test cases to run.`;
|
|
836
|
+
}
|
|
837
|
+
function createClientSideSecretFixPrompt(file, variableName) {
|
|
838
|
+
return `Review the client-side file at ${file}.
|
|
839
|
+
|
|
840
|
+
Qodfy found ${variableName}, which does not start with NEXT_PUBLIC_.
|
|
841
|
+
|
|
842
|
+
Goal:
|
|
843
|
+
Confirm whether this environment variable may be exposed to the browser.
|
|
844
|
+
|
|
845
|
+
Instructions:
|
|
846
|
+
- Check whether this file is a client component or browser-executed code.
|
|
847
|
+
- If ${variableName} is server-only, move access to a server component, API route, or server action.
|
|
848
|
+
- Do not rename environment variables unless necessary.
|
|
849
|
+
- Do not add real secret values.
|
|
850
|
+
- Keep existing behavior unchanged.
|
|
851
|
+
|
|
852
|
+
Return:
|
|
853
|
+
- Whether the variable is safe in this file.
|
|
854
|
+
- The safest code change if it is not safe.
|
|
855
|
+
- Any edge cases I should test.`;
|
|
856
|
+
}
|
|
857
|
+
function createHardcodedSecretFixPrompt(file, secretLabel) {
|
|
858
|
+
return `Review ${file} for a possible hardcoded ${secretLabel}.
|
|
859
|
+
|
|
860
|
+
Goal:
|
|
861
|
+
Remove any real secret from source code without changing behavior.
|
|
862
|
+
|
|
863
|
+
Instructions:
|
|
864
|
+
- Do not print or copy the secret value in your response.
|
|
865
|
+
- Move the value to an environment variable if it is a real secret.
|
|
866
|
+
- Add only the variable name to .env.example.
|
|
867
|
+
- Recommend rotating the secret if it may have been committed.
|
|
868
|
+
- Do not refactor unrelated code.
|
|
869
|
+
|
|
870
|
+
Return:
|
|
871
|
+
- Whether this looks like a real secret.
|
|
872
|
+
- The safest code change.
|
|
873
|
+
- Any follow-up security steps.`;
|
|
874
|
+
}
|
|
875
|
+
function createMissingEnvExampleFixPrompt() {
|
|
876
|
+
return `Create or update .env.example for this project.
|
|
877
|
+
|
|
878
|
+
Goal:
|
|
879
|
+
Document the environment variables required to run and deploy the app.
|
|
880
|
+
|
|
881
|
+
Instructions:
|
|
882
|
+
- Inspect process.env usage in the project.
|
|
883
|
+
- Add variable names only.
|
|
884
|
+
- Do not add real secret values.
|
|
885
|
+
- Use empty placeholders like VARIABLE_NAME=.
|
|
886
|
+
- Add short comments only where they help future maintainers.
|
|
887
|
+
|
|
888
|
+
Return:
|
|
889
|
+
- The proposed .env.example content.
|
|
890
|
+
- A short explanation of any variables that need manual confirmation.`;
|
|
891
|
+
}
|
|
892
|
+
function createProjectRootFixPrompt() {
|
|
893
|
+
return `Review how Qodfy is being run for this project.
|
|
894
|
+
|
|
895
|
+
Goal:
|
|
896
|
+
Make sure the scanner is pointed at the correct app root.
|
|
897
|
+
|
|
898
|
+
Instructions:
|
|
899
|
+
- Find the folder that contains the app package.json.
|
|
900
|
+
- If this is a monorepo, identify the Next.js app folder.
|
|
901
|
+
- Do not move files or refactor the project.
|
|
902
|
+
- Recommend the correct qodfy scan --path command.
|
|
903
|
+
|
|
904
|
+
Return:
|
|
905
|
+
- The correct folder to scan.
|
|
906
|
+
- The exact command to run.`;
|
|
907
|
+
}
|
|
908
|
+
function createPackageJsonFixPrompt() {
|
|
909
|
+
return `Review package.json.
|
|
910
|
+
|
|
911
|
+
Goal:
|
|
912
|
+
Make package.json readable so tooling can detect the project correctly.
|
|
913
|
+
|
|
914
|
+
Instructions:
|
|
915
|
+
- Check for invalid JSON syntax.
|
|
916
|
+
- Keep existing dependencies and scripts unchanged unless they are malformed.
|
|
917
|
+
- Do not upgrade dependencies.
|
|
918
|
+
- Do not refactor unrelated files.
|
|
919
|
+
|
|
920
|
+
Return:
|
|
921
|
+
- The corrected package.json change.
|
|
922
|
+
- A short explanation.`;
|
|
923
|
+
}
|
|
924
|
+
function createNextNotDetectedFixPrompt() {
|
|
925
|
+
return `Review this project structure.
|
|
926
|
+
|
|
927
|
+
Goal:
|
|
928
|
+
Determine whether Qodfy is scanning the correct Next.js app folder.
|
|
929
|
+
|
|
930
|
+
Instructions:
|
|
931
|
+
- Check whether this is a monorepo.
|
|
932
|
+
- Find the package.json that includes next as a dependency.
|
|
933
|
+
- Do not install or remove packages.
|
|
934
|
+
- Recommend the correct qodfy scan --path command if needed.
|
|
935
|
+
|
|
936
|
+
Return:
|
|
937
|
+
- Whether this is a Next.js app.
|
|
938
|
+
- The exact folder Qodfy should scan.`;
|
|
939
|
+
}
|
|
940
|
+
function createReadmeFixPrompt() {
|
|
941
|
+
return `Create a practical README for this project.
|
|
942
|
+
|
|
943
|
+
Goal:
|
|
944
|
+
Help developers run, configure, and maintain the app.
|
|
945
|
+
|
|
946
|
+
Instructions:
|
|
947
|
+
- Include setup commands, environment variable documentation, local development, build, and deployment notes.
|
|
948
|
+
- Do not include real secret values.
|
|
949
|
+
- Keep the README concise and accurate.
|
|
950
|
+
- Do not invent features that are not in the project.
|
|
951
|
+
|
|
952
|
+
Return:
|
|
953
|
+
- The README content.
|
|
954
|
+
- Any assumptions that need confirmation.`;
|
|
955
|
+
}
|
|
956
|
+
function formatPromptFileList(files) {
|
|
957
|
+
if (files.length === 1) {
|
|
958
|
+
return files[0];
|
|
959
|
+
}
|
|
960
|
+
return `${files.length} files: ${formatFileList(files)}`;
|
|
961
|
+
}
|
|
605
962
|
function isPackageJsonObject(data) {
|
|
606
963
|
return typeof data === "object" && data !== null && !Array.isArray(data);
|
|
607
964
|
}
|
|
@@ -615,5 +972,7 @@ function getErrorCode(error) {
|
|
|
615
972
|
return void 0;
|
|
616
973
|
}
|
|
617
974
|
export {
|
|
618
|
-
|
|
975
|
+
recommendedScanChecks,
|
|
976
|
+
scanProject,
|
|
977
|
+
validScanChecks
|
|
619
978
|
};
|