@qodfy/core 0.2.0 → 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 +9 -2
- package/dist/index.js +127 -71
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
type IssueSeverity = "critical" | "warning" | "info";
|
|
2
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[];
|
|
3
6
|
type Issue = {
|
|
4
7
|
id: string;
|
|
5
8
|
ruleId: string;
|
|
@@ -24,6 +27,10 @@ type ScanReport = {
|
|
|
24
27
|
durationMs: number;
|
|
25
28
|
};
|
|
26
29
|
};
|
|
27
|
-
|
|
30
|
+
type ScanOptions = {
|
|
31
|
+
projectPath: string;
|
|
32
|
+
checks?: ScanCheck[];
|
|
33
|
+
};
|
|
34
|
+
declare function scanProject(input: string | ScanOptions): Promise<ScanReport>;
|
|
28
35
|
|
|
29
|
-
export { type Issue, type IssueCategory, 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/**",
|
|
@@ -105,15 +122,28 @@ var issueIdPrefixes = {
|
|
|
105
122
|
"project-source-files-unreadable": "project-source-files-unreadable",
|
|
106
123
|
"webhook-missing-signature-verification": "webhook-signature-verification"
|
|
107
124
|
};
|
|
108
|
-
async function scanProject(
|
|
125
|
+
async function scanProject(input) {
|
|
109
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
|
+
);
|
|
110
131
|
const resolvedProjectPath = path.resolve(projectPath);
|
|
111
132
|
const issues = [];
|
|
112
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;
|
|
113
143
|
const packageJsonPath = path.join(resolvedProjectPath, "package.json");
|
|
114
144
|
const hasPackageJson = await fileExists(packageJsonPath);
|
|
115
145
|
let isNextProject = false;
|
|
116
|
-
if (!hasPackageJson) {
|
|
146
|
+
if (runProjectChecks && !hasPackageJson) {
|
|
117
147
|
addIssue({
|
|
118
148
|
ruleId: "project-missing-package-json",
|
|
119
149
|
category: "project",
|
|
@@ -123,7 +153,7 @@ async function scanProject(projectPath) {
|
|
|
123
153
|
suggestion: "Run Qodfy from the project root or pass --path to the app folder.",
|
|
124
154
|
fixPrompt: createProjectRootFixPrompt()
|
|
125
155
|
});
|
|
126
|
-
} else {
|
|
156
|
+
} else if (runProjectChecks) {
|
|
127
157
|
const packageJsonResult = await safeReadJson(packageJsonPath);
|
|
128
158
|
if (!packageJsonResult.ok) {
|
|
129
159
|
addIssue({
|
|
@@ -167,9 +197,9 @@ async function scanProject(projectPath) {
|
|
|
167
197
|
}
|
|
168
198
|
}
|
|
169
199
|
const envExamplePath = path.join(resolvedProjectPath, ".env.example");
|
|
170
|
-
const hasEnvExample = await fileExists(envExamplePath);
|
|
200
|
+
const hasEnvExample = runEnvironmentChecks ? await fileExists(envExamplePath) : false;
|
|
171
201
|
let envExampleVariables = null;
|
|
172
|
-
if (!hasEnvExample) {
|
|
202
|
+
if (runEnvironmentChecks && !hasEnvExample) {
|
|
173
203
|
addIssue({
|
|
174
204
|
ruleId: "environment-missing-env-example",
|
|
175
205
|
category: "environment",
|
|
@@ -179,7 +209,7 @@ async function scanProject(projectPath) {
|
|
|
179
209
|
suggestion: "Document required variable names only, never real secret values.",
|
|
180
210
|
fixPrompt: createMissingEnvExampleFixPrompt()
|
|
181
211
|
});
|
|
182
|
-
} else {
|
|
212
|
+
} else if (runEnvironmentChecks) {
|
|
183
213
|
const envExampleResult = await safeReadFile(envExamplePath);
|
|
184
214
|
if (!envExampleResult.ok) {
|
|
185
215
|
addIssue({
|
|
@@ -196,8 +226,8 @@ async function scanProject(projectPath) {
|
|
|
196
226
|
envExampleVariables = getEnvExampleVariables(envExampleResult.content);
|
|
197
227
|
}
|
|
198
228
|
}
|
|
199
|
-
const hasReadme = await fileExists(path.join(resolvedProjectPath, "README.md"));
|
|
200
|
-
if (!hasReadme) {
|
|
229
|
+
const hasReadme = runProjectChecks ? await fileExists(path.join(resolvedProjectPath, "README.md")) : true;
|
|
230
|
+
if (runProjectChecks && !hasReadme) {
|
|
201
231
|
addIssue({
|
|
202
232
|
ruleId: "project-missing-readme",
|
|
203
233
|
category: "project",
|
|
@@ -207,7 +237,7 @@ async function scanProject(projectPath) {
|
|
|
207
237
|
fixPrompt: createReadmeFixPrompt()
|
|
208
238
|
});
|
|
209
239
|
}
|
|
210
|
-
const files = await getSourceFiles(resolvedProjectPath, addIssue);
|
|
240
|
+
const files = shouldScanSourceFiles ? await getSourceFiles(resolvedProjectPath, addIssue) : [];
|
|
211
241
|
const apiRoutes = files.filter((file) => {
|
|
212
242
|
return isApiRoute(file);
|
|
213
243
|
});
|
|
@@ -222,56 +252,65 @@ async function scanProject(projectPath) {
|
|
|
222
252
|
const relativeFile = normalizePath(path.relative(resolvedProjectPath, file));
|
|
223
253
|
const statResult = await safeStatFile(file);
|
|
224
254
|
if (!statResult.ok) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
}
|
|
234
266
|
continue;
|
|
235
267
|
}
|
|
236
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) {
|
|
237
285
|
largeFiles++;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
severity: "info",
|
|
242
|
-
title: "Large file skipped from deep scan",
|
|
243
|
-
message: "This file is larger than 500KB and was skipped from deep content checks.",
|
|
244
|
-
file: relativeFile,
|
|
245
|
-
suggestion: "Review large generated or bundled files manually.",
|
|
246
|
-
fixPrompt: createLargeFileFixPrompt(relativeFile)
|
|
286
|
+
largeFileCandidates.push({
|
|
287
|
+
relativeFile,
|
|
288
|
+
size: statResult.size
|
|
247
289
|
});
|
|
290
|
+
}
|
|
291
|
+
if (!shouldReadSourceContent) {
|
|
248
292
|
continue;
|
|
249
293
|
}
|
|
250
294
|
const fileResult = await safeReadFile(file);
|
|
251
295
|
if (!fileResult.ok) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
}
|
|
261
307
|
continue;
|
|
262
308
|
}
|
|
263
309
|
const content = fileResult.content;
|
|
264
|
-
if (statResult.size > LARGE_FILE_WARNING_BYTES) {
|
|
265
|
-
largeFiles++;
|
|
266
|
-
largeFileCandidates.push({
|
|
267
|
-
relativeFile,
|
|
268
|
-
size: statResult.size
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
310
|
const usesAI = aiKeywords.some(
|
|
272
311
|
(keyword) => content.toLowerCase().includes(keyword.toLowerCase())
|
|
273
312
|
);
|
|
274
|
-
if (usesAI) {
|
|
313
|
+
if (runAiChecks && usesAI) {
|
|
275
314
|
aiFiles++;
|
|
276
315
|
const hasRateLimit = content.includes("rateLimit") || content.includes("ratelimit") || content.includes("upstash") || content.includes("limiter");
|
|
277
316
|
if (apiRouteSet.has(file) && !hasRateLimit) {
|
|
@@ -287,7 +326,7 @@ async function scanProject(projectPath) {
|
|
|
287
326
|
});
|
|
288
327
|
}
|
|
289
328
|
}
|
|
290
|
-
const webhookRouteInfo = apiRouteSet.has(file) ? getWebhookRouteInfo(relativeFile, content) : null;
|
|
329
|
+
const webhookRouteInfo = runWebhookChecks && apiRouteSet.has(file) ? getWebhookRouteInfo(relativeFile, content) : null;
|
|
291
330
|
if (webhookRouteInfo && !hasWebhookSignatureVerification(content, webhookRouteInfo.provider)) {
|
|
292
331
|
addIssue({
|
|
293
332
|
ruleId: "webhook-missing-signature-verification",
|
|
@@ -300,24 +339,26 @@ async function scanProject(projectPath) {
|
|
|
300
339
|
fixPrompt: createWebhookSignatureFixPrompt(relativeFile)
|
|
301
340
|
});
|
|
302
341
|
}
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
});
|
|
307
359
|
}
|
|
308
|
-
hardcodedSecretWarningKeys.add(warningKey);
|
|
309
|
-
addIssue({
|
|
310
|
-
ruleId: "security-hardcoded-secret",
|
|
311
|
-
category: "security",
|
|
312
|
-
severity: "critical",
|
|
313
|
-
title: "Possible hardcoded secret",
|
|
314
|
-
message: `A string literal in ${relativeFile} matches the pattern for ${secretMatch.label}. Qodfy does not print possible secret values.`,
|
|
315
|
-
file: relativeFile,
|
|
316
|
-
suggestion: "Move secrets into environment variables and rotate the value if this is a real secret.",
|
|
317
|
-
fixPrompt: createHardcodedSecretFixPrompt(relativeFile, secretMatch.label)
|
|
318
|
-
});
|
|
319
360
|
}
|
|
320
|
-
if (apiRouteSet.has(file)) {
|
|
361
|
+
if (runApiChecks && apiRouteSet.has(file)) {
|
|
321
362
|
const hasAuth = content.includes("auth(") || content.includes("getServerSession") || content.includes("currentUser") || content.includes("clerkClient") || content.includes("session");
|
|
322
363
|
if (!hasAuth && !webhookRouteInfo) {
|
|
323
364
|
addIssue({
|
|
@@ -332,8 +373,8 @@ async function scanProject(projectPath) {
|
|
|
332
373
|
});
|
|
333
374
|
}
|
|
334
375
|
}
|
|
335
|
-
const usedEnvVariables = getUsedEnvVariables(content);
|
|
336
|
-
if (envExampleVariables) {
|
|
376
|
+
const usedEnvVariables = runEnvironmentChecks || runSecurityChecks ? getUsedEnvVariables(content) : /* @__PURE__ */ new Set();
|
|
377
|
+
if (runEnvironmentChecks && envExampleVariables) {
|
|
337
378
|
for (const variableName of usedEnvVariables) {
|
|
338
379
|
if (shouldIgnoreEnvVariable(variableName) || envExampleVariables.has(variableName)) {
|
|
339
380
|
continue;
|
|
@@ -343,7 +384,7 @@ async function scanProject(projectPath) {
|
|
|
343
384
|
missingEnvUsages.set(variableName, filesUsingVariable);
|
|
344
385
|
}
|
|
345
386
|
}
|
|
346
|
-
if (isClientSideFile(relativeFile, content)) {
|
|
387
|
+
if (runSecurityChecks && isClientSideFile(relativeFile, content)) {
|
|
347
388
|
for (const variableName of usedEnvVariables) {
|
|
348
389
|
if (!variableName.startsWith("NEXT_PUBLIC_") && !shouldIgnoreEnvVariable(variableName)) {
|
|
349
390
|
const warningKey = `${relativeFile}:${variableName}`;
|
|
@@ -422,6 +463,16 @@ function createIssueFactory(issues) {
|
|
|
422
463
|
function getIssueIdPrefix(ruleId, category) {
|
|
423
464
|
return issueIdPrefixes[ruleId] ?? `${category}-${ruleId}`;
|
|
424
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
|
+
}
|
|
425
476
|
async function fileExists(filePath) {
|
|
426
477
|
try {
|
|
427
478
|
await fs.access(filePath);
|
|
@@ -508,13 +559,16 @@ async function safeReadJson(filePath) {
|
|
|
508
559
|
}
|
|
509
560
|
async function getSourceFiles(projectPath, addIssue) {
|
|
510
561
|
try {
|
|
511
|
-
|
|
562
|
+
const files = await fg(sourceFilePatterns, {
|
|
512
563
|
cwd: projectPath,
|
|
513
564
|
ignore: ignoredPaths,
|
|
514
565
|
absolute: true,
|
|
515
566
|
onlyFiles: true,
|
|
516
567
|
dot: false
|
|
517
568
|
});
|
|
569
|
+
return files.sort(
|
|
570
|
+
(leftFile, rightFile) => normalizePath(leftFile).localeCompare(normalizePath(rightFile))
|
|
571
|
+
);
|
|
518
572
|
} catch {
|
|
519
573
|
addIssue({
|
|
520
574
|
ruleId: "project-source-files-unreadable",
|
|
@@ -619,8 +673,8 @@ function getWebhookRouteInfo(relativeFile, content) {
|
|
|
619
673
|
const normalizedContent = content.toLowerCase();
|
|
620
674
|
const normalizedRouteContext = `${normalizedFile}
|
|
621
675
|
${normalizedContent}`;
|
|
622
|
-
const pathLooksLikeWebhook =
|
|
623
|
-
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);
|
|
624
678
|
if (!pathLooksLikeWebhook && !contentStronglySuggestsWebhook) {
|
|
625
679
|
return null;
|
|
626
680
|
}
|
|
@@ -631,13 +685,13 @@ ${normalizedContent}`;
|
|
|
631
685
|
};
|
|
632
686
|
}
|
|
633
687
|
function getWebhookProvider(normalizedRouteContext) {
|
|
634
|
-
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)) {
|
|
635
689
|
return "stripe";
|
|
636
690
|
}
|
|
637
|
-
if (normalizedRouteContext
|
|
691
|
+
if (/\bresend\b/.test(normalizedRouteContext) && /\bwebhook\b/.test(normalizedRouteContext)) {
|
|
638
692
|
return "resend";
|
|
639
693
|
}
|
|
640
|
-
if (normalizedRouteContext.includes("clerk_webhook_secret") || normalizedRouteContext
|
|
694
|
+
if (normalizedRouteContext.includes("clerk_webhook_secret") || /\bclerk\b/.test(normalizedRouteContext) && /\bwebhook\b/.test(normalizedRouteContext)) {
|
|
641
695
|
return "clerk";
|
|
642
696
|
}
|
|
643
697
|
if (normalizedRouteContext.includes("x-github-event") || normalizedRouteContext.includes("x-hub-signature")) {
|
|
@@ -918,5 +972,7 @@ function getErrorCode(error) {
|
|
|
918
972
|
return void 0;
|
|
919
973
|
}
|
|
920
974
|
export {
|
|
921
|
-
|
|
975
|
+
recommendedScanChecks,
|
|
976
|
+
scanProject,
|
|
977
|
+
validScanChecks
|
|
922
978
|
};
|