Cybersecurity-Projects/PROJECTS/beginner/simple-vulnerability-scanner/internal/cli/output.go

563 lines
10 KiB
Go

/*
©AngelaMos | 2026
output.go
Terminal output functions for scan results, updates, skips, and errors
Formats and prints all user-facing output: the update table with per-change
type colors, the vulnerability report in compact or verbose mode, the
skipped-packages list, the summary block, and error messages. Reads the
package-level verbose and minSeverity flags defined in update.go.
Key exports:
PrintUpdates - renders the table of available dependency updates
PrintSkipped - lists packages skipped during update resolution
PrintVulnerabilities - renders vulnerability findings in compact or verbose mode
PrintSummary - prints final scan totals and elapsed time
PrintError - prints a formatted error message to stdout
Connects to:
update.go - calls all Print functions after scan completes
pypi - uses ChangeKind (Major, Minor) for update color selection
ui - uses color functions and symbol constants for formatting
types.go - accepts UpdateResult, Vulnerability, ScanResult
*/
package cli
import (
"fmt"
"sort"
"strings"
"github.com/CarterPerez-dev/angela/internal/pypi"
"github.com/CarterPerez-dev/angela/internal/ui"
"github.com/CarterPerez-dev/angela/pkg/types"
)
const (
severityCritical = "CRITICAL"
severityHigh = "HIGH"
severityModerate = "MODERATE"
severityLow = "LOW"
)
func printDivider() {
fmt.Printf("\n %s\n", ui.HiBlack(ui.HRule(44)))
}
func printSectionHeader(
symbol, title string,
symbolColor, titleColor func(a ...any) string,
) {
fmt.Printf(
"\n %s %s\n",
symbolColor(symbol),
titleColor(title),
)
}
func PrintUpdates(updates []types.UpdateResult) {
var actionable []types.UpdateResult
for _, u := range updates {
if !u.Skipped {
actionable = append(actionable, u)
}
}
if len(actionable) == 0 {
printDivider()
fmt.Printf(
"\n %s %s\n",
ui.HiGreen(ui.Check),
ui.HiBlackItalic(
"All dependencies are up to date.",
),
)
return
}
printDivider()
printSectionHeader(
ui.Diamond, "Updates available",
ui.Cyan, ui.Red,
)
nameWidth := 0
oldWidth := 0
for _, u := range actionable {
if len(u.Name) > nameWidth {
nameWidth = len(u.Name)
}
if len(u.OldVer) > oldWidth {
oldWidth = len(u.OldVer)
}
}
for _, u := range actionable {
changeColor := changeColorFn(u.Change)
fmt.Printf(
" %-*s %s %s %s %s %s\n",
nameWidth, ui.HiBlue(u.Name),
ui.HiBlackCrossed(
padRight(u.OldVer, oldWidth),
),
ui.Green(ui.Arrow),
changeColor(u.NewVer),
ui.HiBlack(ui.ArrowRight),
changeColor(u.Change),
)
}
}
func PrintSkipped(updates []types.UpdateResult) {
var skipped []types.UpdateResult
for _, u := range updates {
if u.Skipped {
skipped = append(skipped, u)
}
}
if len(skipped) == 0 {
return
}
printDivider()
printSectionHeader(
ui.ArrowRight, "Skipped",
ui.Yellow, ui.Red,
)
for _, u := range skipped {
fmt.Printf(
" %s %s\n",
ui.HiBlue(u.Name),
ui.HiBlackItalic(u.Reason),
)
}
}
func PrintVulnerabilities(
vulns map[string][]types.Vulnerability,
) {
if len(vulns) == 0 {
return
}
total := 0
for _, vl := range vulns {
total += len(vl)
}
printDivider()
fmt.Printf(
"\n %s %s %s %s %s %s\n",
ui.Red(ui.TriangleUp),
ui.Red("Vulnerabilities:"),
ui.RedBold(fmt.Sprintf("%d", total)),
ui.HiBlack("across"),
ui.RedBold(fmt.Sprintf("%d", len(vulns))),
ui.HiBlack(pluralize("package", len(vulns))),
)
pkgs := sortedVulnPackages(vulns)
if verbose {
printVulnsVerbose(pkgs, vulns)
} else {
printVulnsCompact(pkgs, vulns)
}
}
const maxVulnsPerPackage = 5
func printVulnsCompact(
pkgs []string,
vulns map[string][]types.Vulnerability,
) {
for _, pkg := range pkgs {
vlist := vulns[pkg]
sortVulnsBySeverity(vlist)
fmt.Printf(
"\n %s %s %s\n",
ui.Blue(ui.Gem),
ui.HiCyan(pkg),
ui.HiBlack(fmt.Sprintf(
"(%d %s: %s)",
len(vlist),
pluralize("vuln", len(vlist)),
severityBreakdown(vlist),
)),
)
limit := min(maxVulnsPerPackage, len(vlist))
for i := range limit {
printVulnLine(vlist[i])
}
if len(vlist) > maxVulnsPerPackage {
fmt.Printf(
" %s\n",
ui.HiBlackItalic(fmt.Sprintf(
"...and %d more",
len(vlist)-maxVulnsPerPackage,
)),
)
}
}
fmt.Printf(
"\n %s\n",
ui.HiBlackItalic(
"Run angela scan -v for full details.",
),
)
}
func printVulnsVerbose(
pkgs []string,
vulns map[string][]types.Vulnerability,
) {
for _, pkg := range pkgs {
vlist := vulns[pkg]
sortVulnsBySeverity(vlist)
fmt.Printf(
"\n %s %s %s\n",
ui.Magenta(ui.Gem),
ui.HiCyan(pkg),
ui.HiBlack(fmt.Sprintf(
"(%d %s: %s)",
len(vlist),
pluralize("vuln", len(vlist)),
severityBreakdown(vlist),
)),
)
for _, v := range vlist {
id := preferredID(v)
sevColor := severityColorFn(v.Severity)
fmt.Printf(
"\n %s %s\n",
sevColor(
padRight(strings.ToUpper(v.Severity), 8),
),
ui.Magenta(id),
)
fmt.Printf(
" %s\n",
ui.HiBlack(truncate(v.Summary, 72)),
)
if v.FixedIn != "" {
fmt.Printf(
" %s %s\n",
ui.HiBlack("Fixed in:"),
ui.Green(v.FixedIn),
)
}
if v.Link != "" {
fmt.Printf(
" %s\n",
ui.BlueItalic(v.Link),
)
}
}
}
fmt.Println()
}
func printVulnLine(v types.Vulnerability) {
id := preferredID(v)
sevColor := severityColorFn(v.Severity)
fixedStr := ""
if v.FixedIn != "" {
fixedStr = ui.HiBlack("Fixed: ") +
ui.Green(v.FixedIn)
}
fmt.Printf(
" %s %s %s %s\n",
sevColor(
padRight(strings.ToUpper(v.Severity), 8),
),
ui.Blue(padRight(id, 16)),
ui.HiBlack(truncate(v.Summary, 38)),
fixedStr,
)
}
func sortedVulnPackages(
vulns map[string][]types.Vulnerability,
) []string {
pkgs := make([]string, 0, len(vulns))
for k := range vulns {
pkgs = append(pkgs, k)
}
sort.Slice(pkgs, func(i, j int) bool {
li := len(vulns[pkgs[i]])
lj := len(vulns[pkgs[j]])
if li != lj {
return li > lj
}
return pkgs[i] < pkgs[j]
})
return pkgs
}
func sortVulnsBySeverity(
vulns []types.Vulnerability,
) {
sort.Slice(vulns, func(i, j int) bool {
ri := severityRank(vulns[i].Severity)
rj := severityRank(vulns[j].Severity)
if ri != rj {
return ri < rj
}
return vulns[i].ID < vulns[j].ID
})
}
func severityRank(sev string) int {
switch strings.ToUpper(sev) {
case severityCritical:
return 0
case severityHigh:
return 1
case severityModerate:
return 2
case severityLow:
return 3
default:
return 4
}
}
func severityBreakdown(
vulns []types.Vulnerability,
) string {
counts := make(map[string]int)
for _, v := range vulns {
counts[strings.ToUpper(v.Severity)]++
}
order := []string{
severityCritical,
severityHigh,
severityModerate,
severityLow,
"UNKNOWN",
}
var parts []string
for _, sev := range order {
if n := counts[sev]; n > 0 {
sevColor := severityColorFn(sev)
parts = append(parts, sevColor(fmt.Sprintf(
"%d %s", n, strings.ToLower(sev),
)))
}
}
return strings.Join(parts, ui.HiBlack(", "))
}
func preferredID(v types.Vulnerability) string {
for _, alias := range v.Aliases {
if strings.HasPrefix(alias, "CVE-") {
return alias
}
}
return v.ID
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func filterIgnoredVulns(
vulns map[string][]types.Vulnerability,
ignoreIDs []string,
) map[string][]types.Vulnerability {
if len(ignoreIDs) == 0 {
return vulns
}
ignored := make(map[string]bool, len(ignoreIDs))
for _, id := range ignoreIDs {
ignored[id] = true
}
filtered := make(map[string][]types.Vulnerability)
for pkg, vlist := range vulns {
var kept []types.Vulnerability
for _, v := range vlist {
if isVulnIgnored(v, ignored) {
continue
}
kept = append(kept, v)
}
if len(kept) > 0 {
filtered[pkg] = kept
}
}
return filtered
}
func isVulnIgnored(
v types.Vulnerability, ignored map[string]bool,
) bool {
if ignored[v.ID] {
return true
}
for _, alias := range v.Aliases {
if ignored[alias] {
return true
}
}
return false
}
func filterVulnsBySeverity(
vulns map[string][]types.Vulnerability,
minSev string,
) map[string][]types.Vulnerability {
threshold := severityRank(minSev)
filtered := make(map[string][]types.Vulnerability)
for pkg, vlist := range vulns {
var kept []types.Vulnerability
for _, v := range vlist {
if severityRank(v.Severity) <= threshold {
kept = append(kept, v)
}
}
if len(kept) > 0 {
filtered[pkg] = kept
}
}
return filtered
}
func PrintSummary(
result types.ScanResult, updated bool,
) {
printDivider()
printSectionHeader(
ui.Star, "Summary",
ui.Green, ui.RedItalic,
)
if updated {
fmt.Printf(
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("Updated pyproject.toml"),
)
}
fmt.Printf(
" %s %s %s\n",
ui.Green(ui.Check),
ui.Green(fmt.Sprintf("%d", result.TotalPackages)),
ui.GreenItalic("packages checked"),
)
if result.TotalUpdated > 0 {
fmt.Printf(
" %s %s %s\n",
ui.Green(ui.ArrowUp),
ui.Green(
fmt.Sprintf("%d", result.TotalUpdated),
),
ui.GreenItalic("updated"),
)
}
if result.VulnsScanned {
if result.TotalVulns > 0 {
fmt.Printf(
" %s %s %s\n",
ui.HiRed(ui.Cross),
ui.RedBold(
fmt.Sprintf("%d", result.TotalVulns),
),
ui.HiRed(
pluralize(
"vulnerability",
result.TotalVulns,
)+" found",
),
)
} else {
fmt.Printf(
" %s %s\n",
ui.HiGreen(ui.Check),
ui.HiGreen("No vulnerabilities found"),
)
}
}
fmt.Printf(
" %s %s\n\n",
ui.HiCyan(ui.Timer),
ui.HiBlueItalic(
result.Duration.Round(1e6).String(),
),
)
}
func PrintError(msg string) {
fmt.Printf(
"\n %s %s %s\n\n",
ui.RedBold(ui.Cross),
ui.RedBold("error:"),
ui.HiRed(msg),
)
}
func changeColorFn(
kind string,
) func(a ...any) string {
switch kind {
case pypi.Major.String():
return ui.Red
case pypi.Minor.String():
return ui.HiYellow
default:
return ui.Green
}
}
func severityColorFn(
sev string,
) func(a ...any) string {
switch strings.ToUpper(sev) {
case severityCritical:
return ui.Red
case severityHigh:
return ui.RedBold
case severityModerate:
return ui.HiYellow
case severityLow:
return ui.Cyan
default:
return ui.HiBlack
}
}
func padRight(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}
func pluralize(word string, n int) string {
if n == 1 {
return word
}
if strings.HasSuffix(word, "y") {
return word[:len(word)-1] + "ies"
}
return word + "s"
}