@activemind/scd 1.4.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 (79) hide show
  1. package/LICENSE.md +35 -0
  2. package/README.md +417 -0
  3. package/bin/scd.js +140 -0
  4. package/lib/audit-report.js +93 -0
  5. package/lib/audit-sync.js +172 -0
  6. package/lib/audit.js +356 -0
  7. package/lib/cli-helpers.js +108 -0
  8. package/lib/commands/accept.js +28 -0
  9. package/lib/commands/audit.js +17 -0
  10. package/lib/commands/configure.js +200 -0
  11. package/lib/commands/doctor.js +14 -0
  12. package/lib/commands/exceptions.js +19 -0
  13. package/lib/commands/export-findings.js +46 -0
  14. package/lib/commands/findings.js +306 -0
  15. package/lib/commands/ignore.js +28 -0
  16. package/lib/commands/init.js +16 -0
  17. package/lib/commands/insights.js +24 -0
  18. package/lib/commands/install.js +15 -0
  19. package/lib/commands/list.js +109 -0
  20. package/lib/commands/remove.js +16 -0
  21. package/lib/commands/repo.js +862 -0
  22. package/lib/commands/report.js +234 -0
  23. package/lib/commands/resolve.js +25 -0
  24. package/lib/commands/rules.js +185 -0
  25. package/lib/commands/scan.js +519 -0
  26. package/lib/commands/scope.js +341 -0
  27. package/lib/commands/sync.js +40 -0
  28. package/lib/commands/uninstall.js +15 -0
  29. package/lib/commands/version.js +33 -0
  30. package/lib/comment-map.js +388 -0
  31. package/lib/config.js +325 -0
  32. package/lib/context-modifiers.js +211 -0
  33. package/lib/deep-analyzer.js +225 -0
  34. package/lib/doctor.js +236 -0
  35. package/lib/exception-manager.js +675 -0
  36. package/lib/export-findings.js +376 -0
  37. package/lib/file-context.js +380 -0
  38. package/lib/file-filter.js +204 -0
  39. package/lib/file-manifest.js +145 -0
  40. package/lib/git-utils.js +102 -0
  41. package/lib/global-config.js +239 -0
  42. package/lib/hooks-manager.js +130 -0
  43. package/lib/init-repo.js +147 -0
  44. package/lib/insights-analyzer.js +416 -0
  45. package/lib/insights-output.js +160 -0
  46. package/lib/installer.js +128 -0
  47. package/lib/output-constants.js +32 -0
  48. package/lib/output-terminal.js +407 -0
  49. package/lib/push-queue.js +322 -0
  50. package/lib/remove-repo.js +108 -0
  51. package/lib/repo-context.js +187 -0
  52. package/lib/report-html.js +1154 -0
  53. package/lib/report-index.js +157 -0
  54. package/lib/report-json.js +136 -0
  55. package/lib/report-markdown.js +250 -0
  56. package/lib/resolve-manager.js +148 -0
  57. package/lib/rule-registry.js +205 -0
  58. package/lib/scan-cache.js +171 -0
  59. package/lib/scan-context.js +312 -0
  60. package/lib/scan-schema.js +67 -0
  61. package/lib/scanner-full.js +681 -0
  62. package/lib/scanner-manual.js +348 -0
  63. package/lib/scanner-secrets.js +83 -0
  64. package/lib/scope.js +331 -0
  65. package/lib/store-verify.js +395 -0
  66. package/lib/store.js +310 -0
  67. package/lib/taint-register.js +196 -0
  68. package/lib/version-check.js +46 -0
  69. package/package.json +37 -0
  70. package/rules/rule-loader.js +324 -0
  71. package/rules/rules-aspx-cs.json +399 -0
  72. package/rules/rules-aspx.json +222 -0
  73. package/rules/rules-infra-leakage.json +434 -0
  74. package/rules/rules-js.json +664 -0
  75. package/rules/rules-php.json +521 -0
  76. package/rules/rules-python.json +466 -0
  77. package/rules/rules-secrets.json +99 -0
  78. package/rules/rules-sensitive-files.json +475 -0
  79. package/rules/rules-ts.json +76 -0
