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 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, under 100ms.
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.0
17
- Scanned 148 files · 3 critical · 5 warnings
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 CRIT No rate limiting — anyone could spam this endpoint and run up your API costs rate-limiting
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
- 3 critical · 5 warnings · 3 info
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]{20,}/ },
719
- { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
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 = 5;
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
- const minSeverity = values["min-severity"] ?? "info";
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
- if (values.json) {
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 to show: critical, warning, info (default: info)
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 Scan current directory
4965
- npx prodlint ./my-app Scan specific path
4966
- npx prodlint --json JSON output
4967
- npx prodlint --ignore "*.test" Ignore test files
4968
- npx prodlint --min-severity warning Only warnings and criticals
4969
- npx prodlint --quiet No badge output
4970
- npx prodlint --web example.com Site score
4971
- npx prodlint --web example.com --json Site score with JSON output
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]{20,}/ },
714
- { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
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 = 5;
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(` [![prodlint](${badgeUrl})](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]{20,}/ },
723
- { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
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 = 5;
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
- summary.push(
4800
- `- **[${f.severity}]** \`${f.ruleId}\` ${f.file}:${f.line} \u2014 ${f.message}`
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.0",
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"