getdoorman 1.0.0

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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/bin/doorman.js +444 -0
  4. package/package.json +74 -0
  5. package/src/ai-fixer.js +559 -0
  6. package/src/ast-scanner.js +434 -0
  7. package/src/auth.js +149 -0
  8. package/src/baseline.js +48 -0
  9. package/src/compliance.js +539 -0
  10. package/src/config.js +466 -0
  11. package/src/custom-rules.js +32 -0
  12. package/src/dashboard.js +202 -0
  13. package/src/detector.js +142 -0
  14. package/src/fix-engine.js +48 -0
  15. package/src/fix-registry-extra.js +95 -0
  16. package/src/fix-registry-go-rust.js +77 -0
  17. package/src/fix-registry-java-csharp.js +77 -0
  18. package/src/fix-registry-js.js +99 -0
  19. package/src/fix-registry-mcp-ai.js +57 -0
  20. package/src/fix-registry-python.js +87 -0
  21. package/src/fixer-ruby-php.js +608 -0
  22. package/src/fixer.js +2113 -0
  23. package/src/hooks.js +115 -0
  24. package/src/ignore.js +176 -0
  25. package/src/index.js +384 -0
  26. package/src/metrics.js +126 -0
  27. package/src/monorepo.js +65 -0
  28. package/src/presets.js +54 -0
  29. package/src/reporter.js +975 -0
  30. package/src/rule-worker.js +36 -0
  31. package/src/rules/ast-rules.js +756 -0
  32. package/src/rules/bugs/accessibility.js +235 -0
  33. package/src/rules/bugs/ai-codegen-fixable.js +172 -0
  34. package/src/rules/bugs/ai-codegen.js +365 -0
  35. package/src/rules/bugs/code-smell-bugs.js +247 -0
  36. package/src/rules/bugs/crypto-bugs.js +195 -0
  37. package/src/rules/bugs/docker-bugs.js +158 -0
  38. package/src/rules/bugs/general.js +361 -0
  39. package/src/rules/bugs/go-bugs.js +279 -0
  40. package/src/rules/bugs/index.js +73 -0
  41. package/src/rules/bugs/js-api.js +257 -0
  42. package/src/rules/bugs/js-array-object.js +210 -0
  43. package/src/rules/bugs/js-async-fixable.js +223 -0
  44. package/src/rules/bugs/js-async.js +211 -0
  45. package/src/rules/bugs/js-closure-scope.js +182 -0
  46. package/src/rules/bugs/js-database.js +203 -0
  47. package/src/rules/bugs/js-error-handling.js +148 -0
  48. package/src/rules/bugs/js-logic.js +261 -0
  49. package/src/rules/bugs/js-memory.js +214 -0
  50. package/src/rules/bugs/js-node.js +361 -0
  51. package/src/rules/bugs/js-react.js +373 -0
  52. package/src/rules/bugs/js-regex.js +200 -0
  53. package/src/rules/bugs/js-state.js +272 -0
  54. package/src/rules/bugs/js-type-coercion.js +318 -0
  55. package/src/rules/bugs/nextjs-bugs.js +242 -0
  56. package/src/rules/bugs/nextjs-fixable.js +120 -0
  57. package/src/rules/bugs/node-fixable.js +178 -0
  58. package/src/rules/bugs/python-advanced.js +245 -0
  59. package/src/rules/bugs/python-fixable.js +98 -0
  60. package/src/rules/bugs/python.js +284 -0
  61. package/src/rules/bugs/react-fixable.js +207 -0
  62. package/src/rules/bugs/ruby-bugs.js +182 -0
  63. package/src/rules/bugs/shell-bugs.js +181 -0
  64. package/src/rules/bugs/silent-failures.js +261 -0
  65. package/src/rules/bugs/ts-bugs.js +235 -0
  66. package/src/rules/bugs/unused-vars.js +65 -0
  67. package/src/rules/compliance/accessibility-ext.js +468 -0
  68. package/src/rules/compliance/education.js +322 -0
  69. package/src/rules/compliance/financial.js +421 -0
  70. package/src/rules/compliance/frameworks.js +507 -0
  71. package/src/rules/compliance/healthcare.js +520 -0
  72. package/src/rules/compliance/index.js +2714 -0
  73. package/src/rules/compliance/regional-eu.js +480 -0
  74. package/src/rules/compliance/regional-international.js +903 -0
  75. package/src/rules/cost/index.js +1993 -0
  76. package/src/rules/data/index.js +2503 -0
  77. package/src/rules/dependencies/index.js +1684 -0
  78. package/src/rules/deployment/index.js +2050 -0
  79. package/src/rules/index.js +71 -0
  80. package/src/rules/infrastructure/index.js +3048 -0
  81. package/src/rules/performance/index.js +3455 -0
  82. package/src/rules/quality/index.js +3175 -0
  83. package/src/rules/reliability/index.js +3040 -0
  84. package/src/rules/scope-rules.js +815 -0
  85. package/src/rules/security/ai-api.js +1177 -0
  86. package/src/rules/security/auth.js +1328 -0
  87. package/src/rules/security/cors.js +127 -0
  88. package/src/rules/security/crypto.js +527 -0
  89. package/src/rules/security/csharp.js +862 -0
  90. package/src/rules/security/csrf.js +193 -0
  91. package/src/rules/security/dart.js +835 -0
  92. package/src/rules/security/deserialization.js +291 -0
  93. package/src/rules/security/file-upload.js +187 -0
  94. package/src/rules/security/go.js +850 -0
  95. package/src/rules/security/headers.js +235 -0
  96. package/src/rules/security/index.js +65 -0
  97. package/src/rules/security/injection.js +1639 -0
  98. package/src/rules/security/mcp-server.js +71 -0
  99. package/src/rules/security/misconfiguration.js +660 -0
  100. package/src/rules/security/oauth-jwt.js +329 -0
  101. package/src/rules/security/path-traversal.js +295 -0
  102. package/src/rules/security/php.js +1054 -0
  103. package/src/rules/security/prototype-pollution.js +283 -0
  104. package/src/rules/security/rate-limiting.js +208 -0
  105. package/src/rules/security/ruby.js +1061 -0
  106. package/src/rules/security/rust.js +693 -0
  107. package/src/rules/security/secrets.js +747 -0
  108. package/src/rules/security/shell.js +647 -0
  109. package/src/rules/security/ssrf.js +298 -0
  110. package/src/rules/security/supply-chain-advanced.js +393 -0
  111. package/src/rules/security/supply-chain.js +734 -0
  112. package/src/rules/security/swift.js +835 -0
  113. package/src/rules/security/taint.js +27 -0
  114. package/src/rules/security/xss.js +520 -0
  115. package/src/scan-cache.js +71 -0
  116. package/src/scanner.js +710 -0
  117. package/src/scope-analyzer.js +685 -0
  118. package/src/share.js +88 -0
  119. package/src/taint.js +300 -0
  120. package/src/telemetry.js +183 -0
  121. package/src/tracer.js +190 -0
  122. package/src/upload.js +35 -0
  123. package/src/worker.js +31 -0