@@ -0,0 +1,407 @@
1
+ const { RESET, BOLD, DIM, RED, GREEN, YELLOW, BLUE, CYAN } = require('./output-constants');
2
+ /**
3
+ * output-terminal.js
4
+ * Two-part terminal output:
5
+ * 1. Summary + file-grouped overview ← visible without scrolling
6
+ * 2. Full details per finding ← scroll down for explanations
7
+ */
8
+
9
+ const SEVERITY_CONFIG = {
10
+ CRITICAL: { color: RED, icon: '🔴', label: 'CRITICAL' },
11
+ HIGH: { color: YELLOW, icon: '🟠', label: 'HIGH ' },
12
+ MEDIUM: { color: YELLOW, icon: '🟡', label: 'MEDIUM ' },
13
+ EXPOSURE: { color: BLUE, icon: '🔷', label: 'EXPOSURE' },
14
+ INFO: { color: CYAN, icon: '🔵', label: 'INFO ' },
15
+ };
16
+
17
+ const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, EXPOSURE: 3, INFO: 4 };
18
+
19
+ // ── OSC 8 hyperlink support detection ────────────────────────────────────────
20
+ // Only emit hyperlink sequences in terminals known to support OSC 8.
21
+ // Unsupported terminals (Terminal.app, basic xterm etc.) receive plain text.
22
+ const OSC8_SUPPORTED = (() => {
23
+ const prog = process.env.TERM_PROGRAM ?? '';
24
+ const emul = process.env.TERM_EMULATOR ?? '';
25
+ const vte = process.env.VTE_VERSION ?? '';
26
+ const color = process.env.COLORTERM ?? '';
27
+ return (
28
+ prog === 'iTerm.app' || // iTerm2
29
+ prog === 'vscode' || // VS Code integrated terminal
30
+ prog === 'WarpTerminal' || // Warp
31
+ prog === 'ghostty' || // Ghostty
32
+ emul === 'JetBrains-JediTerm' || // JetBrains IDEs (IntelliJ, WebStorm, …)
33
+ (color === 'truecolor' && vte !== '') // VTE-based: GNOME Terminal, Tilix, …
34
+ );
35
+ })();
36
+
37
+ function fileLink(relativePath, lineNum = null, displayText = null) {
38
+ const display = displayText ?? (lineNum ? `${relativePath}:${lineNum}` : relativePath);
39
+
40
+ // Plain text fallback for terminals that don't support OSC 8
41
+ if (!OSC8_SUPPORTED) return display;
42
+
43
+ const abs = require('path').resolve(process.cwd(), relativePath);
44
+ // file:// URI never includes line number – macOS can't open 'file.js:12'
45
+ const uri = `file://${abs}`;
46
+ const ESC = '\x1b';
47
+ return `${ESC}]8;;${uri}${ESC}\\${display}${ESC}]8;;${ESC}\\`;
48
+ }
49
+
50
+ function formatTerminal(findings, hookType, config = null, meta = {}) {
51
+ if (findings.length === 0) {
52
+ return {
53
+ output: `\n${GREEN}${BOLD} ✅ No security issues found.${RESET}\n`,
54
+ exitCode: 0,
55
+ };
56
+ }
57
+
58
+ const verbose = config?.verbose || meta.verbose || false;
59
+
60
+ // ── Deduplicate & sort ────────────────────────────────────────────────────
61
+ const seen = new Set();
62
+ const unique = findings.filter(f => {
63
+ const key = `${f.ruleId}:${f.filePath}:${f.line}`;
64
+ if (seen.has(key)) return false;
65
+ seen.add(key);
66
+ return true;
67
+ });
68
+
69
+ unique.sort((a, b) => {
70
+ if (a.excepted !== b.excepted) return a.excepted ? 1 : -1;
71
+ return (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9);
72
+ });
73
+
74
+ const active = unique.filter(f => !f.excepted && !f.resolved && f.severity !== 'EXPOSURE');
75
+ const exposures = unique.filter(f => !f.excepted && !f.resolved && f.severity === 'EXPOSURE');
76
+ const excepted = unique.filter(f => f.excepted);
77
+ const rejected = unique.filter(f => f.exception_rejected);
78
+
79
+ let shouldBlock = active.some(f => f.blocks) ||
80
+ excepted.some(f => f.exception_expired);
81
+
82
+ const lines = [];
83
+
84
+ // ── Count per severity ───────────────────────────────────────────────────
85
+ const counts = {};
86
+ for (const f of [...active, ...exposures]) {
87
+ counts[f.severity] = (counts[f.severity] ?? 0) + 1;
88
+ }
89
+
90
+ // Count low-confidence findings per severity for annotation
91
+ const lowConfCounts = {};
92
+ for (const f of [...active, ...exposures]) {
93
+ if (f.confidence === 'LOW') {
94
+ lowConfCounts[f.severity] = (lowConfCounts[f.severity] ?? 0) + 1;
95
+ }
96
+ }
97
+
98
+ const summaryParts = Object.entries(counts)
99
+ .sort((a, b) => (SEV_ORDER[a[0]] ?? 9) - (SEV_ORDER[b[0]] ?? 9))
100
+ .map(([sev, n]) => {
101
+ const cfg = SEVERITY_CONFIG[sev];
102
+ const color = sev === 'CRITICAL' ? RED : sev === 'EXPOSURE' ? BLUE : YELLOW;
103
+ const lowN = lowConfCounts[sev] ?? 0;
104
+ const note = lowN > 0 ? `${DIM} (${lowN} low confidence)${RESET}` : '';
105
+ return `${color}${BOLD}${cfg.icon} ${n} ${sev.trim()}${RESET}${note}`;
106
+ });
107
+
108
+ if (excepted.length > 0) {
109
+ summaryParts.push(`${DIM}✓ ${excepted.length} excepted${RESET}`);
110
+ }
111
+ if (rejected.length > 0) {
112
+ summaryParts.push(`${YELLOW}⛔ ${rejected.length} rejected${RESET}`);
113
+ }
114
+
115
+ const skipped = meta.skipped ?? [];
116
+ const timedOut = meta.timedOut ?? [];
117
+ const scopeExclusions = meta.scopeExclusions ?? null;
118
+
119
+ const modeLabel = hookType === 'manual' ? 'Manual scan'
120
+ : hookType === 'pre-commit' ? 'Pre-commit (secrets)'
121
+ : 'Pre-push (OWASP)';
122
+
123
+ const scanMode = ` ${DIM}· scan: ${config?.scan_mode || 'full'}${RESET}`;
124
+ const trustLevel = ` ${DIM}· trust: ${config?.trust_level || 'balanced'}${RESET}`;
125
+
126
+ const skippedTotal = skipped.length + timedOut.length;
127
+ const skippedNote = skippedTotal > 0 ? ` ${YELLOW}· ${skippedTotal} file(s) skipped/timeout${RESET}` : '';
128
+ const taintCount = unique.filter(f => f.taintSource).length;
129
+ const taintNote = taintCount > 0 ? ` ${DIM}· ${taintCount} taint-tracked${RESET}` : '';
130
+
131
+ lines.push('');
132
+ lines.push(`${BOLD}─── Summary ${'─'.repeat(60)}${RESET}`);
133
+ lines.push(` ${summaryParts.join(' ')}`);
134
+ lines.push(` ${DIM}${modeLabel} · ${unique.length} findings total${taintNote}${skippedNote}${scanMode}${trustLevel}${RESET}`);
135
+ lines.push(`${BOLD}${'─'.repeat(72)}${RESET}`);
136
+
137
+ lines.push('');
138
+
139
+ // ── Skipped / timed-out files ────────────────────────────────────────────
140
+ if (skipped.length > 0 || timedOut.length > 0) {
141
+ for (const s of skipped) {
142
+ if (s.reason === 'too_large') {
143
+ lines.push(` ${DIM}⊘ ${fileLink(s.filePath)} ${s.sizeKb} KB – skipped (too large)${RESET}`);
144
+ } else {
145
+ lines.push(` ${DIM}⊘ ${fileLink(s.filePath)} ${s.error}${RESET}`);
146
+ }
147
+ }
148
+ for (const t of timedOut) {
149
+ lines.push(` ${YELLOW}⏱ ${fileLink(t.filePath)} ${t.sizeKb} KB – timeout${RESET}`);
150
+ }
151
+ lines.push('');
152
+ }
153
+
154
+ // ── Active scope exclusions ─────────────────────────────────────────────
155
+ if (scopeExclusions) {
156
+ const fileExcludes = scopeExclusions.file_excludes || [];
157
+ const ruleExcludes = scopeExclusions.rule_excludes || [];
158
+ if (fileExcludes.length > 0 || ruleExcludes.length > 0) {
159
+ lines.push(`${YELLOW}${BOLD} ⚠ Active scope exclusions:${RESET}`);
160
+ for (const e of fileExcludes) {
161
+ const reason = e.reason ? ` — "${e.reason}"` : '';
162
+ const meta_ = [e.added_by, e.added_at].filter(Boolean).join(', ');
163
+ const src = e._source ? ` [${e._source}]` : '';
164
+ lines.push(` ${YELLOW}Files : ${e.pattern}${reason} (${meta_})${src}${RESET}`);
165
+ }
166
+ for (const e of ruleExcludes) {
167
+ const scope_ = e.files && e.files.length ? e.files.join(', ') : 'all files';
168
+ const reason = e.reason ? ` — "${e.reason}"` : '';
169
+ const meta_ = [e.added_by, e.added_at].filter(Boolean).join(', ');
170
+ const src = e._source ? ` [${e._source}]` : '';
171
+ const excl = e.findings_excluded > 0 ? `, ${e.findings_excluded} finding(s) excluded` : '';
172
+ lines.push(` ${YELLOW}Rules : ${e.rule} (${scope_})${reason} (${meta_}${excl})${src}${RESET}`);
173
+ }
174
+ lines.push('');
175
+ }
176
+ }
177
+
178
+ if (verbose) {
179
+ // ════════════════════════════════════════════════════════════════════════
180
+ // VERBOSE MODE – full file-grouped list + rule details (original format)
181
+ // ════════════════════════════════════════════════════════════════════════
182
+
183
+ const byFile = {};
184
+ for (const f of [...active, ...exposures]) {
185
+ if (!byFile[f.filePath]) byFile[f.filePath] = [];
186
+ byFile[f.filePath].push(f);
187
+ }
188
+
189
+ if (Object.keys(byFile).length > 0) {
190
+ lines.push(`${BOLD}Findings per fil:${RESET}`);
191
+ lines.push('');
192
+ for (const [filePath, filefindings] of Object.entries(byFile)) {
193
+ lines.push(` ${BOLD}${fileLink(filePath)}${RESET}`);
194
+ for (const f of filefindings) {
195
+ const cfg = SEVERITY_CONFIG[f.severity] ?? SEVERITY_CONFIG.INFO;
196
+ const color = f.severity === 'CRITICAL' ? RED : f.severity === 'EXPOSURE' ? BLUE : YELLOW;
197
+ const lineRef = f.line ? `${DIM}${fileLink(f.filePath, f.line, 'rad ' + f.line)}${RESET}` : '';
198
+ const isWarn = f.action === 'warn';
199
+ const rejected = f.exception_rejected ? ` ${YELLOW}⛔ rejected – fix required${RESET}` : '';
200
+ const fid = f.findingId ? ` ${DIM}${f.findingId}${RESET}` : '';
201
+ lines.push(
202
+ ` ${color}${cfg.icon}${RESET} ${color}${f.name}${RESET}` +
203
+ `${isWarn ? ` ${DIM}(warning)${RESET}` : ''}` +
204
+ ` ${lineRef} ${DIM}[${f.ruleId}]${RESET}${fid}${rejected}`
205
+ );
206
+ }
207
+ lines.push('');
208
+ }
209
+ }
210
+
211
+ if (excepted.length > 0) {
212
+ lines.push(` ${DIM}Excepted findings:${RESET}`);
213
+ for (const f of excepted) {
214
+ const expired = f.exception_expired;
215
+ const icon = expired ? `${YELLOW}⚠️ ${RESET}` : `${DIM}✓${RESET} `;
216
+ lines.push(` ${icon} ${DIM}${f.ruleId} – ${f.name} (${fileLink(f.filePath, f.line)})${RESET}` +
217
+ (expired ? ` ${YELLOW}– exception expired!${RESET}` : ''));
218
+ }
219
+ lines.push('');
220
+ }
221
+
222
+ // Rule details with explanation
223
+ const allDetailed = [...active, ...exposures];
224
+ if (allDetailed.length > 0) {
225
+ lines.push('');
226
+ lines.push(`${DIM}${'─'.repeat(60)}${RESET}`);
227
+ lines.push(`${DIM} ↓ Details grouped by rule ↓${RESET}`);
228
+ lines.push(`${DIM}${'─'.repeat(60)}${RESET}`);
229
+
230
+ const byRule = {};
231
+ for (const f of allDetailed) {
232
+ if (!byRule[f.ruleId]) byRule[f.ruleId] = [];
233
+ byRule[f.ruleId].push(f);
234
+ }
235
+
236
+ const sortedRules = Object.entries(byRule).sort(([, a], [, b]) =>
237
+ (SEV_ORDER[a[0].severity] ?? 9) - (SEV_ORDER[b[0].severity] ?? 9)
238
+ );
239
+
240
+ for (const [ruleId, ruleFindings] of sortedRules) {
241
+ const rep = ruleFindings[0];
242
+ const color = rep.severity === 'CRITICAL' ? RED : rep.severity === 'EXPOSURE' ? BLUE : YELLOW;
243
+ const isWarn = rep.action === 'warn';
244
+ const count = ruleFindings.length;
245
+ lines.push('');
246
+ lines.push(
247
+ `${color}${BOLD}${SEVERITY_CONFIG[rep.severity]?.icon ?? '🔵'} ${rep.name}${RESET}` +
248
+ `${isWarn ? ` ${DIM}(warning – does not block)${RESET}` : ''}` +
249
+ `${count > 1 ? ` ${DIM}· ${count} occurrences${RESET}` : ''}` +
250
+ ` ${DIM}[${ruleId}]${RESET}`
251
+ );
252
+ for (const f of ruleFindings) {
253
+ const snippet = f.snippet ? ` ${DIM}→ ${f.snippet.trim().slice(0, 60)}${f.snippet.trim().length > 60 ? '…' : ''}${RESET}` : '';
254
+ // Show confidence badge for LOW/MEDIUM findings
255
+ const confBadge = f.confidence === 'LOW' ? ` ${DIM}[low confidence]${RESET}`
256
+ : f.confidence === 'MEDIUM' ? ` ${DIM}[medium confidence]${RESET}`
257
+ : '';
258
+ lines.push(` ${DIM}${fileLink(f.filePath, f.line)}${RESET}${snippet}${confBadge}`);
259
+ if (f.taintSource) {
260
+ lines.push(` ${DIM} ↳ \$${f.taintSource.variable} assigned from ${f.taintSource.source} on line ${f.taintSource.line}${RESET}`);
261
+ }
262
+ if (f.exception_rejected) {
263
+ lines.push(` ${YELLOW} ⛔ exception rejected – fix required${RESET}`);
264
+ }
265
+ }
266
+ if (rep.why || rep.scenario || rep.fix) {
267
+ lines.push('');
268
+ if (rep.why) lines.push(` ${BOLD}Problem:${RESET} ${rep.why}`);
269
+ if (rep.scenario) lines.push(` ${BOLD}Scenario:${RESET} ${CYAN}${rep.scenario}${RESET}`);
270
+ if (rep.fix) lines.push(` ${BOLD}Fix:${RESET} ${GREEN}${rep.fix}${RESET}`);
271
+ }
272
+ if (rep.severity === 'EXPOSURE' && rep.checklist) {
273
+ lines.push('');
274
+ lines.push(` ${BOLD}Verifiera:${RESET}`);
275
+ rep.checklist.forEach(item => lines.push(` ${YELLOW}☐${RESET} ${item}`));
276
+ for (const f of ruleFindings) {
277
+ lines.push(` ${DIM}→ scd ignore ${f.findingId || f.ruleId} --reason "..." or scd accept ${f.findingId || f.ruleId} --reason "..."${RESET}`);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ } else {
284
+ // ════════════════════════════════════════════════════════════════════════
285
+ // COMPACT MODE (default) – top issues + most affected files
286
+ // ════════════════════════════════════════════════════════════════════════
287
+
288
+ // ── Top issues by rule ──────────────────────────────────────────────────
289
+ const byRule = {};
290
+ for (const f of [...active, ...exposures]) {
291
+ if (!byRule[f.ruleId]) byRule[f.ruleId] = { rep: f, count: 0 };
292
+ byRule[f.ruleId].count++;
293
+ }
294
+
295
+ const sortedRules = Object.values(byRule)
296
+ .sort((a, b) => {
297
+ const sevDiff = (SEV_ORDER[a.rep.severity] ?? 9) - (SEV_ORDER[b.rep.severity] ?? 9);
298
+ return sevDiff !== 0 ? sevDiff : b.count - a.count;
299
+ });
300
+
301
+ const TOP_N = 8;
302
+ lines.push(`${BOLD}Top issues:${RESET}`);
303
+ for (const { rep, count } of sortedRules.slice(0, TOP_N)) {
304
+ const color = rep.severity === 'CRITICAL' ? RED : rep.severity === 'EXPOSURE' ? BLUE : YELLOW;
305
+ const cfg = SEVERITY_CONFIG[rep.severity] ?? SEVERITY_CONFIG.INFO;
306
+ const isWarn = rep.action === 'warn';
307
+ const cnt = String(count).padStart(3);
308
+ lines.push(
309
+ ` ${color}${cfg.icon}${RESET} ${color}${cnt}${RESET} ${DIM}${rep.ruleId.padEnd(20)}${RESET} ${rep.name}` +
310
+ `${isWarn ? ` ${DIM}(warning)${RESET}` : ''}`
311
+ );
312
+ }
313
+ if (sortedRules.length > TOP_N) {
314
+ lines.push(` ${DIM}+ ${sortedRules.length - TOP_N} more rule(s)${RESET}`);
315
+ }
316
+ lines.push('');
317
+
318
+ // ── Most affected files ─────────────────────────────────────────────────
319
+ const byFile = {};
320
+ for (const f of [...active, ...exposures]) {
321
+ if (!byFile[f.filePath]) byFile[f.filePath] = { count: 0, maxSev: 9, lines: [] };
322
+ byFile[f.filePath].count++;
323
+ if (f.line) byFile[f.filePath].lines.push(f.line);
324
+ const sev = SEV_ORDER[f.severity] ?? 9;
325
+ if (sev < byFile[f.filePath].maxSev) byFile[f.filePath].maxSev = sev;
326
+ }
327
+
328
+ const sortedFiles = Object.entries(byFile)
329
+ .sort(([, a], [, b]) => a.maxSev !== b.maxSev ? a.maxSev - b.maxSev : b.count - a.count);
330
+
331
+ const TOP_FILES = 5;
332
+ if (sortedFiles.length > 0) {
333
+ lines.push(`${BOLD}Most affected files:${RESET}`);
334
+ for (const [filePath, info] of sortedFiles.slice(0, TOP_FILES)) {
335
+ const sevLabels = { 0: RED + '🔴', 1: YELLOW + '🟠', 2: YELLOW + '🟡', 3: BLUE + '🔷' };
336
+ const icon = (sevLabels[info.maxSev] ?? DIM + '🔵') + RESET;
337
+ const cnt = String(info.count).padStart(3);
338
+ const name = filePath.length > 45 ? '…' + filePath.slice(-44) : filePath;
339
+ // Show up to 8 line numbers, sorted, comma-separated
340
+ const lineNums = [...new Set(info.lines)].sort((a, b) => a - b);
341
+ const lineStr = lineNums.length > 0
342
+ ? ` ${DIM}(Lines: ${lineNums.slice(0, 8).join(', ')}${lineNums.length > 8 ? ', …' : ''})${RESET}`
343
+ : '';
344
+ lines.push(` ${icon} ${DIM}${cnt}${RESET} ${fileLink(filePath, null, name)}${lineStr}`);
345
+ }
346
+ if (sortedFiles.length > TOP_FILES) {
347
+ lines.push(` ${DIM}+ ${sortedFiles.length - TOP_FILES} more file(s)${RESET}`);
348
+ }
349
+ lines.push('');
350
+ }
351
+
352
+ // ── Excepted summary ────────────────────────────────────────────────────
353
+ if (excepted.length > 0) {
354
+ const expired = excepted.filter(f => f.exception_expired);
355
+ lines.push(` ${DIM}✓ ${excepted.length} finding(s) excepted${RESET}` +
356
+ (expired.length > 0 ? ` ${YELLOW}⚠️ ${expired.length} exception(s) expired — re-approval needed${RESET}` : ''));
357
+ lines.push('');
358
+ }
359
+
360
+ // ── Rejected exceptions ─────────────────────────────────────────────────
361
+ if (rejected.length > 0) {
362
+ lines.push(` ${YELLOW}⛔ ${rejected.length} rejected exception(s) — fix required:${RESET}`);
363
+ for (const f of rejected) {
364
+ const excId = f.exception?.id ? ` ${DIM}[${f.exception.id}]${RESET}` : '';
365
+ lines.push(` ${DIM} ${f.ruleId} ${f.filePath}${f.line ? ':' + f.line : ''}${RESET}${excId}`);
366
+ }
367
+ lines.push(` ${DIM} Run scd exceptions --list rejected to see details${RESET}`);
368
+ lines.push('');
369
+ }
370
+
371
+ // ── Next steps ──────────────────────────────────────────────────────────
372
+ lines.push(`${DIM}${'─'.repeat(62)}${RESET}`);
373
+ lines.push(`${DIM} Full details:${RESET} ${BOLD}scd report --open${RESET} ${DIM}or${RESET} ${BOLD}scd report --serve${RESET} ${DIM}(Linux/Firefox)${RESET}`);
374
+ lines.push(`${DIM} All findings: ${RESET} ${BOLD}scd scan --verbose${RESET} ${DIM}or${RESET} ${BOLD}scd export-findings${RESET}`);
375
+ }
376
+
377
+ lines.push('');
378
+
379
+ // ── Block/pass status ─────────────────────────────────────────────────────
380
+ if (shouldBlock) {
381
+ if (hookType === 'manual') {
382
+ lines.push(`${RED}${BOLD} ⚠️ Critical vulnerabilities found – fix before pushing.${RESET}`);
383
+ lines.push(`${DIM} Manual scan does not block. Git push will block on CRITICAL/HIGH.${RESET}`);
384
+ } else if (hookType === 'pre-commit') {
385
+ lines.push(`${RED}${BOLD} 🚫 Commit blocked – secrets must never reach git history.${RESET}`);
386
+ lines.push(`${DIM} Fix the above and try again.${RESET}`);
387
+ } else {
388
+ lines.push(`${RED}${BOLD} 🚫 Push blocked – critical vulnerabilities must be fixed.${RESET}`);
389
+ lines.push(`${DIM} Run git push again after fixing the above.${RESET}`);
390
+ }
391
+ } else {
392
+ if (hookType === 'manual') {
393
+ lines.push(`${GREEN}${BOLD} ✅ No blocking vulnerabilities.${RESET} ${DIM}Review HIGH/EXPOSURE findings above.${RESET}`);
394
+ } else {
395
+ lines.push(`${GREEN}${BOLD} ✅ Push allowed${RESET} ${DIM}– review HIGH findings above.${RESET}`);
396
+ }
397
+ }
398
+
399
+ lines.push('');
400
+
401
+ return {
402
+ output: lines.join('\n'),
403
+ exitCode: shouldBlock ? 1 : 0,
404
+ };
405
+ }
406
+
407
+ module.exports = { formatTerminal };