Skip to content

Commit 7ca2fe3

Browse files
authored
Add human-readable vulnerability summary to terminal output (#5)
Replace the terse "N vulnerabilities found" line with a colored, categorized summary showing counts per severity level and individual listings for critical and high vulnerabilities. Medium and low are shown as counts only to avoid flooding the terminal. Output goes to stderr, respects --quiet flag, and uses emoji indicators. Long vulnerability summaries are truncated at 60 characters. Closes #2
1 parent 04c2d26 commit 7ca2fe3

4 files changed

Lines changed: 192 additions & 4 deletions

File tree

internal/cli/pipeline.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,8 @@ func runPipeline(cmd *cobra.Command, args []string) error {
208208
return fmt.Errorf("writing VEX document: %w", err)
209209
}
210210
if !quiet {
211-
fmt.Fprintf(os.Stderr, " VEX document written to %s (%d vulnerabilities found)\n",
212-
vexPath, triageResult.VulnCount)
211+
fmt.Fprintf(os.Stderr, " VEX document written to %s\n", vexPath)
212+
vex.PrintSummary(os.Stderr, triageResult)
213213
}
214214
}
215215
} else if !quiet {

internal/cli/vex.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,7 @@ var vexTriageCmd = &cobra.Command{
166166

167167
quiet, _ := cmd.Flags().GetBool("quiet")
168168
if !quiet {
169-
fmt.Fprintf(os.Stderr, "Scanned %d components, found %d vulnerabilities\n",
170-
result.ComponentCount, result.VulnCount)
169+
vex.PrintSummary(os.Stderr, result)
171170
}
172171

173172
outputPath, _ := cmd.Flags().GetString("output")

internal/vex/summary.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package vex
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"sort"
8+
"strings"
9+
)
10+
11+
// severityOrder defines display order (most severe first).
12+
var severityOrder = []SeverityLevel{
13+
SeverityCritical,
14+
SeverityHigh,
15+
SeverityMedium,
16+
SeverityLow,
17+
SeverityUnknown,
18+
}
19+
20+
// severityIndicator returns the emoji + label for a severity level.
21+
func severityIndicator(sev SeverityLevel) string {
22+
switch sev {
23+
case SeverityCritical:
24+
return "\xf0\x9f\x94\xb4 CRITICAL" // 🔴
25+
case SeverityHigh:
26+
return "\xf0\x9f\x9f\xa1 HIGH" // 🟡
27+
case SeverityMedium:
28+
return "\xf0\x9f\x9f\xa0 MEDIUM" // 🟠
29+
case SeverityLow:
30+
return "\xe2\x9a\xaa LOW" // ⚪
31+
case SeverityUnknown:
32+
return "\xe2\x9d\x94 UNKNOWN" // ❔
33+
default:
34+
return string(sev)
35+
}
36+
}
37+
38+
// PrintSummary writes a human-readable vulnerability summary to w.
39+
// If isTTY is false, emoji indicators are replaced with plain text labels.
40+
func PrintSummary(w io.Writer, result *TriageResult) {
41+
if result.VulnCount == 0 {
42+
fmt.Fprintf(w, "\n \xe2\x9c\x85 %d components scanned, no vulnerabilities found\n\n", result.ComponentCount)
43+
return
44+
}
45+
46+
// Header line with counts per severity
47+
fmt.Fprintf(w, "\n")
48+
parts := make([]string, 0)
49+
for _, sev := range severityOrder {
50+
if count, ok := result.CountBySeverity[sev]; ok && count > 0 {
51+
parts = append(parts, fmt.Sprintf("%s %d %s", severityIndicator(sev), count, strings.ToUpper(string(sev))))
52+
}
53+
}
54+
fmt.Fprintf(w, " %s\n\n", strings.Join(parts, " "))
55+
56+
// Group vulnerabilities by severity
57+
grouped := make(map[SeverityLevel][]VulnDetail)
58+
for _, v := range result.Vulnerabilities {
59+
grouped[v.Severity] = append(grouped[v.Severity], v)
60+
}
61+
62+
// Print critical and high individually
63+
for _, sev := range []SeverityLevel{SeverityCritical, SeverityHigh} {
64+
vulns, ok := grouped[sev]
65+
if !ok || len(vulns) == 0 {
66+
continue
67+
}
68+
69+
// Sort by ID for consistent output
70+
sort.Slice(vulns, func(i, j int) bool {
71+
return vulns[i].ID < vulns[j].ID
72+
})
73+
74+
fmt.Fprintf(w, " %s:\n", strings.ToUpper(string(sev)))
75+
for _, v := range vulns {
76+
summary := v.Summary
77+
if len(summary) > 60 {
78+
summary = summary[:57] + "..."
79+
}
80+
fmt.Fprintf(w, " %-30s %-60s %s\n", v.ComponentName, summary, v.ID)
81+
}
82+
fmt.Fprintf(w, "\n")
83+
}
84+
85+
fmt.Fprintf(w, " Scanned %d components, found %d vulnerabilities\n\n", result.ComponentCount, result.VulnCount)
86+
}
87+
88+
// PrintSummaryToStderr is a convenience function that prints to stderr.
89+
func PrintSummaryToStderr(result *TriageResult) {
90+
PrintSummary(os.Stderr, result)
91+
}

internal/vex/summary_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package vex
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestPrintSummaryNoVulns(t *testing.T) {
10+
var buf bytes.Buffer
11+
result := &TriageResult{
12+
VulnCount: 0,
13+
ComponentCount: 42,
14+
CountBySeverity: map[SeverityLevel]int{},
15+
}
16+
PrintSummary(&buf, result)
17+
output := buf.String()
18+
19+
if !strings.Contains(output, "42 components scanned") {
20+
t.Errorf("expected component count in output, got: %s", output)
21+
}
22+
if !strings.Contains(output, "no vulnerabilities found") {
23+
t.Errorf("expected no-vuln message, got: %s", output)
24+
}
25+
}
26+
27+
func TestPrintSummaryWithVulns(t *testing.T) {
28+
var buf bytes.Buffer
29+
result := &TriageResult{
30+
VulnCount: 5,
31+
ComponentCount: 100,
32+
CountBySeverity: map[SeverityLevel]int{
33+
SeverityCritical: 1,
34+
SeverityHigh: 2,
35+
SeverityMedium: 1,
36+
SeverityLow: 1,
37+
},
38+
Vulnerabilities: []VulnDetail{
39+
{ID: "CVE-2021-44228", Summary: "Remote code execution via JNDI", Severity: SeverityCritical, ComponentName: "log4j@2.14.1"},
40+
{ID: "CVE-2024-1234", Summary: "Prototype pollution", Severity: SeverityHigh, ComponentName: "lodash@4.17.20"},
41+
{ID: "CVE-2024-5678", Summary: "SSRF in proxy config", Severity: SeverityHigh, ComponentName: "axios@1.6.0"},
42+
{ID: "CVE-2024-9999", Summary: "Minor info leak", Severity: SeverityMedium, ComponentName: "express@4.17.1"},
43+
{ID: "CVE-2024-0001", Summary: "Low severity issue", Severity: SeverityLow, ComponentName: "debug@4.3.4"},
44+
},
45+
}
46+
PrintSummary(&buf, result)
47+
output := buf.String()
48+
49+
// Check severity header line
50+
if !strings.Contains(output, "CRITICAL") {
51+
t.Errorf("expected CRITICAL in output, got: %s", output)
52+
}
53+
if !strings.Contains(output, "HIGH") {
54+
t.Errorf("expected HIGH in output, got: %s", output)
55+
}
56+
57+
// Check critical vulns are listed individually
58+
if !strings.Contains(output, "log4j@2.14.1") {
59+
t.Errorf("expected critical vuln component listed, got: %s", output)
60+
}
61+
if !strings.Contains(output, "CVE-2021-44228") {
62+
t.Errorf("expected critical CVE listed, got: %s", output)
63+
}
64+
65+
// Check high vulns are listed individually
66+
if !strings.Contains(output, "lodash@4.17.20") {
67+
t.Errorf("expected high vuln component listed, got: %s", output)
68+
}
69+
70+
// Check footer
71+
if !strings.Contains(output, "100 components") {
72+
t.Errorf("expected component count in footer, got: %s", output)
73+
}
74+
if !strings.Contains(output, "5 vulnerabilities") {
75+
t.Errorf("expected vuln count in footer, got: %s", output)
76+
}
77+
}
78+
79+
func TestPrintSummaryLongSummaryTruncated(t *testing.T) {
80+
var buf bytes.Buffer
81+
longSummary := strings.Repeat("A", 100)
82+
result := &TriageResult{
83+
VulnCount: 1,
84+
ComponentCount: 10,
85+
CountBySeverity: map[SeverityLevel]int{
86+
SeverityCritical: 1,
87+
},
88+
Vulnerabilities: []VulnDetail{
89+
{ID: "CVE-2024-0001", Summary: longSummary, Severity: SeverityCritical, ComponentName: "pkg@1.0.0"},
90+
},
91+
}
92+
PrintSummary(&buf, result)
93+
output := buf.String()
94+
95+
if !strings.Contains(output, "...") {
96+
t.Errorf("expected truncated summary with ..., got: %s", output)
97+
}
98+
}

0 commit comments

Comments
 (0)