@@ -0,0 +1,975 @@
1
+ import chalk from 'chalk';
2
+ import { writeFileSync } from 'fs';
3
+
4
+ const SEVERITY_COLORS = {
5
+ critical: chalk.bgRed.white.bold,
6
+ high: chalk.red.bold,
7
+ medium: chalk.yellow,
8
+ low: chalk.blue,
9
+ info: chalk.gray,
10
+ };
11
+
12
+ const SEVERITY_LABELS = {
13
+ critical: 'CRITICAL',
14
+ high: 'HIGH ',
15
+ medium: 'MEDIUM ',
16
+ low: 'LOW ',
17
+ info: 'INFO ',
18
+ };
19
+
20
+ const SEVERITY_ICONS = {
21
+ critical: '\u{1F534}',
22
+ high: '\u{1F7E0}',
23
+ medium: '\u{1F7E1}',
24
+ low: '\u{1F535}',
25
+ info: '\u{26AA}',
26
+ };
27
+
28
+ const CONFIDENCE_LEVELS = ['definite', 'likely', 'suggestion'];
29
+ const CONFIDENCE_WEIGHT = { definite: 1, likely: 0.6, suggestion: 0 };
30
+
31
+ const CONFIDENCE_BADGES = {
32
+ definite: chalk.bgRed.white(' DEFINITE '),
33
+ likely: chalk.bgYellow.black(' LIKELY '),
34
+ suggestion: chalk.bgGray.white(' SUGGESTION '),
35
+ };
36
+
37
+ const CATEGORY_NAMES = {
38
+ security: 'SECURITY',
39
+ performance: 'PERFORMANCE',
40
+ reliability: 'RELIABILITY',
41
+ cost: 'COST',
42
+ compliance: 'COMPLIANCE',
43
+ data: 'DATA PROTECTION',
44
+ infrastructure: 'INFRASTRUCTURE',
45
+ quality: 'CODE QUALITY',
46
+ dependencies: 'DEPENDENCIES',
47
+ deployment: 'DEPLOYMENT',
48
+ bugs: 'BUGS',
49
+ };
50
+
51
+ // --- Scoring ---
52
+
53
+ const SEVERITY_DEDUCTIONS = {
54
+ critical: 10,
55
+ high: 4,
56
+ medium: 1.5,
57
+ low: 0.5,
58
+ info: 0,
59
+ };
60
+
61
+ const SUGGESTION_DEDUCTION = 0.5;
62
+
63
+ /**
64
+ * Categories that count toward the safety score.
65
+ * Other categories (compliance, infrastructure, deployment, dependencies) are
66
+ * shown as "Recommendations" and do NOT tank the score.
67
+ */
68
+ const SCORE_CATEGORIES = new Set([
69
+ 'security', 'bugs', 'reliability', 'performance', 'cost', 'data', 'quality',
70
+ ]);
71
+
72
+ /**
73
+ * Calculate the safety score based on findings and project signals.
74
+ * Only findings in SCORE_CATEGORIES affect the score. Compliance, infrastructure,
75
+ * deployment, and dependency findings are shown as recommendations.
76
+ * @param {Array} findings - array of finding objects
77
+ * @param {object} [projectSignals] - optional { hasSecurityMiddleware, hasTests, hasCI }
78
+ * @returns {number} score 0-100
79
+ */
80
+ export function calculateScore(findings, projectSignals = {}) {
81
+ let score = 100;
82
+
83
+ // Calculate raw deductions per finding, then apply diminishing returns per severity group.
84
+ // This preserves per-finding confidence weights while preventing hundreds of findings
85
+ // from obliterating the score.
86
+ const groups = {};
87
+
88
+ for (const finding of findings) {
89
+ // Skip recommendation categories — they don't affect score
90
+ const cat = (finding.category || '').toLowerCase();
91
+ if (cat && !SCORE_CATEGORIES.has(cat)) continue;
92
+
93
+ const confidence = finding.confidence || 'likely';
94
+ const weight = CONFIDENCE_WEIGHT[confidence] ?? 0.6;
95
+ const severity = finding.severity || 'info';
96
+
97
+ if (confidence === 'suggestion') {
98
+ score -= SUGGESTION_DEDUCTION * weight;
99
+ continue;
100
+ }
101
+
102
+ if (!groups[severity]) groups[severity] = [];
103
+ groups[severity].push((SEVERITY_DEDUCTIONS[severity] || 0) * weight);
104
+ }
105
+
106
+ // Apply diminishing returns: full deduction for first N findings per severity,
107
+ // then logarithmic scaling. This keeps small counts accurate (matching old behavior)
108
+ // while preventing 500+ medium findings from pushing the score to 0.
109
+ const FULL_IMPACT_THRESHOLD = { critical: 2, high: 4, medium: 8, low: 20, info: 50 };
110
+
111
+ for (const [severity, deductions] of Object.entries(groups)) {
112
+ const threshold = FULL_IMPACT_THRESHOLD[severity] || 10;
113
+ // Sort descending so highest-confidence findings get full impact
114
+ deductions.sort((a, b) => b - a);
115
+
116
+ if (deductions.length <= threshold) {
117
+ // Small count: full impact (preserves original behavior for few findings)
118
+ for (const d of deductions) score -= d;
119
+ } else {
120
+ // Full impact up to threshold
121
+ for (let i = 0; i < threshold; i++) score -= deductions[i];
122
+ // Logarithmic scaling for the rest
123
+ const extraCount = deductions.length - threshold;
124
+ if (extraCount > 0) {
125
+ const avgDeduction = deductions.slice(threshold).reduce((a, b) => a + b, 0) / extraCount;
126
+ score -= Math.log2(extraCount + 1) * avgDeduction;
127
+ }
128
+ }
129
+ }
130
+
131
+ // Bonus points for good practices
132
+ if (projectSignals.hasSecurityMiddleware) score += 5;
133
+ if (projectSignals.hasTests) score += 5;
134
+ if (projectSignals.hasCI) score += 5;
135
+
136
+ return Math.max(0, Math.min(100, Math.round(score)));
137
+ }
138
+
139
+ // --- Severity summary helpers ---
140
+
141
+ function countBySeverity(findings) {
142
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
143
+ for (const f of findings) {
144
+ const sev = f.severity || 'info';
145
+ if (counts[sev] !== undefined) counts[sev]++;
146
+ }
147
+ return counts;
148
+ }
149
+
150
+ function countByCategory(findings) {
151
+ const counts = {};
152
+ for (const f of findings) {
153
+ const cat = f.category || 'other';
154
+ counts[cat] = (counts[cat] || 0) + 1;
155
+ }
156
+ return counts;
157
+ }
158
+
159
+ function groupByFile(findings) {
160
+ const groups = {};
161
+ for (const f of findings) {
162
+ const file = f.file || '(no file)';
163
+ if (!groups[file]) groups[file] = [];
164
+ groups[file].push(f);
165
+ }
166
+ return groups;
167
+ }
168
+
169
+ const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
170
+
171
+ /**
172
+ * Check if a finding has a real auto-fix (replace/insert/create), not just a suggestion string.
173
+ */
174
+ function isAutoFixable(finding) {
175
+ const fix = finding.fix;
176
+ if (!fix) return false;
177
+ if (fix === true) return true;
178
+ if (typeof fix === 'object') return true; // Any fix object means we can try
179
+ return false;
180
+ }
181
+
182
+ function sortBySeverity(findings) {
183
+ return [...findings].sort(
184
+ (a, b) => (SEVERITY_ORDER[a.severity] || 4) - (SEVERITY_ORDER[b.severity] || 4),
185
+ );
186
+ }
187
+
188
+ // --- Terminal output ---
189
+
190
+ function printSeverityBar(counts) {
191
+ const parts = [];
192
+ if (counts.critical > 0) parts.push(`${SEVERITY_ICONS.critical} ${counts.critical} critical`);
193
+ if (counts.high > 0) parts.push(`${SEVERITY_ICONS.high} ${counts.high} high`);
194
+ if (counts.medium > 0) parts.push(`${SEVERITY_ICONS.medium} ${counts.medium} medium`);
195
+ if (counts.low > 0) parts.push(`${SEVERITY_ICONS.low} ${counts.low} low`);
196
+ if (counts.info > 0) parts.push(`${SEVERITY_ICONS.info} ${counts.info} info`);
197
+
198
+ if (parts.length === 0) {
199
+ console.log(chalk.green.bold(' No issues found!'));
200
+ } else {
201
+ console.log(` ${parts.join(' | ')}`);
202
+ }
203
+ console.log('');
204
+ }
205
+
206
+ function printScoreBadge(score) {
207
+ let color, label;
208
+ if (score >= 80) {
209
+ color = chalk.green.bold;
210
+ label = 'SAFE TO LAUNCH';
211
+ } else if (score >= 60) {
212
+ color = chalk.yellow.bold;
213
+ label = 'NEEDS ATTENTION';
214
+ } else if (score >= 40) {
215
+ color = chalk.red.bold;
216
+ label = 'AT RISK';
217
+ } else {
218
+ color = chalk.bgRed.white.bold;
219
+ label = 'NOT SAFE TO LAUNCH';
220
+ }
221
+
222
+ console.log(` Score: ${color(`${score}/100`)} — ${color(label)}`);
223
+ }
224
+
225
+ /**
226
+ * Print detailed findings grouped by file (used when --detail flag is set or few findings).
227
+ */
228
+ function printDetailedFindings(visible) {
229
+ const byFile = groupByFile(visible);
230
+ for (const [file, fileFindings] of Object.entries(byFile)) {
231
+ console.log(chalk.bold.white(` ${file}`));
232
+ const sorted = sortBySeverity(fileFindings);
233
+
234
+ for (const item of sorted) {
235
+ const sevColor = SEVERITY_COLORS[item.severity] || chalk.white;
236
+ const sevLabel = SEVERITY_LABELS[item.severity] || item.severity;
237
+ const confidence = item.confidence || 'likely';
238
+ const badge = CONFIDENCE_BADGES[confidence] || '';
239
+ const fixTag = isAutoFixable(item) ? chalk.green(' [auto-fixable]') : '';
240
+ const lineInfo = item.line ? chalk.gray(`:${item.line}`) : '';
241
+
242
+ console.log(` ${sevColor(sevLabel)} ${item.title}${fixTag}${lineInfo}`);
243
+
244
+ if (item.codeContext) {
245
+ console.log(chalk.gray(` > ${item.codeContext.trim()}`));
246
+ }
247
+
248
+ if (item.fix?.suggestion) {
249
+ console.log(chalk.gray(` Tip: ${item.fix.suggestion}`));
250
+ } else if (item.fix && typeof item.fix === 'string') {
251
+ console.log(chalk.green(` Fix: ${item.fix}`));
252
+ } else if (item.description && (item.severity === 'critical' || item.severity === 'high')) {
253
+ console.log(chalk.gray(` ${item.description}`));
254
+ }
255
+ }
256
+ console.log('');
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Estimate the score impact of fixing a set of findings.
262
+ */
263
+ function estimateScoreGain(findingsToFix) {
264
+ let gain = 0;
265
+ for (const f of findingsToFix) {
266
+ const confidence = f.confidence || 'likely';
267
+ const weight = CONFIDENCE_WEIGHT[confidence] ?? 0.6;
268
+ const severity = f.severity || 'info';
269
+ if (confidence === 'suggestion') {
270
+ gain += SUGGESTION_DEDUCTION * weight;
271
+ } else {
272
+ gain += (SEVERITY_DEDUCTIONS[severity] || 0) * weight;
273
+ }
274
+ }
275
+ return Math.round(gain);
276
+ }
277
+
278
+ /**
279
+ * Print the smart summary — category breakdown, top priorities, score roadmap.
280
+ */
281
+ function printSmartSummary(visible, score) {
282
+ // --- Category breakdown ---
283
+ const catCounts = countByCategory(visible);
284
+ const catEntries = Object.entries(catCounts).sort((a, b) => b[1] - a[1]);
285
+
286
+ console.log(chalk.bold.white(' Issues by category:'));
287
+ console.log('');
288
+ for (const [cat, count] of catEntries) {
289
+ const label = CATEGORY_NAMES[cat] || cat.toUpperCase();
290
+ const barLen = Math.max(1, Math.round((count / visible.length) * 30));
291
+ const bar = chalk.cyan('\u2588'.repeat(barLen));
292
+ console.log(` ${chalk.gray(label.padEnd(18))} ${bar} ${chalk.bold(count)}`);
293
+ }
294
+ console.log('');
295
+
296
+ // --- Top 5 priorities ---
297
+ const prioritized = sortBySeverity(visible);
298
+ // Deduplicate by title (show unique issues, not the same issue in 50 files)
299
+ const seen = new Set();
300
+ const unique = [];
301
+ for (const f of prioritized) {
302
+ const key = f.ruleId || f.title;
303
+ if (!seen.has(key)) {
304
+ seen.add(key);
305
+ // Count how many files this issue appears in
306
+ const count = prioritized.filter(p => (p.ruleId || p.title) === key).length;
307
+ unique.push({ ...f, occurrences: count });
308
+ }
309
+ }
310
+
311
+ const top5 = unique.slice(0, 5);
312
+ console.log(chalk.bold.white(' Top priorities (fix these first):'));
313
+ console.log('');
314
+ for (let i = 0; i < top5.length; i++) {
315
+ const item = top5[i];
316
+ const sevColor = SEVERITY_COLORS[item.severity] || chalk.white;
317
+ const sevLabel = (item.severity || 'info').toUpperCase().padEnd(8);
318
+ const fixTag = isAutoFixable(item) ? chalk.green(' \u2713 auto-fixable') : '';
319
+ const fileCount = item.occurrences > 1 ? chalk.gray(` \u00d7 ${item.occurrences} files`) : '';
320
+ console.log(` ${chalk.bold(`${i + 1}.`)} ${sevColor(sevLabel)} ${item.title}${fixTag}${fileCount}`);
321
+ if (item.description) {
322
+ const desc = item.description.length > 120 ? item.description.slice(0, 120) + '...' : item.description;
323
+ console.log(chalk.gray(` ${desc}`));
324
+ }
325
+ }
326
+ if (unique.length > 5) {
327
+ console.log(chalk.gray(` ... and ${unique.length - 5} more unique issues`));
328
+ }
329
+ console.log('');
330
+
331
+ // --- Score roadmap ---
332
+ printScoreRoadmap(visible, score);
333
+ }
334
+
335
+ /**
336
+ * Print an action-oriented roadmap showing what to do next.
337
+ */
338
+ function printScoreRoadmap(visible, score) {
339
+ const autoFixable = visible.filter(f => isAutoFixable(f));
340
+ const criticalCount = visible.filter(f => f.severity === 'critical').length;
341
+ const highCount = visible.filter(f => f.severity === 'high').length;
342
+ const mediumCount = visible.filter(f => f.severity === 'medium').length;
343
+
344
+ console.log(chalk.bold.white(' What to do next:'));
345
+ console.log('');
346
+
347
+ let stepNum = 1;
348
+
349
+ // Step 1: Auto-fix (always show if available — it's the easy win)
350
+ if (autoFixable.length > 0) {
351
+ console.log(` ${chalk.green(`${stepNum}.`)} Run ${chalk.bold.green('npx getdoorman fix')} — instantly resolves ${chalk.bold(autoFixable.length)} issues`);
352
+ stepNum++;
353
+ }
354
+
355
+ // Step 2: Critical findings
356
+ if (criticalCount > 0) {
357
+ // Deduplicate by ruleId to show unique issue types
358
+ const uniqueCritical = new Set(visible.filter(f => f.severity === 'critical').map(f => f.ruleId || f.title));
359
+ console.log(` ${chalk.red(`${stepNum}.`)} Fix ${chalk.bold(criticalCount)} critical issues (${uniqueCritical.size} unique types)`);
360
+ // Show top 3 critical issue types
361
+ const critTypes = {};
362
+ for (const f of visible.filter(f => f.severity === 'critical')) {
363
+ const key = f.title || f.ruleId;
364
+ critTypes[key] = (critTypes[key] || 0) + 1;
365
+ }
366
+ const topCrit = Object.entries(critTypes).sort((a, b) => b[1] - a[1]).slice(0, 3);
367
+ for (const [title, count] of topCrit) {
368
+ const suffix = count > 1 ? chalk.gray(` (${count}×)`) : '';
369
+ console.log(chalk.gray(` \u2022 ${title}${suffix}`));
370
+ }
371
+ stepNum++;
372
+ }
373
+
374
+ // Step 3: High findings
375
+ if (highCount > 0) {
376
+ const uniqueHigh = new Set(visible.filter(f => f.severity === 'high').map(f => f.ruleId || f.title));
377
+ console.log(` ${chalk.yellow(`${stepNum}.`)} Fix ${chalk.bold(highCount)} high issues (${uniqueHigh.size} unique types)`);
378
+ stepNum++;
379
+ }
380
+
381
+ // Step 4: Medium (just mention count)
382
+ if (mediumCount > 0) {
383
+ console.log(` ${chalk.blue(`${stepNum}.`)} Clean up ${chalk.bold(mediumCount)} medium issues for a polished codebase`);
384
+ }
385
+
386
+ // Show projected score after fixing critical + high
387
+ const remaining = visible.filter(f => f.severity !== 'critical' && f.severity !== 'high' && !isAutoFixable(f));
388
+ const projectedScore = calculateScore(remaining);
389
+ if (projectedScore > score) {
390
+ console.log('');
391
+ console.log(chalk.gray(` After steps 1${criticalCount > 0 ? '-' + (autoFixable.length > 0 ? 3 : 2) : ''}: ~${chalk.bold.white(projectedScore + '/100')}`));
392
+ }
393
+
394
+ console.log('');
395
+ }
396
+
397
+ /**
398
+ * Print the scan report to the terminal (default output).
399
+ */
400
+ export function printReport(findings, stack, score, options = {}) {
401
+ // Legacy JSON handling (kept for backward compat; index.js now handles JSON separately)
402
+ if (options.json) {
403
+ console.log(JSON.stringify({ score, findings, stack }, null, 2));
404
+ return;
405
+ }
406
+
407
+ console.log('');
408
+ console.log(chalk.bold.cyan('\u2501'.repeat(60)));
409
+ console.log(chalk.bold.cyan(' Doorman v1.0.0 \u2014 Scan Results'));
410
+ console.log(chalk.bold.cyan('\u2501'.repeat(60)));
411
+ console.log('');
412
+
413
+ // Detected stack
414
+ const stackParts = [
415
+ stack.framework || stack.language || 'Unknown',
416
+ stack.orm,
417
+ stack.database,
418
+ stack.hasTypescript ? 'TypeScript' : null,
419
+ stack.hasDocker ? 'Docker' : null,
420
+ ].filter(Boolean);
421
+
422
+ console.log(chalk.gray(` Detected: ${stackParts.join(' + ')}`));
423
+ console.log('');
424
+
425
+ // Normalize confidence and filter by verbosity
426
+ const normalized = findings.map((f) => ({ ...f, confidence: f.confidence || 'likely' }));
427
+ const visible = options.verbose
428
+ ? normalized
429
+ : normalized.filter((f) => f.confidence !== 'suggestion');
430
+
431
+ if (visible.length === 0) {
432
+ console.log(chalk.green.bold(' No issues found!'));
433
+ console.log('');
434
+ printScoreBadge(score);
435
+ console.log(chalk.bold.cyan('\u2501'.repeat(60)));
436
+ console.log('');
437
+ return;
438
+ }
439
+
440
+ // --- Determine output mode by plan ---
441
+ // Free: security + bugs only
442
+ // Pro ($20): security, bugs, performance, reliability, cost, data, quality
443
+ // Enterprise ($100): all categories (adds compliance, infrastructure, deployment, dependencies)
444
+ const FREE_CATEGORIES = new Set(['security', 'bugs']);
445
+ const PRO_CATEGORIES = new Set(['security', 'bugs', 'performance', 'reliability', 'cost', 'data', 'quality']);
446
+ const ENTERPRISE_ONLY = new Set(['compliance', 'infrastructure', 'deployment', 'dependencies']);
447
+
448
+ let userCategories;
449
+ if (options.allCategories) userCategories = null; // show all
450
+ else if (options.planCategories) userCategories = new Set(options.planCategories);
451
+ else userCategories = FREE_CATEGORIES;
452
+
453
+ const vibeVisible = userCategories ? visible.filter(f => userCategories.has(f.category)) : visible;
454
+ const hiddenCount = visible.length - vibeVisible.length;
455
+
456
+ // Count what Pro would unlock vs what Enterprise would unlock
457
+ const proUnlockCount = userCategories === FREE_CATEGORIES
458
+ ? visible.filter(f => PRO_CATEGORIES.has(f.category) && !FREE_CATEGORIES.has(f.category)).length
459
+ : 0;
460
+ const enterpriseUnlockCount = userCategories && userCategories !== null
461
+ ? visible.filter(f => ENTERPRISE_ONLY.has(f.category)).length
462
+ : 0;
463
+
464
+ if (options.detail) {
465
+ // Full detail: every finding, per file
466
+ printDetailedFindings(visible);
467
+ } else if (options.strict) {
468
+ // Strict: show smart summary with ALL findings
469
+ printSeverityBar(countBySeverity(visible));
470
+ printSmartSummary(visible, score);
471
+ } else {
472
+ // DEFAULT: Vibe coder mode — gated by plan
473
+ printVibeCoderReport(vibeVisible, score, hiddenCount, proUnlockCount, enterpriseUnlockCount);
474
+ }
475
+
476
+ // Footer
477
+ console.log(chalk.bold.cyan('\u2501'.repeat(60)));
478
+ printScoreBadge(score);
479
+
480
+ // Show auto-fix banner in footer only for strict/detail modes (vibe coder mode has its own)
481
+ if (options.strict || options.detail) {
482
+ const totalAutoFix = findings.filter((f) => isAutoFixable(f)).length;
483
+ if (totalAutoFix > 0) {
484
+ console.log('');
485
+ console.log(
486
+ chalk.yellow(` ${totalAutoFix} auto-fixable issue${totalAutoFix === 1 ? '' : 's'} found.`),
487
+ );
488
+ console.log(chalk.yellow(` Run ${chalk.bold('npx getdoorman fix')} to fix them automatically.`));
489
+ }
490
+ }
491
+
492
+ if (!options.detail) {
493
+ console.log('');
494
+ console.log(chalk.gray(` ${chalk.bold('--detail')} to see every finding | ${chalk.bold('--strict')} to see all severities`));
495
+ }
496
+
497
+ console.log(chalk.bold.cyan('\u2501'.repeat(60)));
498
+ console.log('');
499
+
500
+ // One-line summary at the end — use filtered findings to match the report above
501
+ const summaryFindings = (options.strict || options.detail) ? findings : vibeVisible;
502
+ printSummaryLine(summaryFindings, score, { minScore: options.minScore });
503
+ console.log('');
504
+ }
505
+
506
+ /**
507
+ * Vibe coder report — default mode.
508
+ * Shows top issues with file + line so users can paste into Claude/Codex to fix.
509
+ */
510
+ function printVibeCoderReport(visible, score, hiddenCategoryCount = 0, proUnlockCount = 0, enterpriseUnlockCount = 0) {
511
+ const critical = visible.filter(f => f.severity === 'critical');
512
+ const high = visible.filter(f => f.severity === 'high');
513
+ const medium = visible.filter(f => f.severity === 'medium');
514
+
515
+ // --- Show top issues with details ---
516
+ const sevColors = { critical: chalk.red, high: chalk.hex('#ff6b35'), medium: chalk.yellow };
517
+ const allSorted = [...critical, ...high, ...medium];
518
+ const showCount = Math.min(allSorted.length, 15);
519
+
520
+ if (critical.length > 0) {
521
+ console.log(chalk.red.bold(` ${critical.length} critical issue${critical.length === 1 ? '' : 's'}`));
522
+ }
523
+ if (high.length > 0) {
524
+ console.log(chalk.hex('#ff6b35').bold(` ${high.length} high-priority issue${high.length === 1 ? '' : 's'}`));
525
+ }
526
+ if (medium.length > 0) {
527
+ console.log(chalk.yellow(` ${medium.length} medium issue${medium.length === 1 ? '' : 's'}`));
528
+ }
529
+ console.log('');
530
+
531
+ for (let i = 0; i < showCount; i++) {
532
+ const f = allSorted[i];
533
+ const sev = (sevColors[f.severity] || chalk.white)(f.severity.toUpperCase().padEnd(8));
534
+ const loc = f.file ? chalk.gray(` ${f.file}${f.line ? ':' + f.line : ''}`) : '';
535
+ console.log(` ${sev} ${f.title}`);
536
+ if (loc) console.log(` ${loc}`);
537
+ }
538
+ if (allSorted.length > showCount) {
539
+ console.log(chalk.gray(` ... +${allSorted.length - showCount} more (use --detail to see all)`));
540
+ }
541
+ console.log('');
542
+
543
+ // --- Tell them how to fix ---
544
+ console.log(chalk.green(' To fix: paste the issues above into Claude, Codex, or Cursor.'));
545
+ console.log(chalk.gray(' Example: "Fix the SQL injection in src/api/search.ts:42"'));
546
+ console.log('');
547
+
548
+ // Projected score after fixing
549
+ const afterFixing = visible.filter(f => f.severity !== 'critical' && f.severity !== 'high' && !isAutoFixable(f));
550
+ const projected = calculateScore(afterFixing);
551
+ if (projected > score) {
552
+ console.log(chalk.gray(` After fixing: score goes from ${chalk.white.bold(score)} → ${chalk.green.bold(projected + '/100')}`));
553
+ console.log('');
554
+ }
555
+
556
+ // --- Upsell ---
557
+ if (proUnlockCount > 0) {
558
+ console.log(chalk.cyan(` 🔒 ${proUnlockCount} more issues in performance, cost, reliability, data...`));
559
+ console.log(chalk.gray(' Upgrade to Pro ($20/mo) to see all categories + unlimited fixes.'));
560
+ console.log('');
561
+ }
562
+ if (enterpriseUnlockCount > 0 && proUnlockCount === 0) {
563
+ console.log(chalk.gray(` + ${enterpriseUnlockCount} compliance/infrastructure findings (Enterprise plan)`));
564
+ console.log('');
565
+ }
566
+ }
567
+
568
+ // --- JSON output ---
569
+
570
+ /**
571
+ * Generate structured JSON output for CI integration.
572
+ */
573
+ export function generateJSON(findings, stack, score, metadata = {}) {
574
+ const counts = countBySeverity(findings);
575
+ const categoryCounts = countByCategory(findings);
576
+
577
+ return {
578
+ version: '1.0.0',
579
+ timestamp: new Date().toISOString(),
580
+ score,
581
+ summary: {
582
+ total: findings.length,
583
+ bySeverity: counts,
584
+ byCategory: categoryCounts,
585
+ },
586
+ findings: findings.map((f) => ({
587
+ ruleId: f.ruleId || null,
588
+ title: f.title || '',
589
+ description: f.description || '',
590
+ severity: f.severity || 'info',
591
+ confidence: f.confidence || 'likely',
592
+ category: f.category || 'other',
593
+ file: f.file || null,
594
+ line: f.line || null,
595
+ codeContext: f.codeContext || null,
596
+ fix: f.fix || null,
597
+ attackPath: f.attackPath || null,
598
+ })),
599
+ stack,
600
+ metadata: {
601
+ filesScanned: metadata.filesScanned || 0,
602
+ scanDurationSeconds: metadata.scanDurationSeconds || 0,
603
+ rulesMatched: new Set(findings.map((f) => f.ruleId).filter(Boolean)).size,
604
+ mode: metadata.mode || 'full',
605
+ ...(metadata.partial && { partial: true }),
606
+ },
607
+ };
608
+ }
609
+
610
+ // --- SARIF output ---
611
+
612
+ const SARIF_SEVERITY_MAP = {
613
+ critical: 'error',
614
+ high: 'error',
615
+ medium: 'warning',
616
+ low: 'note',
617
+ info: 'note',
618
+ };
619
+
620
+ /**
621
+ * Generate SARIF 2.1.0 output for GitHub Code Scanning, VS Code, etc.
622
+ */
623
+ export function generateSARIF(findings, stack, score, metadata = {}) {
624
+ // Collect unique rules
625
+ const ruleMap = new Map();
626
+ for (const f of findings) {
627
+ const ruleId = f.ruleId || `doorman-${f.category || 'general'}-${f.title ? f.title.replace(/\s+/g, '-').toLowerCase().slice(0, 40) : 'unknown'}`;
628
+ if (!ruleMap.has(ruleId)) {
629
+ ruleMap.set(ruleId, {
630
+ id: ruleId,
631
+ name: f.title || ruleId,
632
+ shortDescription: { text: f.title || ruleId },
633
+ fullDescription: { text: f.description || f.title || ruleId },
634
+ defaultConfiguration: {
635
+ level: SARIF_SEVERITY_MAP[f.severity] || 'note',
636
+ },
637
+ properties: {
638
+ tags: [f.category || 'security'],
639
+ },
640
+ });
641
+ }
642
+ }
643
+
644
+ const rules = Array.from(ruleMap.values());
645
+ const ruleIndex = {};
646
+ rules.forEach((r, i) => {
647
+ ruleIndex[r.id] = i;
648
+ });
649
+
650
+ const results = findings.map((f) => {
651
+ const ruleId = f.ruleId || `doorman-${f.category || 'general'}-${f.title ? f.title.replace(/\s+/g, '-').toLowerCase().slice(0, 40) : 'unknown'}`;
652
+ const result = {
653
+ ruleId,
654
+ ruleIndex: ruleIndex[ruleId] ?? 0,
655
+ level: SARIF_SEVERITY_MAP[f.severity] || 'note',
656
+ message: {
657
+ text: f.description || f.title || 'Finding detected',
658
+ },
659
+ };
660
+
661
+ if (f.file) {
662
+ result.locations = [
663
+ {
664
+ physicalLocation: {
665
+ artifactLocation: {
666
+ uri: f.file,
667
+ uriBaseId: '%SRCROOT%',
668
+ },
669
+ region: {
670
+ startLine: f.line || 1,
671
+ },
672
+ },
673
+ },
674
+ ];
675
+ }
676
+
677
+ if (f.fix) {
678
+ result.fixes = [
679
+ {
680
+ description: {
681
+ text: typeof f.fix === 'string' ? f.fix : 'Auto-fix available',
682
+ },
683
+ },
684
+ ];
685
+ }
686
+
687
+ return result;
688
+ });
689
+
690
+ return {
691
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
692
+ version: '2.1.0',
693
+ runs: [
694
+ {
695
+ tool: {
696
+ driver: {
697
+ name: 'Doorman',
698
+ version: '1.0.0',
699
+ informationUri: 'https://github.com/tiago520/VoiceAI',
700
+ rules,
701
+ },
702
+ },
703
+ results,
704
+ invocations: [
705
+ {
706
+ executionSuccessful: true,
707
+ endTimeUtc: new Date().toISOString(),
708
+ },
709
+ ],
710
+ properties: {
711
+ score,
712
+ filesScanned: metadata.filesScanned || 0,
713
+ scanDurationSeconds: metadata.scanDurationSeconds || 0,
714
+ },
715
+ },
716
+ ],
717
+ };
718
+ }
719
+
720
+ // --- HTML report ---
721
+
722
+ /**
723
+ * Generate a standalone HTML report with dark theme dashboard.
724
+ */
725
+ export function generateHTML(findings, stack, score, metadata = {}) {
726
+ const counts = countBySeverity(findings);
727
+ const categoryCounts = countByCategory(findings);
728
+ const sorted = sortBySeverity(findings);
729
+
730
+ const stackParts = [
731
+ stack.framework || stack.language || 'Unknown',
732
+ stack.orm,
733
+ stack.database,
734
+ stack.hasTypescript ? 'TypeScript' : null,
735
+ stack.hasDocker ? 'Docker' : null,
736
+ ].filter(Boolean);
737
+
738
+ let scoreColor;
739
+ let scoreLabel;
740
+ if (score >= 80) {
741
+ scoreColor = '#22c55e';
742
+ scoreLabel = 'SAFE TO LAUNCH';
743
+ } else if (score >= 60) {
744
+ scoreColor = '#eab308';
745
+ scoreLabel = 'NEEDS ATTENTION';
746
+ } else if (score >= 40) {
747
+ scoreColor = '#ef4444';
748
+ scoreLabel = 'AT RISK';
749
+ } else {
750
+ scoreColor = '#dc2626';
751
+ scoreLabel = 'NOT SAFE TO LAUNCH';
752
+ }
753
+
754
+ const sevColors = {
755
+ critical: '#dc2626',
756
+ high: '#ef4444',
757
+ medium: '#eab308',
758
+ low: '#3b82f6',
759
+ info: '#6b7280',
760
+ };
761
+
762
+ const findingsRows = sorted
763
+ .map((f, i) => {
764
+ const sev = f.severity || 'info';
765
+ const color = sevColors[sev] || '#6b7280';
766
+ const file = f.file ? `${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}` : '-';
767
+ const confidence = f.confidence || 'likely';
768
+ return `<tr>
769
+ <td>${i + 1}</td>
770
+ <td><span class="sev-badge" style="background:${color}">${sev.toUpperCase()}</span></td>
771
+ <td>${escapeHtml(f.title || '')}</td>
772
+ <td>${escapeHtml(f.category || '')}</td>
773
+ <td>${file}</td>
774
+ <td><span class="conf-badge conf-${confidence}">${confidence}</span></td>
775
+ <td>${f.fix ? '<span class="fix-yes">Yes</span>' : '-'}</td>
776
+ </tr>`;
777
+ })
778
+ .join('\n');
779
+
780
+ const categoryChartData = Object.entries(categoryCounts)
781
+ .sort((a, b) => b[1] - a[1])
782
+ .map(
783
+ ([cat, count]) =>
784
+ `<div class="cat-bar-row"><span class="cat-label">${escapeHtml(CATEGORY_NAMES[cat] || cat.toUpperCase())}</span><div class="cat-bar" style="width:${Math.max(5, (count / Math.max(findings.length, 1)) * 100)}%">${count}</div></div>`,
785
+ )
786
+ .join('\n');
787
+
788
+ // Score gauge SVG
789
+ const gaugeAngle = (score / 100) * 180;
790
+ const gaugeRad = (gaugeAngle * Math.PI) / 180;
791
+ const gaugeX = 50 + 40 * Math.cos(Math.PI - gaugeRad);
792
+ const gaugeY = 50 - 40 * Math.sin(Math.PI - gaugeRad);
793
+ const largeArc = gaugeAngle > 90 ? 1 : 0;
794
+
795
+ return `<!DOCTYPE html>
796
+ <html lang="en">
797
+ <head>
798
+ <meta charset="UTF-8">
799
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
800
+ <title>Doorman Report</title>
801
+ <style>
802
+ * { margin: 0; padding: 0; box-sizing: border-box; }
803
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; background: #0f172a; color: #e2e8f0; min-height: 100vh; padding: 2rem; }
804
+ .container { max-width: 1200px; margin: 0 auto; }
805
+ h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: #38bdf8; }
806
+ .subtitle { color: #64748b; margin-bottom: 2rem; }
807
+ .dashboard { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem; }
808
+ .card { background: #1e293b; border-radius: 12px; padding: 1.5rem; border: 1px solid #334155; }
809
+ .card h2 { font-size: 1rem; color: #94a3b8; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }
810
+ .score-card { display: flex; flex-direction: column; align-items: center; justify-content: center; }
811
+ .score-value { font-size: 3rem; font-weight: bold; color: ${scoreColor}; }
812
+ .score-label { font-size: 0.9rem; color: ${scoreColor}; font-weight: bold; margin-top: 0.5rem; }
813
+ .sev-summary { display: flex; gap: 1rem; flex-wrap: wrap; justify-content: center; }
814
+ .sev-item { text-align: center; padding: 0.5rem 1rem; border-radius: 8px; background: #0f172a; min-width: 80px; }
815
+ .sev-item .count { font-size: 1.5rem; font-weight: bold; }
816
+ .sev-item .label { font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; }
817
+ .gauge-svg { width: 120px; height: 70px; }
818
+ .cat-bar-row { display: flex; align-items: center; margin-bottom: 0.5rem; }
819
+ .cat-label { width: 140px; font-size: 0.8rem; color: #94a3b8; text-align: right; padding-right: 0.75rem; flex-shrink: 0; }
820
+ .cat-bar { background: #38bdf8; color: #0f172a; font-size: 0.75rem; font-weight: bold; padding: 4px 8px; border-radius: 4px; min-width: 30px; text-align: center; }
821
+ table { width: 100%; border-collapse: collapse; }
822
+ th { text-align: left; padding: 0.75rem; border-bottom: 2px solid #334155; color: #94a3b8; font-size: 0.8rem; text-transform: uppercase; cursor: pointer; user-select: none; }
823
+ th:hover { color: #38bdf8; }
824
+ td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #1e293b; font-size: 0.85rem; }
825
+ tr:hover { background: #1e293b; }
826
+ .sev-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: bold; color: white; }
827
+ .conf-badge { padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; }
828
+ .conf-definite { background: #dc2626; color: white; }
829
+ .conf-likely { background: #eab308; color: black; }
830
+ .conf-suggestion { background: #475569; color: white; }
831
+ .fix-yes { color: #22c55e; font-weight: bold; }
832
+ .meta { color: #64748b; font-size: 0.8rem; margin-top: 2rem; text-align: center; }
833
+ @media (max-width: 768px) { .dashboard { grid-template-columns: 1fr; } }
834
+ </style>
835
+ </head>
836
+ <body>
837
+ <div class="container">
838
+ <h1>Doorman Security Report</h1>
839
+ <p class="subtitle">Stack: ${escapeHtml(stackParts.join(' + '))} | ${metadata.filesScanned || 0} files scanned in ${metadata.scanDurationSeconds || 0}s | ${new Date().toISOString().slice(0, 10)}</p>
840
+
841
+ <div class="dashboard">
842
+ <div class="card score-card">
843
+ <h2>Security Score</h2>
844
+ <svg class="gauge-svg" viewBox="0 0 100 55">
845
+ <path d="M 10 50 A 40 40 0 0 1 90 50" fill="none" stroke="#334155" stroke-width="6" stroke-linecap="round"/>
846
+ <path d="M 10 50 A 40 40 0 ${largeArc} 1 ${gaugeX.toFixed(1)} ${gaugeY.toFixed(1)}" fill="none" stroke="${scoreColor}" stroke-width="6" stroke-linecap="round"/>
847
+ </svg>
848
+ <div class="score-value">${score}</div>
849
+ <div class="score-label">${scoreLabel}</div>
850
+ </div>
851
+
852
+ <div class="card">
853
+ <h2>Severity Breakdown</h2>
854
+ <div class="sev-summary">
855
+ <div class="sev-item"><div class="count" style="color:${sevColors.critical}">${counts.critical}</div><div class="label">Critical</div></div>
856
+ <div class="sev-item"><div class="count" style="color:${sevColors.high}">${counts.high}</div><div class="label">High</div></div>
857
+ <div class="sev-item"><div class="count" style="color:${sevColors.medium}">${counts.medium}</div><div class="label">Medium</div></div>
858
+ <div class="sev-item"><div class="count" style="color:${sevColors.low}">${counts.low}</div><div class="label">Low</div></div>
859
+ <div class="sev-item"><div class="count" style="color:${sevColors.info}">${counts.info}</div><div class="label">Info</div></div>
860
+ </div>
861
+ </div>
862
+
863
+ <div class="card">
864
+ <h2>Category Breakdown</h2>
865
+ ${categoryChartData || '<p style="color:#64748b">No findings</p>'}
866
+ </div>
867
+ </div>
868
+
869
+ <div class="card">
870
+ <h2>Findings (${findings.length})</h2>
871
+ <table id="findings-table">
872
+ <thead>
873
+ <tr>
874
+ <th data-col="0">#</th>
875
+ <th data-col="1">Severity</th>
876
+ <th data-col="2">Title</th>
877
+ <th data-col="3">Category</th>
878
+ <th data-col="4">File</th>
879
+ <th data-col="5">Confidence</th>
880
+ <th data-col="6">Fix</th>
881
+ </tr>
882
+ </thead>
883
+ <tbody>
884
+ ${findingsRows || '<tr><td colspan="7" style="text-align:center;color:#64748b">No findings</td></tr>'}
885
+ </tbody>
886
+ </table>
887
+ </div>
888
+
889
+ <p class="meta">Generated by Doorman v1.0.0</p>
890
+ </div>
891
+
892
+ <script>
893
+ // Simple table sorting
894
+ document.querySelectorAll('#findings-table th').forEach(th => {
895
+ th.addEventListener('click', () => {
896
+ const table = document.getElementById('findings-table');
897
+ const tbody = table.querySelector('tbody');
898
+ const rows = Array.from(tbody.querySelectorAll('tr'));
899
+ const col = parseInt(th.dataset.col);
900
+ const asc = th.dataset.asc !== 'true';
901
+ th.dataset.asc = asc;
902
+ rows.sort((a, b) => {
903
+ const at = (a.children[col] || {}).textContent || '';
904
+ const bt = (b.children[col] || {}).textContent || '';
905
+ return asc ? at.localeCompare(bt, undefined, {numeric: true}) : bt.localeCompare(at, undefined, {numeric: true});
906
+ });
907
+ rows.forEach(r => tbody.appendChild(r));
908
+ });
909
+ });
910
+ </script>
911
+ </body>
912
+ </html>`;
913
+ }
914
+
915
+ function escapeHtml(str) {
916
+ return str
917
+ .replace(/&/g, '&amp;')
918
+ .replace(/</g, '&lt;')
919
+ .replace(/>/g, '&gt;')
920
+ .replace(/"/g, '&quot;');
921
+ }
922
+
923
+ // --- One-line summary ---
924
+
925
+ /**
926
+ * Build a one-line summary string (without ANSI codes).
927
+ * @param {Array} findings
928
+ * @param {number} score
929
+ * @param {object} [opts] - { minScore }
930
+ * @returns {string}
931
+ */
932
+ export function formatSummaryLine(findings, score, opts = {}) {
933
+ const counts = countBySeverity(findings);
934
+ const minScore = opts.minScore != null ? parseInt(opts.minScore, 10) : null;
935
+
936
+ let line = `Doorman: ${score}/100 — ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low`;
937
+
938
+ if (minScore != null) {
939
+ if (score < minScore || counts.critical > 0 || counts.high > 0) {
940
+ line += ` \u2717 BLOCKED (min-score: ${minScore})`;
941
+ } else {
942
+ line += ` \u2713 PASSED (min-score: ${minScore})`;
943
+ }
944
+ }
945
+
946
+ return line;
947
+ }
948
+
949
+ /**
950
+ * Print the bold one-line summary to stdout.
951
+ */
952
+ export function printSummaryLine(findings, score, opts = {}) {
953
+ const line = formatSummaryLine(findings, score, opts);
954
+ console.log(chalk.bold(line));
955
+ }
956
+
957
+ // --- File writers ---
958
+
959
+ /**
960
+ * Write SARIF output to a file.
961
+ */
962
+ export function writeSARIF(filePath, findings, stack, score, metadata = {}) {
963
+ const sarif = generateSARIF(findings, stack, score, metadata);
964
+ writeFileSync(filePath, JSON.stringify(sarif, null, 2));
965
+ return sarif;
966
+ }
967
+
968
+ /**
969
+ * Write HTML report to a file.
970
+ */
971
+ export function writeHTML(filePath, findings, stack, score, metadata = {}) {
972
+ const html = generateHTML(findings, stack, score, metadata);
973
+ writeFileSync(filePath, html);
974
+ return html;
975
+ }