Cybersecurity-Projects/PROJECTS/simple-vulnerability-scanner/internal/cli/output.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"
}