prodlint 0.9.0 → 0.9.2
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/README.md +38 -6
- package/dist/cli.js +197 -14
- package/dist/index.d.ts +9 -1
- package/dist/index.js +215 -3
- package/dist/mcp.js +21 -6
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
Production readiness for vibe-coded apps.
|
|
8
8
|
|
|
9
|
-
Static analysis for vibe-coded apps. Flags the security, reliability, performance, and AI quality issues that Cursor, v0, Bolt, and Copilot create — hallucinated imports, missing auth, hardcoded secrets, unvalidated server actions, and more. Zero config, no LLM, 52 rules
|
|
9
|
+
Static analysis for vibe-coded apps. Flags the security, reliability, performance, and AI quality issues that Cursor, v0, Bolt, and Copilot create — hallucinated imports, missing auth, hardcoded secrets, unvalidated server actions, and more. Zero config, no LLM, 52 rules.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx prodlint
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
prodlint v0.9.
|
|
17
|
-
Scanned 148 files ·
|
|
16
|
+
prodlint v0.9.2
|
|
17
|
+
Scanned 148 files · 2 critical · 5 warnings · 1 info
|
|
18
18
|
|
|
19
19
|
src/app/api/checkout/route.ts
|
|
20
|
-
12:1
|
|
20
|
+
12:1 INFO No rate limiting — anyone could spam this endpoint and run up your API costs rate-limiting
|
|
21
21
|
28:5 WARN Empty catch block silently swallows error shallow-catch
|
|
22
22
|
|
|
23
23
|
src/actions/submit.ts
|
|
@@ -35,7 +35,7 @@ npx prodlint
|
|
|
35
35
|
|
|
36
36
|
Overall: 82/100 (weighted)
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
2 critical · 5 warnings · 4 info
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
## Why?
|
|
@@ -50,6 +50,11 @@ prodlint checks what TypeScript and ESLint don't: **whether your vibe-coded app
|
|
|
50
50
|
npx prodlint # Run directly (no install)
|
|
51
51
|
npx prodlint ./my-app # Scan specific path
|
|
52
52
|
npx prodlint --json # JSON output for CI
|
|
53
|
+
npx prodlint --sarif # SARIF 2.1.0 for GitHub Code Scanning
|
|
54
|
+
npx prodlint --summary # Quick pass/fail + top 3 blockers
|
|
55
|
+
npx prodlint --profile startup # Only critical findings
|
|
56
|
+
npx prodlint --profile strict # All findings including info
|
|
57
|
+
npx prodlint --baseline .prodlint-baseline.json # Only new findings
|
|
53
58
|
npx prodlint --ignore "*.test.ts" # Ignore patterns
|
|
54
59
|
npx prodlint --min-severity warning # Only warnings and criticals
|
|
55
60
|
npx prodlint --quiet # Suppress badge output
|
|
@@ -196,13 +201,40 @@ Posts a score breakdown as a PR comment and fails the build if below threshold.
|
|
|
196
201
|
| `score` | Overall score (0-100) |
|
|
197
202
|
| `critical` | Number of critical findings |
|
|
198
203
|
|
|
204
|
+
### SARIF + GitHub Code Scanning
|
|
205
|
+
|
|
206
|
+
Upload prodlint results to GitHub's Security tab:
|
|
207
|
+
|
|
208
|
+
```yaml
|
|
209
|
+
- name: Run prodlint
|
|
210
|
+
run: npx prodlint --sarif > prodlint.sarif
|
|
211
|
+
|
|
212
|
+
- name: Upload SARIF
|
|
213
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
214
|
+
with:
|
|
215
|
+
sarif_file: prodlint.sarif
|
|
216
|
+
category: prodlint
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Baseline for Existing Projects
|
|
220
|
+
|
|
221
|
+
Adopt prodlint gradually without drowning in pre-existing findings:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# Save current state as baseline
|
|
225
|
+
npx prodlint --baseline-save .prodlint-baseline.json
|
|
226
|
+
|
|
227
|
+
# CI only fails on NEW findings
|
|
228
|
+
npx prodlint --baseline .prodlint-baseline.json
|
|
229
|
+
```
|
|
230
|
+
|
|
199
231
|
## MCP Server
|
|
200
232
|
|
|
201
233
|
Use prodlint inside Cursor, Claude Code, or any MCP-compatible editor:
|
|
202
234
|
|
|
203
235
|
**Claude Code:**
|
|
204
236
|
```bash
|
|
205
|
-
claude mcp add prodlint npx prodlint-mcp
|
|
237
|
+
claude mcp add prodlint -- npx -y prodlint-mcp
|
|
206
238
|
```
|
|
207
239
|
|
|
208
240
|
**Cursor / Windsurf:**
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs } from "util";
|
|
5
|
+
import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
|
|
5
6
|
|
|
6
7
|
// src/utils/file-walker.ts
|
|
7
8
|
import fg from "fast-glob";
|
|
@@ -715,8 +716,8 @@ function summarizeFindings(findings) {
|
|
|
715
716
|
|
|
716
717
|
// src/rules/secrets.ts
|
|
717
718
|
var SECRET_PATTERNS = [
|
|
718
|
-
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{
|
|
719
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
719
|
+
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
720
|
+
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
720
721
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
721
722
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
722
723
|
{ name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
|
|
@@ -1250,7 +1251,7 @@ var corsConfigRule = {
|
|
|
1250
1251
|
};
|
|
1251
1252
|
|
|
1252
1253
|
// src/rules/ai-smells.ts
|
|
1253
|
-
var CONSOLE_LOG_THRESHOLD =
|
|
1254
|
+
var CONSOLE_LOG_THRESHOLD = 3;
|
|
1254
1255
|
var ANY_TYPE_THRESHOLD = 5;
|
|
1255
1256
|
var COMMENTED_CODE_THRESHOLD = 3;
|
|
1256
1257
|
var aiSmellsRule = {
|
|
@@ -3290,6 +3291,7 @@ var useClientOveruseRule = {
|
|
|
3290
3291
|
// src/rules/env-fallback-secret.ts
|
|
3291
3292
|
var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
|
|
3292
3293
|
var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
|
|
3294
|
+
var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
|
|
3293
3295
|
var envFallbackSecretRule = {
|
|
3294
3296
|
id: "env-fallback-secret",
|
|
3295
3297
|
name: "Secret with Fallback Value",
|
|
@@ -3317,6 +3319,20 @@ var envFallbackSecretRule = {
|
|
|
3317
3319
|
});
|
|
3318
3320
|
continue;
|
|
3319
3321
|
}
|
|
3322
|
+
const connMatch = CONN_STRING_FALLBACK.exec(line);
|
|
3323
|
+
if (connMatch) {
|
|
3324
|
+
findings.push({
|
|
3325
|
+
ruleId: "env-fallback-secret",
|
|
3326
|
+
file: file.relativePath,
|
|
3327
|
+
line: i + 1,
|
|
3328
|
+
column: connMatch.index + 1,
|
|
3329
|
+
message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
|
|
3330
|
+
severity: "warning",
|
|
3331
|
+
category: "security",
|
|
3332
|
+
fix: "Fail fast when required env vars are missing instead of falling back to a default value"
|
|
3333
|
+
});
|
|
3334
|
+
continue;
|
|
3335
|
+
}
|
|
3320
3336
|
const genericMatch = ENV_FALLBACK.exec(line);
|
|
3321
3337
|
if (genericMatch && !isConfigFile(file.relativePath)) {
|
|
3322
3338
|
findings.push({
|
|
@@ -4577,6 +4593,99 @@ function reportPretty(result, opts = {}) {
|
|
|
4577
4593
|
function reportJson(result) {
|
|
4578
4594
|
return JSON.stringify(result, null, 2);
|
|
4579
4595
|
}
|
|
4596
|
+
function reportSummary(result) {
|
|
4597
|
+
const lines = [];
|
|
4598
|
+
const { critical, warning, info } = result.summary;
|
|
4599
|
+
const hasCritical = critical > 0;
|
|
4600
|
+
const verdict = hasCritical ? pc.red(pc.bold("FAIL")) : pc.green(pc.bold("PASS"));
|
|
4601
|
+
const scorePart = scoreColor(result.overallScore)(`${result.overallScore}/100`);
|
|
4602
|
+
const countParts = [];
|
|
4603
|
+
if (critical > 0) countParts.push(`${critical} critical`);
|
|
4604
|
+
if (warning > 0) countParts.push(`${warning} warning`);
|
|
4605
|
+
if (info > 0) countParts.push(`${info} info`);
|
|
4606
|
+
lines.push("");
|
|
4607
|
+
if (countParts.length > 0) {
|
|
4608
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} ${countParts.join(", ")} ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4609
|
+
} else {
|
|
4610
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} no issues ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4611
|
+
}
|
|
4612
|
+
const topFindings = result.findings.filter((f) => f.severity === "critical" || f.severity === "warning").slice(0, 3);
|
|
4613
|
+
if (topFindings.length > 0) {
|
|
4614
|
+
for (let i = 0; i < topFindings.length; i++) {
|
|
4615
|
+
const f = topFindings[i];
|
|
4616
|
+
const color = SEVERITY_COLORS[f.severity];
|
|
4617
|
+
const label = SEVERITY_LABELS[f.severity];
|
|
4618
|
+
lines.push(` ${pc.dim(`${i + 1}.`)} ${f.file}:${f.line} ${pc.dim("\u2014")} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`);
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
lines.push("");
|
|
4622
|
+
return lines.join("\n");
|
|
4623
|
+
}
|
|
4624
|
+
var SARIF_SEVERITY_MAP = {
|
|
4625
|
+
critical: "error",
|
|
4626
|
+
warning: "warning",
|
|
4627
|
+
info: "note"
|
|
4628
|
+
};
|
|
4629
|
+
function reportSarif(result) {
|
|
4630
|
+
const ruleMap = /* @__PURE__ */ new Map();
|
|
4631
|
+
for (const r of rules) {
|
|
4632
|
+
ruleMap.set(r.id, r);
|
|
4633
|
+
}
|
|
4634
|
+
const usedRuleIds = new Set(result.findings.map((f) => f.ruleId));
|
|
4635
|
+
const sarifRules = [...usedRuleIds].map((id) => {
|
|
4636
|
+
const rule = ruleMap.get(id);
|
|
4637
|
+
return {
|
|
4638
|
+
id,
|
|
4639
|
+
name: rule?.name ?? id,
|
|
4640
|
+
shortDescription: { text: rule?.description ?? id },
|
|
4641
|
+
defaultConfiguration: {
|
|
4642
|
+
level: SARIF_SEVERITY_MAP[rule?.severity ?? "info"] ?? "note"
|
|
4643
|
+
},
|
|
4644
|
+
helpUri: `https://prodlint.com/rules/${id}`
|
|
4645
|
+
};
|
|
4646
|
+
});
|
|
4647
|
+
const sarifResults = result.findings.map((f) => {
|
|
4648
|
+
const sarifResult = {
|
|
4649
|
+
ruleId: f.ruleId,
|
|
4650
|
+
level: SARIF_SEVERITY_MAP[f.severity] ?? "note",
|
|
4651
|
+
message: { text: f.message },
|
|
4652
|
+
locations: [
|
|
4653
|
+
{
|
|
4654
|
+
physicalLocation: {
|
|
4655
|
+
artifactLocation: { uri: f.file.replace(/\\/g, "/") },
|
|
4656
|
+
region: { startLine: f.line, startColumn: f.column }
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
]
|
|
4660
|
+
};
|
|
4661
|
+
if (f.fix) {
|
|
4662
|
+
sarifResult.fixes = [
|
|
4663
|
+
{
|
|
4664
|
+
description: { text: f.fix }
|
|
4665
|
+
}
|
|
4666
|
+
];
|
|
4667
|
+
}
|
|
4668
|
+
return sarifResult;
|
|
4669
|
+
});
|
|
4670
|
+
const sarif = {
|
|
4671
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
4672
|
+
version: "2.1.0",
|
|
4673
|
+
runs: [
|
|
4674
|
+
{
|
|
4675
|
+
tool: {
|
|
4676
|
+
driver: {
|
|
4677
|
+
name: "prodlint",
|
|
4678
|
+
version: getVersion(),
|
|
4679
|
+
informationUri: "https://prodlint.com",
|
|
4680
|
+
rules: sarifRules
|
|
4681
|
+
}
|
|
4682
|
+
},
|
|
4683
|
+
results: sarifResults
|
|
4684
|
+
}
|
|
4685
|
+
]
|
|
4686
|
+
};
|
|
4687
|
+
return JSON.stringify(sarif, null, 2);
|
|
4688
|
+
}
|
|
4580
4689
|
function renderBar(score) {
|
|
4581
4690
|
const width = 20;
|
|
4582
4691
|
const filled = Math.round(score / 100 * width);
|
|
@@ -4876,13 +4985,26 @@ async function runWebScan(targetUrl) {
|
|
|
4876
4985
|
|
|
4877
4986
|
// src/cli.ts
|
|
4878
4987
|
var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
4988
|
+
var PROFILE_SEVERITY = {
|
|
4989
|
+
startup: "critical",
|
|
4990
|
+
balanced: "warning",
|
|
4991
|
+
strict: "info"
|
|
4992
|
+
};
|
|
4993
|
+
function fingerprintFinding(f) {
|
|
4994
|
+
return `${f.ruleId}::${f.file}::${f.line}`;
|
|
4995
|
+
}
|
|
4879
4996
|
async function main() {
|
|
4880
4997
|
const { values, positionals } = parseArgs({
|
|
4881
4998
|
allowPositionals: true,
|
|
4882
4999
|
options: {
|
|
4883
5000
|
json: { type: "boolean", default: false },
|
|
5001
|
+
sarif: { type: "boolean", default: false },
|
|
5002
|
+
summary: { type: "boolean", default: false },
|
|
4884
5003
|
ignore: { type: "string", multiple: true, default: [] },
|
|
4885
5004
|
"min-severity": { type: "string", default: "info" },
|
|
5005
|
+
profile: { type: "string" },
|
|
5006
|
+
baseline: { type: "string" },
|
|
5007
|
+
"baseline-save": { type: "string" },
|
|
4886
5008
|
quiet: { type: "boolean", default: false },
|
|
4887
5009
|
web: { type: "boolean", default: false },
|
|
4888
5010
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -4907,15 +5029,66 @@ async function main() {
|
|
|
4907
5029
|
return;
|
|
4908
5030
|
}
|
|
4909
5031
|
const targetPath = positionals[0] ?? ".";
|
|
4910
|
-
|
|
5032
|
+
let minSeverity;
|
|
5033
|
+
if (values.profile) {
|
|
5034
|
+
const profileName = values.profile;
|
|
5035
|
+
if (!(profileName in PROFILE_SEVERITY)) {
|
|
5036
|
+
console.error(`Unknown profile: ${profileName}. Available: startup, balanced, strict`);
|
|
5037
|
+
process.exit(2);
|
|
5038
|
+
}
|
|
5039
|
+
minSeverity = PROFILE_SEVERITY[profileName];
|
|
5040
|
+
} else {
|
|
5041
|
+
minSeverity = values["min-severity"] ?? "info";
|
|
5042
|
+
}
|
|
4911
5043
|
const result = await scan({
|
|
4912
5044
|
path: targetPath,
|
|
4913
5045
|
ignore: values.ignore
|
|
4914
5046
|
});
|
|
4915
5047
|
const minRank = SEVERITY_RANK[minSeverity] ?? 1;
|
|
4916
5048
|
result.findings = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank);
|
|
4917
|
-
|
|
5049
|
+
result.summary = {
|
|
5050
|
+
critical: result.findings.filter((f) => f.severity === "critical").length,
|
|
5051
|
+
warning: result.findings.filter((f) => f.severity === "warning").length,
|
|
5052
|
+
info: result.findings.filter((f) => f.severity === "info").length
|
|
5053
|
+
};
|
|
5054
|
+
if (values["baseline-save"]) {
|
|
5055
|
+
const baselinePath = values["baseline-save"];
|
|
5056
|
+
const entries = result.findings.map((f) => ({
|
|
5057
|
+
ruleId: f.ruleId,
|
|
5058
|
+
file: f.file,
|
|
5059
|
+
line: f.line,
|
|
5060
|
+
message: f.message
|
|
5061
|
+
}));
|
|
5062
|
+
writeFileSync(baselinePath, JSON.stringify(entries, null, 2));
|
|
5063
|
+
console.log(`Baseline saved: ${entries.length} findings written to ${baselinePath}`);
|
|
5064
|
+
return;
|
|
5065
|
+
}
|
|
5066
|
+
if (values.baseline) {
|
|
5067
|
+
const baselinePath = values.baseline;
|
|
5068
|
+
if (!existsSync(baselinePath)) {
|
|
5069
|
+
console.error(`Baseline file not found: ${baselinePath}`);
|
|
5070
|
+
console.error("Run with --baseline-save first to create a baseline.");
|
|
5071
|
+
process.exit(2);
|
|
5072
|
+
}
|
|
5073
|
+
const baselineData = JSON.parse(readFileSync2(baselinePath, "utf8"));
|
|
5074
|
+
const baselineFingerprints = new Set(
|
|
5075
|
+
baselineData.map((e) => `${e.ruleId}::${e.file}::${e.line}`)
|
|
5076
|
+
);
|
|
5077
|
+
result.findings = result.findings.filter(
|
|
5078
|
+
(f) => !baselineFingerprints.has(fingerprintFinding(f))
|
|
5079
|
+
);
|
|
5080
|
+
result.summary = {
|
|
5081
|
+
critical: result.findings.filter((f) => f.severity === "critical").length,
|
|
5082
|
+
warning: result.findings.filter((f) => f.severity === "warning").length,
|
|
5083
|
+
info: result.findings.filter((f) => f.severity === "info").length
|
|
5084
|
+
};
|
|
5085
|
+
}
|
|
5086
|
+
if (values.sarif) {
|
|
5087
|
+
console.log(reportSarif(result));
|
|
5088
|
+
} else if (values.json) {
|
|
4918
5089
|
console.log(reportJson(result));
|
|
5090
|
+
} else if (values.summary) {
|
|
5091
|
+
console.log(reportSummary(result));
|
|
4919
5092
|
} else {
|
|
4920
5093
|
console.log(reportPretty(result, { quiet: values.quiet }));
|
|
4921
5094
|
}
|
|
@@ -4953,22 +5126,32 @@ function printHelp() {
|
|
|
4953
5126
|
|
|
4954
5127
|
Options:
|
|
4955
5128
|
--json Output results as JSON
|
|
5129
|
+
--sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
|
|
5130
|
+
--summary Quick pass/fail verdict with top 3 findings
|
|
4956
5131
|
--ignore <pattern> Glob patterns to ignore (can be repeated)
|
|
4957
|
-
--min-severity <level> Minimum severity
|
|
5132
|
+
--min-severity <level> Minimum severity: critical, warning, info (default: info)
|
|
5133
|
+
--profile <name> Preset: startup (criticals only), balanced (warnings+), strict (all)
|
|
5134
|
+
--baseline <file> Only show findings not in baseline file
|
|
5135
|
+
--baseline-save <file> Save current findings as baseline snapshot
|
|
4958
5136
|
--quiet Suppress badge and summary
|
|
4959
5137
|
--web Get your site's prodlint score (14 AI agent checks)
|
|
4960
5138
|
-h, --help Show this help message
|
|
4961
5139
|
-v, --version Show version
|
|
4962
5140
|
|
|
4963
5141
|
Examples:
|
|
4964
|
-
npx prodlint
|
|
4965
|
-
npx prodlint ./my-app
|
|
4966
|
-
npx prodlint --json
|
|
4967
|
-
npx prodlint --
|
|
4968
|
-
npx prodlint --
|
|
4969
|
-
npx prodlint --
|
|
4970
|
-
npx prodlint --
|
|
4971
|
-
npx prodlint --
|
|
5142
|
+
npx prodlint Scan current directory
|
|
5143
|
+
npx prodlint ./my-app Scan specific path
|
|
5144
|
+
npx prodlint --json JSON output
|
|
5145
|
+
npx prodlint --sarif SARIF output for CI integration
|
|
5146
|
+
npx prodlint --summary Quick pass/fail + top blockers
|
|
5147
|
+
npx prodlint --profile startup Only critical findings
|
|
5148
|
+
npx prodlint --baseline .prodlint-baseline.json Only new findings
|
|
5149
|
+
npx prodlint --baseline-save .prodlint-baseline.json Save baseline
|
|
5150
|
+
npx prodlint --ignore "*.test" Ignore test files
|
|
5151
|
+
npx prodlint --min-severity warning Only warnings and criticals
|
|
5152
|
+
npx prodlint --quiet No badge output
|
|
5153
|
+
npx prodlint --web example.com Site score
|
|
5154
|
+
npx prodlint --web example.com --json Site score with JSON output
|
|
4972
5155
|
`);
|
|
4973
5156
|
}
|
|
4974
5157
|
main().catch((err) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -73,6 +73,14 @@ declare function scan(options: ScanOptions): Promise<ScanResult>;
|
|
|
73
73
|
|
|
74
74
|
declare const rules: Rule[];
|
|
75
75
|
|
|
76
|
+
interface ReportOptions {
|
|
77
|
+
quiet?: boolean;
|
|
78
|
+
}
|
|
79
|
+
declare function reportPretty(result: ScanResult, opts?: ReportOptions): string;
|
|
80
|
+
declare function reportJson(result: ScanResult): string;
|
|
81
|
+
declare function reportSummary(result: ScanResult): string;
|
|
82
|
+
declare function reportSarif(result: ScanResult): string;
|
|
83
|
+
|
|
76
84
|
type CheckStatus = "pass" | "fail" | "warn" | "info";
|
|
77
85
|
type CheckSeverity = "critical" | "high" | "medium" | "low";
|
|
78
86
|
interface ScanCheck {
|
|
@@ -116,4 +124,4 @@ interface WebScanResult {
|
|
|
116
124
|
|
|
117
125
|
declare function runWebScan(targetUrl: string): Promise<WebScanResult>;
|
|
118
126
|
|
|
119
|
-
export { type Category, type CategoryScore, type FileContext, type Finding, type ProjectContext, type Rule, type ScanOptions, type ScanResult, type Severity, type CheckContext as WebCheckContext, type ScanCheck as WebScanCheck, type WebScanResult, rules, runWebScan, scan };
|
|
127
|
+
export { type Category, type CategoryScore, type FileContext, type Finding, type ProjectContext, type Rule, type ScanOptions, type ScanResult, type Severity, type CheckContext as WebCheckContext, type ScanCheck as WebScanCheck, type WebScanResult, reportJson, reportPretty, reportSarif, reportSummary, rules, runWebScan, scan };
|
package/dist/index.js
CHANGED
|
@@ -710,8 +710,8 @@ function summarizeFindings(findings) {
|
|
|
710
710
|
|
|
711
711
|
// src/rules/secrets.ts
|
|
712
712
|
var SECRET_PATTERNS = [
|
|
713
|
-
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{
|
|
714
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
713
|
+
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
714
|
+
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
715
715
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
716
716
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
717
717
|
{ name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
|
|
@@ -1245,7 +1245,7 @@ var corsConfigRule = {
|
|
|
1245
1245
|
};
|
|
1246
1246
|
|
|
1247
1247
|
// src/rules/ai-smells.ts
|
|
1248
|
-
var CONSOLE_LOG_THRESHOLD =
|
|
1248
|
+
var CONSOLE_LOG_THRESHOLD = 3;
|
|
1249
1249
|
var ANY_TYPE_THRESHOLD = 5;
|
|
1250
1250
|
var COMMENTED_CODE_THRESHOLD = 3;
|
|
1251
1251
|
var aiSmellsRule = {
|
|
@@ -3285,6 +3285,7 @@ var useClientOveruseRule = {
|
|
|
3285
3285
|
// src/rules/env-fallback-secret.ts
|
|
3286
3286
|
var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
|
|
3287
3287
|
var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
|
|
3288
|
+
var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
|
|
3288
3289
|
var envFallbackSecretRule = {
|
|
3289
3290
|
id: "env-fallback-secret",
|
|
3290
3291
|
name: "Secret with Fallback Value",
|
|
@@ -3312,6 +3313,20 @@ var envFallbackSecretRule = {
|
|
|
3312
3313
|
});
|
|
3313
3314
|
continue;
|
|
3314
3315
|
}
|
|
3316
|
+
const connMatch = CONN_STRING_FALLBACK.exec(line);
|
|
3317
|
+
if (connMatch) {
|
|
3318
|
+
findings.push({
|
|
3319
|
+
ruleId: "env-fallback-secret",
|
|
3320
|
+
file: file.relativePath,
|
|
3321
|
+
line: i + 1,
|
|
3322
|
+
column: connMatch.index + 1,
|
|
3323
|
+
message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
|
|
3324
|
+
severity: "warning",
|
|
3325
|
+
category: "security",
|
|
3326
|
+
fix: "Fail fast when required env vars are missing instead of falling back to a default value"
|
|
3327
|
+
});
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3315
3330
|
const genericMatch = ENV_FALLBACK.exec(line);
|
|
3316
3331
|
if (genericMatch && !isConfigFile(file.relativePath)) {
|
|
3317
3332
|
findings.push({
|
|
@@ -4486,6 +4501,199 @@ async function scan(options) {
|
|
|
4486
4501
|
};
|
|
4487
4502
|
}
|
|
4488
4503
|
|
|
4504
|
+
// src/reporter.ts
|
|
4505
|
+
import pc from "picocolors";
|
|
4506
|
+
var SEVERITY_COLORS = {
|
|
4507
|
+
critical: pc.red,
|
|
4508
|
+
warning: pc.yellow,
|
|
4509
|
+
info: pc.blue
|
|
4510
|
+
};
|
|
4511
|
+
var SEVERITY_LABELS = {
|
|
4512
|
+
critical: "CRIT",
|
|
4513
|
+
warning: "WARN",
|
|
4514
|
+
info: "INFO"
|
|
4515
|
+
};
|
|
4516
|
+
function scoreColor(score) {
|
|
4517
|
+
if (score >= 80) return pc.green;
|
|
4518
|
+
if (score >= 50) return pc.yellow;
|
|
4519
|
+
return pc.red;
|
|
4520
|
+
}
|
|
4521
|
+
function groupByFile(findings) {
|
|
4522
|
+
const map = /* @__PURE__ */ new Map();
|
|
4523
|
+
for (const f of findings) {
|
|
4524
|
+
const group = map.get(f.file) ?? [];
|
|
4525
|
+
group.push(f);
|
|
4526
|
+
map.set(f.file, group);
|
|
4527
|
+
}
|
|
4528
|
+
return map;
|
|
4529
|
+
}
|
|
4530
|
+
function reportPretty(result, opts = {}) {
|
|
4531
|
+
const lines = [];
|
|
4532
|
+
const { critical, warning, info } = result.summary;
|
|
4533
|
+
lines.push("");
|
|
4534
|
+
lines.push(pc.bold(" prodlint") + pc.dim(` v${result.version}`));
|
|
4535
|
+
const headerParts = [`Scanned ${result.filesScanned} files`];
|
|
4536
|
+
if (critical > 0) headerParts.push(`${critical} critical`);
|
|
4537
|
+
if (warning > 0) headerParts.push(`${warning} warnings`);
|
|
4538
|
+
if (info > 0) headerParts.push(`${info} info`);
|
|
4539
|
+
lines.push(pc.dim(` ${headerParts.join(" \xB7 ")}`));
|
|
4540
|
+
lines.push("");
|
|
4541
|
+
if (result.findings.length > 0) {
|
|
4542
|
+
const grouped = groupByFile(result.findings);
|
|
4543
|
+
for (const [file, findings] of grouped) {
|
|
4544
|
+
lines.push(pc.underline(file));
|
|
4545
|
+
for (const f of findings) {
|
|
4546
|
+
const color = SEVERITY_COLORS[f.severity];
|
|
4547
|
+
const label = SEVERITY_LABELS[f.severity];
|
|
4548
|
+
lines.push(
|
|
4549
|
+
` ${pc.dim(`${f.line}:${f.column}`)} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`
|
|
4550
|
+
);
|
|
4551
|
+
if (f.fix) {
|
|
4552
|
+
lines.push(` ${pc.dim(` \u21B3 ${f.fix}`)}`);
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
lines.push("");
|
|
4556
|
+
}
|
|
4557
|
+
}
|
|
4558
|
+
lines.push(pc.bold(" Scores"));
|
|
4559
|
+
for (const cs of result.categoryScores) {
|
|
4560
|
+
const color = scoreColor(cs.score);
|
|
4561
|
+
const bar = renderBar(cs.score);
|
|
4562
|
+
lines.push(` ${cs.category.padEnd(14)} ${color(String(cs.score).padStart(3))} ${bar} ${pc.dim(`(${cs.findingCount} issues)`)}`);
|
|
4563
|
+
}
|
|
4564
|
+
lines.push("");
|
|
4565
|
+
const overallColor = scoreColor(result.overallScore);
|
|
4566
|
+
lines.push(pc.bold(` Overall: ${overallColor(String(result.overallScore))}/100`));
|
|
4567
|
+
lines.push("");
|
|
4568
|
+
const summaryParts = [];
|
|
4569
|
+
if (critical > 0) summaryParts.push(pc.red(`${critical} critical`));
|
|
4570
|
+
if (warning > 0) summaryParts.push(pc.yellow(`${warning} warnings`));
|
|
4571
|
+
if (info > 0) summaryParts.push(pc.blue(`${info} info`));
|
|
4572
|
+
if (summaryParts.length === 0) {
|
|
4573
|
+
lines.push(pc.green(" No issues found!"));
|
|
4574
|
+
} else {
|
|
4575
|
+
lines.push(` ${summaryParts.join(pc.dim(" \xB7 "))}`);
|
|
4576
|
+
}
|
|
4577
|
+
lines.push("");
|
|
4578
|
+
if (!opts.quiet) {
|
|
4579
|
+
const badgeColor = result.overallScore >= 80 ? "brightgreen" : result.overallScore >= 60 ? "yellow" : "red";
|
|
4580
|
+
const badgeUrl = `https://img.shields.io/badge/prodlint-${result.overallScore}%2F100-${badgeColor}`;
|
|
4581
|
+
lines.push(pc.dim(" Add to your README:"));
|
|
4582
|
+
lines.push(pc.dim(` [](https://prodlint.com)`));
|
|
4583
|
+
lines.push("");
|
|
4584
|
+
}
|
|
4585
|
+
return lines.join("\n");
|
|
4586
|
+
}
|
|
4587
|
+
function reportJson(result) {
|
|
4588
|
+
return JSON.stringify(result, null, 2);
|
|
4589
|
+
}
|
|
4590
|
+
function reportSummary(result) {
|
|
4591
|
+
const lines = [];
|
|
4592
|
+
const { critical, warning, info } = result.summary;
|
|
4593
|
+
const hasCritical = critical > 0;
|
|
4594
|
+
const verdict = hasCritical ? pc.red(pc.bold("FAIL")) : pc.green(pc.bold("PASS"));
|
|
4595
|
+
const scorePart = scoreColor(result.overallScore)(`${result.overallScore}/100`);
|
|
4596
|
+
const countParts = [];
|
|
4597
|
+
if (critical > 0) countParts.push(`${critical} critical`);
|
|
4598
|
+
if (warning > 0) countParts.push(`${warning} warning`);
|
|
4599
|
+
if (info > 0) countParts.push(`${info} info`);
|
|
4600
|
+
lines.push("");
|
|
4601
|
+
if (countParts.length > 0) {
|
|
4602
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} ${countParts.join(", ")} ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4603
|
+
} else {
|
|
4604
|
+
lines.push(` ${verdict} ${pc.dim("\u2014")} no issues ${pc.dim(`(score: ${scorePart})`)}`);
|
|
4605
|
+
}
|
|
4606
|
+
const topFindings = result.findings.filter((f) => f.severity === "critical" || f.severity === "warning").slice(0, 3);
|
|
4607
|
+
if (topFindings.length > 0) {
|
|
4608
|
+
for (let i = 0; i < topFindings.length; i++) {
|
|
4609
|
+
const f = topFindings[i];
|
|
4610
|
+
const color = SEVERITY_COLORS[f.severity];
|
|
4611
|
+
const label = SEVERITY_LABELS[f.severity];
|
|
4612
|
+
lines.push(` ${pc.dim(`${i + 1}.`)} ${f.file}:${f.line} ${pc.dim("\u2014")} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`);
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
lines.push("");
|
|
4616
|
+
return lines.join("\n");
|
|
4617
|
+
}
|
|
4618
|
+
var SARIF_SEVERITY_MAP = {
|
|
4619
|
+
critical: "error",
|
|
4620
|
+
warning: "warning",
|
|
4621
|
+
info: "note"
|
|
4622
|
+
};
|
|
4623
|
+
function reportSarif(result) {
|
|
4624
|
+
const ruleMap = /* @__PURE__ */ new Map();
|
|
4625
|
+
for (const r of rules) {
|
|
4626
|
+
ruleMap.set(r.id, r);
|
|
4627
|
+
}
|
|
4628
|
+
const usedRuleIds = new Set(result.findings.map((f) => f.ruleId));
|
|
4629
|
+
const sarifRules = [...usedRuleIds].map((id) => {
|
|
4630
|
+
const rule = ruleMap.get(id);
|
|
4631
|
+
return {
|
|
4632
|
+
id,
|
|
4633
|
+
name: rule?.name ?? id,
|
|
4634
|
+
shortDescription: { text: rule?.description ?? id },
|
|
4635
|
+
defaultConfiguration: {
|
|
4636
|
+
level: SARIF_SEVERITY_MAP[rule?.severity ?? "info"] ?? "note"
|
|
4637
|
+
},
|
|
4638
|
+
helpUri: `https://prodlint.com/rules/${id}`
|
|
4639
|
+
};
|
|
4640
|
+
});
|
|
4641
|
+
const sarifResults = result.findings.map((f) => {
|
|
4642
|
+
const sarifResult = {
|
|
4643
|
+
ruleId: f.ruleId,
|
|
4644
|
+
level: SARIF_SEVERITY_MAP[f.severity] ?? "note",
|
|
4645
|
+
message: { text: f.message },
|
|
4646
|
+
locations: [
|
|
4647
|
+
{
|
|
4648
|
+
physicalLocation: {
|
|
4649
|
+
artifactLocation: { uri: f.file.replace(/\\/g, "/") },
|
|
4650
|
+
region: { startLine: f.line, startColumn: f.column }
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
]
|
|
4654
|
+
};
|
|
4655
|
+
if (f.fix) {
|
|
4656
|
+
sarifResult.fixes = [
|
|
4657
|
+
{
|
|
4658
|
+
description: { text: f.fix }
|
|
4659
|
+
}
|
|
4660
|
+
];
|
|
4661
|
+
}
|
|
4662
|
+
return sarifResult;
|
|
4663
|
+
});
|
|
4664
|
+
const sarif = {
|
|
4665
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
4666
|
+
version: "2.1.0",
|
|
4667
|
+
runs: [
|
|
4668
|
+
{
|
|
4669
|
+
tool: {
|
|
4670
|
+
driver: {
|
|
4671
|
+
name: "prodlint",
|
|
4672
|
+
version: getVersion(),
|
|
4673
|
+
informationUri: "https://prodlint.com",
|
|
4674
|
+
rules: sarifRules
|
|
4675
|
+
}
|
|
4676
|
+
},
|
|
4677
|
+
results: sarifResults
|
|
4678
|
+
}
|
|
4679
|
+
]
|
|
4680
|
+
};
|
|
4681
|
+
return JSON.stringify(sarif, null, 2);
|
|
4682
|
+
}
|
|
4683
|
+
function renderBar(score) {
|
|
4684
|
+
const width = 20;
|
|
4685
|
+
const filled = Math.round(score / 100 * width);
|
|
4686
|
+
const empty = width - filled;
|
|
4687
|
+
const color = scoreColor(score);
|
|
4688
|
+
return color("\u2588".repeat(filled)) + pc.dim("\u2591".repeat(empty));
|
|
4689
|
+
}
|
|
4690
|
+
var STATUS_ICONS = {
|
|
4691
|
+
pass: pc.green,
|
|
4692
|
+
fail: pc.red,
|
|
4693
|
+
warn: pc.yellow,
|
|
4694
|
+
info: pc.blue
|
|
4695
|
+
};
|
|
4696
|
+
|
|
4489
4697
|
// src/web-scanner/checks.ts
|
|
4490
4698
|
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4491
4699
|
return {
|
|
@@ -4726,6 +4934,10 @@ async function runWebScan(targetUrl) {
|
|
|
4726
4934
|
};
|
|
4727
4935
|
}
|
|
4728
4936
|
export {
|
|
4937
|
+
reportJson,
|
|
4938
|
+
reportPretty,
|
|
4939
|
+
reportSarif,
|
|
4940
|
+
reportSummary,
|
|
4729
4941
|
rules,
|
|
4730
4942
|
runWebScan,
|
|
4731
4943
|
scan
|
package/dist/mcp.js
CHANGED
|
@@ -719,8 +719,8 @@ function summarizeFindings(findings) {
|
|
|
719
719
|
|
|
720
720
|
// src/rules/secrets.ts
|
|
721
721
|
var SECRET_PATTERNS = [
|
|
722
|
-
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{
|
|
723
|
-
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{
|
|
722
|
+
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
723
|
+
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
724
724
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
725
725
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
726
726
|
{ name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
|
|
@@ -1254,7 +1254,7 @@ var corsConfigRule = {
|
|
|
1254
1254
|
};
|
|
1255
1255
|
|
|
1256
1256
|
// src/rules/ai-smells.ts
|
|
1257
|
-
var CONSOLE_LOG_THRESHOLD =
|
|
1257
|
+
var CONSOLE_LOG_THRESHOLD = 3;
|
|
1258
1258
|
var ANY_TYPE_THRESHOLD = 5;
|
|
1259
1259
|
var COMMENTED_CODE_THRESHOLD = 3;
|
|
1260
1260
|
var aiSmellsRule = {
|
|
@@ -3294,6 +3294,7 @@ var useClientOveruseRule = {
|
|
|
3294
3294
|
// src/rules/env-fallback-secret.ts
|
|
3295
3295
|
var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
|
|
3296
3296
|
var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
|
|
3297
|
+
var CONN_STRING_FALLBACK = /process\.env\.\w+\s*(?:\|\||\?\?)\s*['"`](?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\//i;
|
|
3297
3298
|
var envFallbackSecretRule = {
|
|
3298
3299
|
id: "env-fallback-secret",
|
|
3299
3300
|
name: "Secret with Fallback Value",
|
|
@@ -3321,6 +3322,20 @@ var envFallbackSecretRule = {
|
|
|
3321
3322
|
});
|
|
3322
3323
|
continue;
|
|
3323
3324
|
}
|
|
3325
|
+
const connMatch = CONN_STRING_FALLBACK.exec(line);
|
|
3326
|
+
if (connMatch) {
|
|
3327
|
+
findings.push({
|
|
3328
|
+
ruleId: "env-fallback-secret",
|
|
3329
|
+
file: file.relativePath,
|
|
3330
|
+
line: i + 1,
|
|
3331
|
+
column: connMatch.index + 1,
|
|
3332
|
+
message: "Connection string with credentials used as fallback \u2014 hardcoded DB/service URL becomes the production connection when env var is missing",
|
|
3333
|
+
severity: "warning",
|
|
3334
|
+
category: "security",
|
|
3335
|
+
fix: "Fail fast when required env vars are missing instead of falling back to a default value"
|
|
3336
|
+
});
|
|
3337
|
+
continue;
|
|
3338
|
+
}
|
|
3324
3339
|
const genericMatch = ENV_FALLBACK.exec(line);
|
|
3325
3340
|
if (genericMatch && !isConfigFile(file.relativePath)) {
|
|
3326
3341
|
findings.push({
|
|
@@ -4796,9 +4811,9 @@ server.tool(
|
|
|
4796
4811
|
if (result.findings.length > 0) {
|
|
4797
4812
|
summary.push("", "### Findings");
|
|
4798
4813
|
for (const f of result.findings.slice(0, 30)) {
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4814
|
+
const line = `- **[${f.severity}]** \`${f.ruleId}\` ${f.file}:${f.line} \u2014 ${f.message}`;
|
|
4815
|
+
summary.push(f.fix ? `${line}
|
|
4816
|
+
- _Fix: ${f.fix}_` : line);
|
|
4802
4817
|
}
|
|
4803
4818
|
if (result.findings.length > 30) {
|
|
4804
4819
|
summary.push(`- ...and ${result.findings.length - 30} more findings`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prodlint",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Production readiness for vibe-coded apps — know your AI code is ready to ship",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "prodlint contributors",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@babel/parser": "^7.29.0",
|
|
47
47
|
"@babel/types": "^7.29.0",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
49
|
+
"@rollup/rollup-win32-x64-msvc": "^4.59.0",
|
|
49
50
|
"fast-glob": "^3.3.3",
|
|
50
51
|
"picocolors": "^1.1.1",
|
|
51
52
|
"zod": "^4.3.6"
|