563 lines
10 KiB
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"
|
|
}
|