@maverick006/vibeguard 1.0.10 → 1.0.12

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/src/formatter.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { execSync } from 'child_process';
3
- import { NormalizedFinding, Severity } from '@maverick006/types';
3
+ import { NormalizedFinding, Severity, ScannerCoverage, ScannerState, DeterministicScore } from '@maverick006/types';
4
4
  import path from 'path';
5
5
 
6
6
  export interface ScanStats {
@@ -8,11 +8,59 @@ export interface ScanStats {
8
8
  high: number;
9
9
  medium: number;
10
10
  low: number;
11
+ info: number;
11
12
  total: number;
12
13
  score: number;
14
+ grade: 'A' | 'B' | 'C' | 'D' | 'F';
13
15
  riskLevel: 'LOW RISK' | 'MEDIUM RISK' | 'HIGH RISK' | 'CRITICAL RISK';
14
16
  }
15
17
 
18
+ export interface AIRemediationData {
19
+ findingId: string;
20
+ severity?: string;
21
+ issue: string;
22
+ impact?: string;
23
+ recommendation?: string;
24
+ suggestedFix?: string;
25
+ hasConcretePatch: boolean;
26
+ confidence?: number;
27
+ status: 'AWAITING REVIEW' | 'GUIDANCE ONLY' | 'VERIFIED' | 'NOT_VERIFIED' | 'UNAVAILABLE';
28
+ unavailableReason?: string;
29
+ }
30
+
31
+ export interface ScannerTelemetry {
32
+ scanner: string;
33
+ state: ScannerState | string;
34
+ durationMs?: number;
35
+ findingsCount?: number;
36
+ reason?: string;
37
+ }
38
+
39
+ export interface RenderOptions {
40
+ findings: NormalizedFinding[];
41
+ stats: ScanStats;
42
+ deterministicScore?: DeterministicScore;
43
+ coverage: ScannerCoverage;
44
+ gitInfo: ReturnType<typeof getGitInfo>;
45
+ duration: string;
46
+ scanners: ScannerTelemetry[];
47
+ remediation?: AIRemediationData;
48
+ syncStatus?: 'SYNCED' | 'SKIPPED' | 'FAILED';
49
+ policyThreshold?: string;
50
+ verbose?: boolean;
51
+ }
52
+
53
+ export function maskSecrets(input: string): string {
54
+ if (!input) return input;
55
+ let masked = input;
56
+ masked = masked.replace(/\b(AKIA[0-9A-Z]{16})\b/g, 'AKIA****************');
57
+ masked = masked.replace(/\b(gh[pousr]_[A-Za-z0-9_]{36,255})\b/g, 'ghp_************************************');
58
+ masked = masked.replace(/(bearer\s+)([a-zA-Z0-9_\-\.]{15,})/gi, '$1[REDACTED]');
59
+ masked = masked.replace(/(password|secret|api[_-]?key)\s*[:=]\s*['"]?([a-zA-Z0-9_\-\.]{8,})['"]?/gi, '$1: [REDACTED]');
60
+ masked = masked.replace(/-----BEGIN[ A-Z0-9_-]+PRIVATE KEY-----[\s\S]*?-----END[ A-Z0-9_-]+PRIVATE KEY-----/g, '[REDACTED_PRIVATE_KEY]');
61
+ return masked;
62
+ }
63
+
16
64
  function stripAnsi(str: string): string {
17
65
  return str.replace(/\u001b\[[0-9;]*m/g, '');
18
66
  }
@@ -22,8 +70,7 @@ function visibleWidth(str: string): number {
22
70
  let width = 0;
23
71
  for (const char of plain) {
24
72
  const code = char.codePointAt(0) || 0;
25
- if (code === 0xFE0F || code === 0xFE0E) continue; // ignore variation selectors
26
- // Emojis and certain symbols take 2 terminal columns
73
+ if (code === 0xFE0F || code === 0xFE0E) continue;
27
74
  if (code > 0x1F000 || (code >= 0x2600 && code <= 0x27BF)) {
28
75
  width += 2;
29
76
  } else {
@@ -40,9 +87,10 @@ function padVisible(str: string, targetWidth: number): string {
40
87
  }
41
88
 
42
89
  export function getGitInfo(cwd: string = process.cwd()) {
43
- let name = path.basename(cwd);
90
+ const resolvedCwd = path.resolve(cwd);
91
+ let name = path.basename(resolvedCwd);
44
92
  let branch = 'main';
45
- let commit = '4f2c1ab';
93
+ let commit = 'HEAD';
46
94
  let remoteUrl = '';
47
95
 
48
96
  try {
@@ -64,7 +112,7 @@ export function getGitInfo(cwd: string = process.cwd()) {
64
112
  }
65
113
  } catch {}
66
114
 
67
- return { name, branch, commit, remoteUrl, policy: 'enterprise' };
115
+ return { name, branch, commit, remoteUrl };
68
116
  }
69
117
 
70
118
  export function calculateScore(findings: NormalizedFinding[]): ScanStats {
@@ -72,50 +120,62 @@ export function calculateScore(findings: NormalizedFinding[]): ScanStats {
72
120
  let high = 0;
73
121
  let medium = 0;
74
122
  let low = 0;
123
+ let info = 0;
75
124
 
76
125
  for (const f of findings) {
77
126
  const sev = (f.severity || '').toUpperCase();
78
127
  if (sev === 'CRITICAL' || sev === Severity.CRITICAL) critical++;
79
128
  else if (sev === 'HIGH' || sev === Severity.HIGH) high++;
80
129
  else if (sev === 'MEDIUM' || sev === Severity.MEDIUM) medium++;
81
- else low++;
130
+ else if (sev === 'LOW' || sev === Severity.LOW) low++;
131
+ else info++;
82
132
  }
83
133
 
84
- // Calculate risk score: 100 is best, 0 is worst
85
- const deductions = (critical * 15) + (high * 7) + (medium * 3) + (low * 1);
86
- const score = Math.max(10, Math.min(100, 100 - deductions));
134
+ const deductions = (critical * 30) + (high * 10) + (medium * 3) + (low * 1);
135
+ let score = Math.max(0, Math.min(100, 100 - deductions));
136
+
137
+ let grade: ScanStats['grade'] = 'A';
138
+ if (critical > 0) {
139
+ grade = 'F';
140
+ score = Math.min(score, 49);
141
+ } else if (score >= 90) grade = 'A';
142
+ else if (score >= 80) grade = 'B';
143
+ else if (score >= 70) grade = 'C';
144
+ else if (score >= 50) grade = 'D';
145
+ else grade = 'F';
87
146
 
88
147
  let riskLevel: ScanStats['riskLevel'] = 'LOW RISK';
89
- if (score < 50 || critical > 0) riskLevel = 'CRITICAL RISK';
90
- else if (score < 75 || high > 0) riskLevel = 'HIGH RISK';
91
- else if (score < 90 || medium > 0) riskLevel = 'MEDIUM RISK';
148
+ if (grade === 'F') riskLevel = 'CRITICAL RISK';
149
+ else if (grade === 'D') riskLevel = 'HIGH RISK';
150
+ else if (grade === 'C' || grade === 'B') riskLevel = 'MEDIUM RISK';
92
151
 
93
152
  return {
94
153
  critical,
95
154
  high,
96
155
  medium,
97
156
  low,
157
+ info,
98
158
  total: findings.length,
99
159
  score,
160
+ grade,
100
161
  riskLevel
101
162
  };
102
163
  }
103
164
 
104
- export function renderDashboard(options: {
105
- findings: NormalizedFinding[];
106
- stats: ScanStats;
107
- gitInfo: ReturnType<typeof getGitInfo>;
108
- duration: string;
109
- remediation?: {
110
- findingId: string;
111
- issue: string;
112
- impact: string;
113
- recommendation: string;
114
- diffSnippet: string;
115
- confidence: number;
116
- };
117
- }) {
118
- const { findings, stats, gitInfo, duration, remediation } = options;
165
+ export function renderDashboard(options: RenderOptions) {
166
+ const {
167
+ findings,
168
+ stats,
169
+ deterministicScore,
170
+ coverage,
171
+ gitInfo,
172
+ duration,
173
+ scanners,
174
+ remediation,
175
+ syncStatus,
176
+ policyThreshold,
177
+ verbose
178
+ } = options;
119
179
 
120
180
  const cyan = chalk.hex('#00E5FF');
121
181
  const gray = chalk.hex('#94A3B8');
@@ -127,20 +187,20 @@ export function renderDashboard(options: {
127
187
  const yellow = chalk.hex('#F59E0B');
128
188
  const white = chalk.white;
129
189
 
130
- const now = new Date();
131
- const timeStr = now.toTimeString().split(' ')[0];
132
-
133
- // Shield ASCII Art
134
- const shield = [
135
- ' ,-----. ',
136
- ' / _ \\ ',
137
- ' | / \\ | ',
138
- ' | | ✓ | | ',
139
- ' \\ \\ / / ',
140
- ' `-------\' '
190
+ // Active domains count (out of 7)
191
+ const domainKeys: (keyof ScannerCoverage)[] = [
192
+ 'code',
193
+ 'dependencies',
194
+ 'secrets',
195
+ 'containers',
196
+ 'iac',
197
+ 'web',
198
+ 'cloud'
141
199
  ];
200
+ const activeDomainsCount = domainKeys.filter(k => coverage[k]).length;
201
+ const isPartial = activeDomainsCount < 7;
142
202
 
143
- // Title ASCII Art (VIBEGUARD)
203
+ // Title ASCII Art
144
204
  const title = [
145
205
  ' __ __ ___ ____ _____ ____ _ _ _ ____ ____ ',
146
206
  '\\ \\ / /|_ _| __ )| ____/ ___| | | | / \\ | _ \\| _ \\ ',
@@ -149,127 +209,370 @@ export function renderDashboard(options: {
149
209
  ' \\_/ |___|____/|_____|\\____|\\___/_/ \\_\\_| \\_\\____/'
150
210
  ];
151
211
 
152
- const scoreColor = stats.score >= 85 ? green : stats.score >= 65 ? yellow : red;
153
- const riskColor = stats.riskLevel === 'LOW RISK' ? green : stats.riskLevel === 'MEDIUM RISK' ? yellow : red;
154
-
155
212
  console.log('\n');
156
-
157
- // Print Top Row: Clock aligned right
158
- console.log(' '.repeat(65) + cyan.bold(timeStr));
159
-
160
- // 1. Header (Shield + VIBEGUARD)
161
- console.log(`${cyan(shield[0])} ${cyan.bold(title[0])}`);
162
- console.log(`${cyan(shield[1])} ${cyan.bold(title[1])}`);
163
- console.log(`${cyan(shield[2])} ${cyan.bold(title[2])}`);
164
- console.log(`${cyan(shield[3])} ${cyan.bold(title[3])}`);
165
- console.log(`${cyan(shield[4])} ${cyan.bold(title[4])}`);
166
- console.log(`${cyan(shield[5])} ${gray('AI-Powered DevSecOps Orchestrator')}`);
167
- console.log(` ${cyan('Scanning. Analyzing. Protecting.')}\n`);
168
-
169
- // 2. Risk Score Box (Placed cleanly BELOW VIBEGUARD Header)
170
- const boxWidth = 74;
171
- console.log(darkBorder(`┌${''.repeat(boxWidth)}┐`));
172
- console.log(`${darkBorder('│')} ${cyan.bold('OVERALL RISK SCORE')}${' '.repeat(boxWidth - 20)}${darkBorder('│')}`);
213
+
214
+ // 1. Header (VIBEGUARD - Cloud + Security Posture)
215
+ for (const line of title) {
216
+ console.log(cyan.bold(line));
217
+ }
218
+ console.log(`\n${white.bold('VIBEGUARD')} ${dimGray('│')} ${cyan('Cloud + Security Posture')}`);
219
+ console.log(gray('Scanning. Analyzing. Protecting.\n'));
220
+
221
+ // 2. Redesigned Security Posture Header (Honest Partial Posture Representation)
222
+ const boxWidth = 76;
223
+ console.log(darkBorder(`┌─ SECURITY POSTURE ${'─'.repeat(boxWidth - 21)}┐`));
224
+ console.log(`${darkBorder('│')}${' '.repeat(boxWidth)}${darkBorder('│')}`);
225
+
226
+ const scoreNum = deterministicScore?.score ?? stats.score;
227
+ const gradeLetter = deterministicScore?.grade ?? stats.grade;
228
+ const gradeColor = gradeLetter === 'A' ? green : gradeLetter === 'B' ? cyan : gradeLetter === 'C' ? yellow : red;
229
+
230
+ const scoreLine = ` ${gradeColor.bold(String(scoreNum))} ${gray('/ 100')} ${gradeColor.bold('Grade ' + gradeLetter)}`;
231
+ console.log(`${darkBorder('│')}${padVisible(scoreLine, boxWidth)}${darkBorder('│')}`);
232
+
233
+ const postureBadge = isPartial
234
+ ? yellow.bold('PARTIAL POSTURE') + dimGray(` (${activeDomainsCount} / 7 security domains assessed)`)
235
+ : green.bold('COMPLETE POSTURE') + dimGray(' (7 / 7 security domains assessed)');
236
+ const statusLine = ` ${postureBadge}`;
237
+ console.log(`${darkBorder('│')}${padVisible(statusLine, boxWidth)}${darkBorder('│')}`);
173
238
  console.log(`${darkBorder('│')}${' '.repeat(boxWidth)}${darkBorder('│')}`);
174
-
175
- const scoreLine1 = ` ${scoreColor.bold(String(stats.score))} ${gray('/100')} ${red('CRITICAL')} ${String(stats.critical).padEnd(2, ' ')} ${orange('HIGH')} ${String(stats.high).padEnd(2, ' ')}`;
176
- const scoreLine2 = ` ${riskColor.bold(stats.riskLevel.padEnd(13, ' '))} ${yellow('MEDIUM')} ${String(stats.medium).padEnd(2, ' ')} ${cyan('LOW')} ${String(stats.low).padEnd(2, ' ')}`;
177
239
 
178
- console.log(`${darkBorder('│')}${padVisible(scoreLine1, boxWidth)}${darkBorder('')}`);
179
- console.log(`${darkBorder('│')}${padVisible(scoreLine2, boxWidth)}${darkBorder('')}`);
240
+ const critStr = stats.critical > 0 ? red.bold(`${stats.critical} Critical`) : dimGray('0 Critical');
241
+ const highStr = stats.high > 0 ? orange.bold(`${stats.high} High`) : dimGray('0 High');
242
+ const medStr = stats.medium > 0 ? yellow.bold(`${stats.medium} Medium`) : dimGray('0 Medium');
243
+ const lowStr = stats.low > 0 ? cyan(`${stats.low} Low`) : dimGray('0 Low');
244
+ const findingsLine = ` ${critStr} ${highStr} ${medStr} ${lowStr} ${gray(`(${stats.total} total ${stats.total === 1 ? 'finding' : 'findings'})`)}`;
245
+ console.log(`${darkBorder('│')}${padVisible(findingsLine, boxWidth)}${darkBorder('│')}`);
246
+
247
+ const coverageLine = ` Coverage: ${cyan(`${activeDomainsCount} / 7`)} security domains`;
248
+ console.log(`${darkBorder('│')}${padVisible(coverageLine, boxWidth)}${darkBorder('│')}`);
180
249
  console.log(darkBorder(`└${'─'.repeat(boxWidth)}┘`));
181
250
  console.log('');
182
251
 
183
- // 3. Multi-panel Grid (Pipeline & Findings)
184
- const pipelineRows = [
185
- `</> SAST (Semgrep) ${green('✓ OK')}`,
186
- `📦 Dependency Check (Trivy) ${green('✓ OK')}`,
187
- `🔑 Secrets Scan (Gitleaks) ${green('✓ OK')}`,
188
- `🔒 IaC Scan (Checkov) ${green('✓ OK')}`,
189
- `🐳 Container Scan (Trivy) ${green('✓ OK')}`,
190
- `📑 Code Quality (ESLint) ${green('✓ OK')}`,
191
- ``,
192
- `${cyan('▶ REPOSITORY')}`,
193
- `${gray('Name:')} ${white(gitInfo.name)}`,
194
- `${gray('Branch:')} ${white(gitInfo.branch)}`,
195
- `${gray('Commit:')} ${white(gitInfo.commit)}`,
196
- `${gray('Scan Time:')} ${white(duration)}`,
197
- `${gray('Policy:')} ${white(gitInfo.policy)}`
252
+ // 3. Truthful Scanner Status & Security Domains
253
+ console.log(cyan.bold('▶ SCANNER COVERAGE'));
254
+ console.log('');
255
+
256
+ for (const s of scanners) {
257
+ const dur = s.durationMs ? `${(s.durationMs / 1000).toFixed(1)}s` : '0.1s';
258
+ const name = s.scanner.padEnd(16);
259
+
260
+ let stateStr = '';
261
+ const normState = String(s.state).toUpperCase();
262
+
263
+ if (normState === 'SUCCESS') {
264
+ const count = s.findingsCount || 0;
265
+ const countLabel = `${count} ${count === 1 ? 'finding' : 'findings'}`;
266
+ stateStr = `${green('✓ SUCCESS')} ${white(countLabel.padEnd(14))} ${dimGray(dur)}`;
267
+ } else if (normState === 'NOT_INSTALLED') {
268
+ stateStr = `${dimGray('○ NOT INSTALLED')}`;
269
+ } else if (normState === 'SKIPPED') {
270
+ const r = (s.reason || '').toLowerCase();
271
+ if (r.includes('not applicable') || r.includes('no live web') || r.includes('no iac') || r.includes('credentials not configured')) {
272
+ stateStr = `${dimGray('— NOT APPLICABLE')} ${dimGray(s.reason ? `(${s.reason.replace(/^Skipped:\s*/i, '')})` : '')}`;
273
+ } else {
274
+ stateStr = `${yellow('⚠ SKIPPED')} ${dimGray(s.reason || '')}`;
275
+ }
276
+ } else if (normState === 'TIMEOUT') {
277
+ stateStr = `${red('⏱ TIMEOUT')} ${dimGray(`(${dur})`)}`;
278
+ } else if (normState === 'FAILED') {
279
+ stateStr = `${red('✗ FAILED')} ${dimGray(s.reason || '')}`;
280
+ } else {
281
+ stateStr = `${dimGray('— ' + normState)}`;
282
+ }
283
+
284
+ console.log(` ${name} ${stateStr}`);
285
+ }
286
+
287
+ // Domain mapping summary
288
+ console.log(`\n ${gray(`Domain Assessment: ${activeDomainsCount} / 7`)}`);
289
+ const domainLabels: { key: keyof ScannerCoverage; label: string }[] = [
290
+ { key: 'dependencies', label: 'Dependencies' },
291
+ { key: 'code', label: 'Code' },
292
+ { key: 'secrets', label: 'Secrets' },
293
+ { key: 'containers', label: 'Containers' },
294
+ { key: 'iac', label: 'IaC' },
295
+ { key: 'web', label: 'Web/API' },
296
+ { key: 'cloud', label: 'Cloud' }
198
297
  ];
199
298
 
200
- // Table of findings (top 10)
201
- const topFindings = findings.slice(0, 10);
202
- const tableHeader = `${dimGray(padVisible('ID', 13))} ${dimGray(padVisible('SEVERITY', 10))} ${dimGray(padVisible('TITLE', 28))} ${dimGray('FILE:LINE')}`;
203
-
204
- const findingRows: string[] = [tableHeader];
299
+ const domainStr = domainLabels
300
+ .map(d => (coverage[d.key] ? green(`✓ ${d.label}`) : dimGray(`○ ${d.label}`)))
301
+ .join(' ');
302
+ console.log(` ${domainStr}\n`);
303
+
304
+ // 4. Score Breakdown
305
+ console.log(cyan.bold('▶ SCORE BREAKDOWN'));
306
+ console.log('');
307
+ const breakdownWidth = 48;
308
+ console.log(` ${padVisible('Base score', breakdownWidth - 6)} ${white('100')}`);
309
+
310
+ if (stats.critical > 0) {
311
+ const critDed = stats.critical * 30;
312
+ console.log(` ${padVisible(`${stats.critical} × Critical finding${stats.critical > 1 ? 's' : ''}`, breakdownWidth - 6)} ${red(`- ${critDed}`)}`);
313
+ }
314
+ if (stats.high > 0) {
315
+ const highDed = stats.high * 10;
316
+ console.log(` ${padVisible(`${stats.high} × High finding${stats.high > 1 ? 's' : ''}`, breakdownWidth - 6)} ${orange(`- ${highDed}`)}`);
317
+ }
318
+ if (stats.medium > 0) {
319
+ const medDed = stats.medium * 3;
320
+ console.log(` ${padVisible(`${stats.medium} × Medium finding${stats.medium > 1 ? 's' : ''}`, breakdownWidth - 6)} ${yellow(`- ${medDed}`)}`);
321
+ }
322
+ if (stats.low > 0) {
323
+ const lowDed = stats.low * 1;
324
+ console.log(` ${padVisible(`${stats.low} × Low finding${stats.low > 1 ? 's' : ''}`, breakdownWidth - 6)} ${cyan(`- ${lowDed}`)}`);
325
+ }
326
+ if (stats.total === 0) {
327
+ console.log(` ${padVisible('No security deductions across assessed domains', breakdownWidth - 6)} ${green('+ 0')}`);
328
+ }
205
329
 
206
- if (topFindings.length === 0) {
207
- findingRows.push(green(' No security vulnerabilities detected. Codebase is clean!'));
330
+ console.log(` ${darkBorder('─'.repeat(breakdownWidth))}`);
331
+ console.log(` ${padVisible('Final score', breakdownWidth - 6)} ${white.bold(String(scoreNum))}`);
332
+ console.log(` ${padVisible('Grade', breakdownWidth - 6)} ${gradeColor.bold(gradeLetter)}`);
333
+
334
+ if (stats.critical > 0) {
335
+ console.log(` ${red('Critical finding detected: Grade capped at F')}`);
336
+ }
337
+ console.log('');
338
+
339
+ // 5. Findings Table
340
+ console.log(cyan.bold('▶ FINDINGS'));
341
+ console.log('');
342
+
343
+ const displayLimit = 5;
344
+ const topFindings = findings.slice(0, displayLimit);
345
+
346
+ const colId = 15;
347
+ const colSev = 12;
348
+ const colIssue = 36;
349
+ const colLoc = 24;
350
+
351
+ const headerRow = ` ${dimGray(padVisible('ID', colId))} ${dimGray(padVisible('SEVERITY', colSev))} ${dimGray(padVisible('ISSUE', colIssue))} ${dimGray('LOCATION')}`;
352
+ console.log(headerRow);
353
+ console.log(` ${darkBorder('─'.repeat(colId + colSev + colIssue + colLoc))}`);
354
+
355
+ if (findings.length === 0) {
356
+ console.log(` ${green('✓ No vulnerabilities detected in scanned domains.')}`);
208
357
  } else {
209
- for (const f of topFindings) {
210
- const id = f.id || 'VG-FIND';
358
+ findings.forEach((f, idx) => {
359
+ if (idx >= displayLimit) return;
360
+ // Consistent ID format VG-FIND-001
361
+ const id = f.id || `VG-FIND-${String(idx + 1).padStart(3, '0')}`;
211
362
  const sev = (f.severity || 'LOW').toUpperCase();
212
- let sevFormatted = cyan('LOW ');
213
- if (sev === 'CRITICAL') sevFormatted = red.bold('CRITICAL ');
214
- else if (sev === 'HIGH') sevFormatted = orange.bold('HIGH ');
215
- else if (sev === 'MEDIUM') sevFormatted = yellow('MEDIUM ');
216
-
217
- const rawTitle = f.title || 'Security Finding';
218
- const titleTruncated = rawTitle.length > 26 ? rawTitle.slice(0, 24) + '..' : rawTitle;
219
- const titleFormatted = padVisible(white(titleTruncated), 28);
220
-
221
- const fileLine = `${f.file || 'unknown'}:${f.line || 1}`;
222
- const idFormatted = padVisible(cyan(id), 13);
223
- findingRows.push(`${idFormatted} ${sevFormatted} ${titleFormatted} ${dimGray(fileLine)}`);
363
+
364
+ let sevFormatted = cyan('LOW ');
365
+ if (sev === 'CRITICAL') sevFormatted = red.bold('CRITICAL');
366
+ else if (sev === 'HIGH') sevFormatted = orange.bold('HIGH ');
367
+ else if (sev === 'MEDIUM') sevFormatted = yellow('MEDIUM ');
368
+
369
+ const rawTitle = maskSecrets(f.title || 'Security Finding');
370
+ const truncatedTitle = rawTitle.length > 33 ? rawTitle.slice(0, 31) + '..' : rawTitle;
371
+
372
+ const loc = `${f.file || 'repo'}:${f.line || 1}`;
373
+ const row = ` ${white(padVisible(id, colId))} ${padVisible(sevFormatted, colSev)} ${white(padVisible(truncatedTitle, colIssue))} ${dimGray(loc)}`;
374
+ console.log(row);
375
+ });
376
+
377
+ if (findings.length > displayLimit) {
378
+ console.log(`\n ${dimGray(`Showing ${displayLimit} of ${findings.length} findings`)}`);
224
379
  }
225
380
  }
381
+ console.log('');
226
382
 
227
- // Print Section Headers with ANSI-safe padding
228
- const headerCol1 = padVisible(cyan('▶ SCANNER PIPELINE'), 36);
229
- const headerCol2 = cyan('▶ TOP FINDINGS');
230
- console.log(`${headerCol1} ${headerCol2}`);
231
-
232
- const maxRows = Math.max(pipelineRows.length, findingRows.length);
233
- for (let i = 0; i < maxRows; i++) {
234
- const left = padVisible(pipelineRows[i] || '', 36);
235
- const right = findingRows[i] || '';
236
- console.log(`${left} ${right}`);
383
+ // 6. Structured AI Remediation Section (Directive 10 & 11)
384
+ if (remediation) {
385
+ if (remediation.status === 'UNAVAILABLE') {
386
+ console.log(cyan.bold('▶ AI REMEDIATION · UNAVAILABLE'));
387
+ console.log('');
388
+ console.log(gray(' Reason:'));
389
+ console.log(` ${yellow(remediation.unavailableReason || 'NVIDIA_API_KEY not configured.')}`);
390
+ console.log(` ${dimGray('Scanning, deterministic scoring, and policy enforcement remain 100% operational.')}\n`);
391
+ } else {
392
+ console.log(cyan.bold('▶ AI REMEDIATION · OPTIONAL'));
393
+ console.log('');
394
+ console.log(` ${gray('Finding:')} ${white.bold(remediation.findingId)}`);
395
+ if (remediation.severity) {
396
+ console.log(` ${gray('Severity:')} ${white(remediation.severity)}`);
397
+ }
398
+ console.log('');
399
+
400
+ console.log(` ${cyan('ISSUE')}`);
401
+ console.log(` ${white(maskSecrets(remediation.issue))}\n`);
402
+
403
+ if (remediation.impact) {
404
+ console.log(` ${cyan('IMPACT')}`);
405
+ console.log(` ${white(maskSecrets(remediation.impact))}\n`);
406
+ }
407
+
408
+ if (remediation.recommendation) {
409
+ console.log(` ${cyan('RECOMMENDED FIX')}`);
410
+ console.log(` ${white(maskSecrets(remediation.recommendation))}\n`);
411
+ }
412
+
413
+ console.log(` ${cyan('SUGGESTED FIX')}`);
414
+ if (remediation.hasConcretePatch && remediation.suggestedFix) {
415
+ console.log(` ${darkBorder('┌────────────────────────────────────────────────────────────┐')}`);
416
+ const lines = maskSecrets(remediation.suggestedFix).split('\n');
417
+ for (const line of lines) {
418
+ let colored = white(line);
419
+ if (line.trim().startsWith('+')) colored = green(line);
420
+ else if (line.trim().startsWith('-')) colored = red(line);
421
+ else if (line.trim().startsWith('#') || line.trim().startsWith('//')) colored = dimGray(line);
422
+ console.log(` ${darkBorder('│')} ${padVisible(colored, 58)} ${darkBorder('│')}`);
423
+ }
424
+ console.log(` ${darkBorder('└────────────────────────────────────────────────────────────┘')}`);
425
+
426
+ if (remediation.confidence) {
427
+ console.log(`\n ${gray('Confidence:')} ${green.bold(remediation.confidence + '%')}`);
428
+ }
429
+ console.log(` ${gray('Status:')} ${white.bold(remediation.status)}`);
430
+ } else {
431
+ console.log(` ${yellow('No patch generated — guidance only.')}\n`);
432
+ console.log(` ${gray('Status:')} ${yellow.bold('GUIDANCE ONLY')}`);
433
+ }
434
+ console.log('');
435
+ }
237
436
  }
238
437
 
239
- // 4. AI Remediation Section
240
- if (remediation) {
241
- console.log(`\n${cyan('▶ AI REMEDIATION (VG-AI)')}\n`);
242
- console.log(`${cyan('Finding:')} ${red.bold(remediation.findingId)}\n`);
243
- console.log(`${cyan('Issue')}\n${white(remediation.issue)}\n`);
244
- console.log(`${cyan('Impact')}\n${white(remediation.impact)}\n`);
245
- console.log(`${cyan('Recommendation')}\n${white(remediation.recommendation)}\n`);
246
-
247
- console.log(`${cyan('Suggested Fix')}`);
248
- console.log(darkBorder('┌────────────────────────────────────────────────────────────┐'));
249
- const diffLines = remediation.diffSnippet.split('\n');
250
- for (const d of diffLines) {
251
- let styled = d;
252
- if (d.trim().startsWith('-')) styled = red(d);
253
- else if (d.trim().startsWith('+')) styled = green(d);
254
- else if (d.trim().startsWith('#')) styled = dimGray(d);
255
- else styled = white(d);
256
-
257
- const paddedLine = padVisible(styled, 58);
258
- console.log(`${darkBorder('')} ${paddedLine} ${darkBorder('│')}`);
438
+ // 7. Repository Metadata (Directive 13)
439
+ console.log(cyan.bold('▶ REPOSITORY'));
440
+ console.log('');
441
+ console.log(` ${gray('Name'.padEnd(12))} ${white(gitInfo.name)}`);
442
+ console.log(` ${gray('Branch'.padEnd(12))} ${white(gitInfo.branch)}`);
443
+ console.log(` ${gray('Commit'.padEnd(12))} ${white(gitInfo.commit)}`);
444
+ console.log(` ${gray('Duration'.padEnd(12))} ${white(duration)}`);
445
+ if (policyThreshold) {
446
+ console.log(` ${gray('Policy'.padEnd(12))} ${white('fail-on ' + policyThreshold)}`);
447
+ }
448
+ console.log('');
449
+
450
+ // 8. Truthful Summary (Directive 14)
451
+ console.log(cyan.bold('▶ SUMMARY'));
452
+ console.log('');
453
+ const evaluatedCount = scanners.length;
454
+ const executedCount = scanners.filter(s => String(s.state).toUpperCase() === 'SUCCESS').length;
455
+
456
+ console.log(` ${green('✓')} Scan completed in ${duration}`);
457
+ console.log(` ${green('')} ${evaluatedCount} scanners evaluated`);
458
+ console.log(` ${green('✓')} ${executedCount} scanner${executedCount === 1 ? '' : 's'} executed`);
459
+ console.log(` ${green('✓')} ${stats.total} findings detected (${stats.critical} critical, ${stats.high} high, ${stats.medium} medium, ${stats.low} low)`);
460
+ console.log('');
461
+
462
+ // 9. Local vs Cloud Synchronization Status (Directive 15)
463
+ console.log(` ${green('✓')} Local scan completed`);
464
+ if (syncStatus === 'SYNCED') {
465
+ console.log(` ${green('✓')} Results synced to VibeGuard Cloud (Tenant Isolated)`);
466
+ } else if (syncStatus === 'FAILED') {
467
+ console.log(` ${red('✗')} Cloud sync failed (local result preserved)`);
468
+ } else {
469
+ console.log(` ${dimGray('○')} Cloud sync skipped — run 'vibeguard scan --sync' to stream telemetry`);
470
+ }
471
+
472
+ // 10. Privacy Guarantee (Directive 16)
473
+ console.log(`\n ${dimGray('Privacy: Source code remains local. Only normalized security metadata is synchronized.')}\n`);
474
+ }
475
+
476
+ export function renderCIOutput(options: {
477
+ deterministicScore: DeterministicScore;
478
+ findings: NormalizedFinding[];
479
+ policyPassed: boolean;
480
+ failThreshold: string;
481
+ scanners?: ScannerTelemetry[];
482
+ verbose?: boolean;
483
+ }): string[] {
484
+ const lines: string[] = [];
485
+ lines.push('VibeGuard Security Policy');
486
+ lines.push('');
487
+ lines.push(`Score: ${options.deterministicScore.score}/100 (${options.deterministicScore.grade})`);
488
+ lines.push(`Findings: ${options.findings.length}`);
489
+ lines.push(`Critical: ${options.deterministicScore.breakdown.critical}`);
490
+ lines.push(`High: ${options.deterministicScore.breakdown.high}`);
491
+ lines.push(`Medium: ${options.deterministicScore.breakdown.medium}`);
492
+ lines.push(`Low: ${options.deterministicScore.breakdown.low}`);
493
+ lines.push('');
494
+ lines.push(`Policy: ${options.policyPassed ? 'PASS' : 'FAIL'}`);
495
+ lines.push(`Threshold: ${options.failThreshold.toUpperCase()}`);
496
+
497
+ if (options.verbose && options.scanners) {
498
+ lines.push('');
499
+ lines.push('Scanner Telemetry:');
500
+ for (const sr of options.scanners) {
501
+ lines.push(` - ${sr.scanner.padEnd(14)}: ${sr.state.padEnd(14)} (${sr.durationMs || 0}ms) findings: ${sr.findingsCount || 0}`);
259
502
  }
260
- console.log(darkBorder('└────────────────────────────────────────────────────────────┘'));
503
+ }
504
+ return lines;
505
+ }
506
+
507
+ export function generateJsonOutput(options: {
508
+ deterministicScore: DeterministicScore;
509
+ findings: NormalizedFinding[];
510
+ coverageData: ScannerCoverage;
511
+ activeDomainsCount: number;
512
+ postureStatus: 'COMPLETE' | 'PARTIAL';
513
+ scanners: ScannerTelemetry[];
514
+ gitInfo: { name: string; branch: string; commit: string };
515
+ policyPassed: boolean;
516
+ failThreshold: string;
517
+ durationMs: number;
518
+ }) {
519
+ return {
520
+ score: options.deterministicScore.score,
521
+ grade: options.deterministicScore.grade,
522
+ postureStatus: options.postureStatus,
523
+ coverage: {
524
+ assessedDomains: options.activeDomainsCount,
525
+ totalDomains: 7,
526
+ domains: options.coverageData
527
+ },
528
+ deductions: options.deterministicScore.deductions,
529
+ breakdown: options.deterministicScore.breakdown,
530
+ explanation: options.deterministicScore.explanation,
531
+ findings: options.findings.map(f => ({
532
+ id: f.id,
533
+ title: maskSecrets(f.title || ''),
534
+ severity: f.severity,
535
+ scanner: f.scanner,
536
+ file: f.file,
537
+ line: f.line,
538
+ ruleId: f.ruleId,
539
+ cwe: f.cwe,
540
+ owasp: f.owasp
541
+ })),
542
+ scanners: options.scanners,
543
+ repository: {
544
+ name: options.gitInfo.name,
545
+ branch: options.gitInfo.branch,
546
+ commit: options.gitInfo.commit
547
+ },
548
+ policyResult: {
549
+ passed: options.policyPassed,
550
+ threshold: options.failThreshold.toUpperCase()
551
+ },
552
+ durationMs: options.durationMs
553
+ };
554
+ }
261
555
 
262
- console.log(`\n${cyan('Confidence:')} ${green.bold(remediation.confidence + '%')}`);
556
+ export function evaluatePolicy(
557
+ failThreshold: string,
558
+ breakdown: { critical: number; high: number; medium: number; low: number },
559
+ totalFindings: number
560
+ ): { passed: boolean; exitCode: number } {
561
+ const normThreshold = (failThreshold || 'high').toLowerCase();
562
+ let thresholdBreached = false;
563
+
564
+ if (normThreshold === 'critical') {
565
+ thresholdBreached = breakdown.critical > 0;
566
+ } else if (normThreshold === 'high') {
567
+ thresholdBreached = breakdown.critical > 0 || breakdown.high > 0;
568
+ } else if (normThreshold === 'medium') {
569
+ thresholdBreached = breakdown.critical > 0 || breakdown.high > 0 || breakdown.medium > 0;
570
+ } else if (normThreshold === 'low') {
571
+ thresholdBreached = totalFindings > 0;
263
572
  }
264
573
 
265
- // 5. Summary Bar (bottom capsule)
266
- console.log(`\n${cyan('▶ SUMMARY')}`);
267
- const summaryText = `🛡️ Scan completed in ${duration} │ ${stats.total} findings │ 6 passed`;
268
- const barTop = `┌${'─'.repeat(visibleWidth(summaryText) + 4)}┐`;
269
- const barMid = `│ ${summaryText} │`;
270
- const barBot = `└${'─'.repeat(visibleWidth(summaryText) + 4)}┘`;
271
- console.log(cyan(barTop));
272
- console.log(cyan(barMid));
273
- console.log(cyan(barBot));
274
- console.log(`\n💡 ${gray('Tip: Run')} ${cyan('`vibeguard watch`')} ${gray('to continuously monitor your codebase.')}\n`);
574
+ return {
575
+ passed: !thresholdBreached,
576
+ exitCode: thresholdBreached ? 1 : 0
577
+ };
275
578
  }