457 lines
8.8 KiB
Go
457 lines
8.8 KiB
Go
// ©AngelaMos | 2026
|
|
// output.go
|
|
|
|
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/CarterPerez-dev/angela/internal/pypi"
|
|
"github.com/CarterPerez-dev/angela/pkg/types"
|
|
"github.com/fatih/color"
|
|
)
|
|
|
|
var (
|
|
bold = color.New(color.Bold).SprintFunc()
|
|
dim = color.New(color.Faint).SprintFunc()
|
|
cyan = color.New(color.FgCyan, color.Bold).SprintFunc()
|
|
green = color.New(color.FgGreen).SprintFunc()
|
|
yellow = color.New(color.FgYellow).SprintFunc()
|
|
red = color.New(color.FgRed).SprintFunc()
|
|
redBold = color.New(color.FgRed, color.Bold).SprintFunc()
|
|
greenBold = color.New(color.FgGreen, color.Bold).SprintFunc()
|
|
white = color.New(color.FgWhite, color.Bold).SprintFunc()
|
|
)
|
|
|
|
// PrintScanning announces the start of a scan
|
|
func PrintScanning(count int) {
|
|
fmt.Printf(
|
|
"\n %s %d dependencies...\n\n",
|
|
cyan("Scanning"), count,
|
|
)
|
|
}
|
|
|
|
// PrintUpdates displays a formatted table of available dependency updates
|
|
func PrintUpdates(updates []types.UpdateResult) {
|
|
var actionable []types.UpdateResult
|
|
for _, u := range updates {
|
|
if !u.Skipped {
|
|
actionable = append(actionable, u)
|
|
}
|
|
}
|
|
|
|
if len(actionable) == 0 {
|
|
fmt.Printf(" %s\n\n", dim("All dependencies are up to date."))
|
|
return
|
|
}
|
|
|
|
fmt.Printf(" %s\n", cyan("Updates available:"))
|
|
|
|
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\n",
|
|
nameWidth, white(u.Name),
|
|
dim(padRight(u.OldVer, oldWidth)),
|
|
dim("->"),
|
|
changeColor(u.NewVer),
|
|
dim("("+u.Change+")"),
|
|
)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// PrintSkipped displays dependencies that were not updated with reasons
|
|
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
|
|
}
|
|
|
|
fmt.Printf(" %s\n", dim("Skipped:"))
|
|
for _, u := range skipped {
|
|
fmt.Printf(" %s %s\n", dim(u.Name), dim(u.Reason))
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// PrintVulnerabilities displays security advisories in compact or verbose mode
|
|
func PrintVulnerabilities(
|
|
vulns map[string][]types.Vulnerability,
|
|
) {
|
|
if len(vulns) == 0 {
|
|
return
|
|
}
|
|
|
|
total := 0
|
|
for _, vl := range vulns {
|
|
total += len(vl)
|
|
}
|
|
|
|
fmt.Printf(
|
|
" %s %d across %d %s\n",
|
|
redBold("Vulnerabilities:"),
|
|
total,
|
|
len(vulns),
|
|
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 (%d %s: %s)\n",
|
|
white(pkg),
|
|
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",
|
|
dim(fmt.Sprintf(
|
|
"...and %d more",
|
|
len(vlist)-maxVulnsPerPackage,
|
|
)),
|
|
)
|
|
}
|
|
}
|
|
|
|
fmt.Printf(
|
|
"\n %s\n\n",
|
|
dim("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 (%d %s: %s)\n",
|
|
white(pkg),
|
|
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),
|
|
),
|
|
bold(id),
|
|
)
|
|
fmt.Printf(" %s\n", v.Summary)
|
|
if v.FixedIn != "" {
|
|
fmt.Printf(
|
|
" %s %s\n",
|
|
dim("Fixed in:"),
|
|
green(v.FixedIn),
|
|
)
|
|
}
|
|
if v.Link != "" {
|
|
fmt.Printf(" %s\n", dim(v.Link))
|
|
}
|
|
}
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
func printVulnLine(v types.Vulnerability) {
|
|
id := preferredID(v)
|
|
sevColor := severityColorFn(v.Severity)
|
|
|
|
fixedStr := ""
|
|
if v.FixedIn != "" {
|
|
fixedStr = dim("Fixed: ") + green(v.FixedIn)
|
|
}
|
|
|
|
fmt.Printf(
|
|
" %s %s %s %s\n",
|
|
sevColor(padRight(strings.ToUpper(v.Severity), 8)),
|
|
bold(padRight(id, 16)),
|
|
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 "CRITICAL":
|
|
return 0
|
|
case "HIGH":
|
|
return 1
|
|
case "MODERATE":
|
|
return 2
|
|
case "LOW":
|
|
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{
|
|
"CRITICAL", "HIGH", "MODERATE", "LOW", "UNKNOWN",
|
|
}
|
|
|
|
var parts []string
|
|
for _, sev := range order {
|
|
if n := counts[sev]; n > 0 {
|
|
parts = append(parts, fmt.Sprintf(
|
|
"%d %s", n, strings.ToLower(sev),
|
|
))
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// PrintSummary displays final counts after an update or scan operation
|
|
func PrintSummary(result types.ScanResult, updated bool) {
|
|
if updated {
|
|
fmt.Printf(" %s\n", greenBold("Updated pyproject.toml"))
|
|
}
|
|
fmt.Printf(
|
|
" %s packages checked\n",
|
|
bold(fmt.Sprintf("%d", result.TotalPackages)),
|
|
)
|
|
if result.TotalUpdated > 0 {
|
|
fmt.Printf(
|
|
" %s updated\n",
|
|
bold(fmt.Sprintf("%d", result.TotalUpdated)),
|
|
)
|
|
}
|
|
if result.VulnsScanned {
|
|
if result.TotalVulns > 0 {
|
|
fmt.Printf(
|
|
" %s\n",
|
|
red(fmt.Sprintf(
|
|
"%d %s found",
|
|
result.TotalVulns,
|
|
pluralize("vulnerability", result.TotalVulns),
|
|
)),
|
|
)
|
|
} else {
|
|
fmt.Printf(
|
|
" %s\n",
|
|
green("No vulnerabilities found"),
|
|
)
|
|
}
|
|
}
|
|
fmt.Printf(
|
|
" %s %s\n\n",
|
|
dim("Done in"),
|
|
dim(result.Duration.Round(1e6).String()),
|
|
)
|
|
}
|
|
|
|
// PrintError displays a user-friendly error message
|
|
func PrintError(msg string) {
|
|
fmt.Printf("\n %s %s\n\n", red("error:"), msg)
|
|
}
|
|
|
|
func changeColorFn(kind string) func(a ...any) string {
|
|
switch kind {
|
|
case pypi.Major.String():
|
|
return red
|
|
case pypi.Minor.String():
|
|
return yellow
|
|
default:
|
|
return green
|
|
}
|
|
}
|
|
|
|
func severityColorFn(sev string) func(a ...any) string {
|
|
switch strings.ToUpper(sev) {
|
|
case "CRITICAL":
|
|
return redBold
|
|
case "HIGH":
|
|
return red
|
|
case "MODERATE":
|
|
return yellow
|
|
case "LOW":
|
|
return cyan
|
|
default:
|
|
return dim
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|