package table
import (
"bytes"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/samber/lo"
"github.com/xlab/treeprint"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"github.com/aquasecurity/table"
"github.com/aquasecurity/tml"
dbTypes "github.com/aquasecurity/trivy-db/pkg/types"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/types"
)
var showSuppressedOnce = sync.OnceFunc(func() {
log.Info(`Some vulnerabilities have been ignored/suppressed. Use the "--show-suppressed" flag to display them.`)
})
type vulnerabilityRenderer struct {
w *bytes.Buffer
result types.Result
isTerminal bool
tree bool // Show dependency tree
showSuppressed bool // Show suppressed vulnerabilities
severities []dbTypes.Severity
once *sync.Once
}
func NewVulnerabilityRenderer(result types.Result, isTerminal, tree, suppressed bool, severities []dbTypes.Severity) *vulnerabilityRenderer {
buf := bytes.NewBuffer([]byte{})
if !isTerminal {
tml.DisableFormatting()
}
return &vulnerabilityRenderer{
w: buf,
result: result,
isTerminal: isTerminal,
tree: tree,
showSuppressed: suppressed,
severities: severities,
once: new(sync.Once),
}
}
func (r *vulnerabilityRenderer) Render() string {
// There are 3 cases when we show the vulnerability table (or only target and `Total: 0...`):
// When Result contains vulnerabilities;
// When Result target is OS packages even if no vulnerabilities are found;
// When we show non-empty `Suppressed Vulnerabilities` table.
if len(r.result.Vulnerabilities) > 0 || r.result.Class == types.ClassOSPkg || (r.showSuppressed && len(r.result.ModifiedFindings) > 0) {
r.renderDetectedVulnerabilities()
if r.tree {
r.renderDependencyTree()
}
}
if r.showSuppressed {
r.renderModifiedVulnerabilities()
} else if len(r.result.ModifiedFindings) > 0 {
showSuppressedOnce()
}
return r.w.String()
}
func (r *vulnerabilityRenderer) renderDetectedVulnerabilities() {
tw := newTableWriter(r.w, r.isTerminal)
r.setHeaders(tw)
r.setVulnerabilityRows(tw, r.result.Vulnerabilities)
severityCount := r.countSeverities(r.result.Vulnerabilities)
total, summaries := summarize(r.severities, severityCount)
target := r.result.Target
if r.result.Class == types.ClassLangPkg {
target += fmt.Sprintf(" (%s)", r.result.Type)
}
RenderTarget(r.w, target, r.isTerminal)
r.printf("Total: %d (%s)\n\n", total, strings.Join(summaries, ", "))
tw.Render()
}
func (r *vulnerabilityRenderer) setHeaders(tw *table.Table) {
if len(r.result.Vulnerabilities) == 0 {
return
}
header := []string{
"Library",
"Vulnerability",
"Severity",
"Status",
"Installed Version",
"Fixed Version",
"Title",
}
tw.SetHeaders(header...)
}
func (r *vulnerabilityRenderer) setVulnerabilityRows(tw *table.Table, vulns []types.DetectedVulnerability) {
for _, v := range vulns {
lib := v.PkgName
if v.PkgPath != "" {
// get path to root jar
// for other languages return unchanged path
pkgPath := rootJarFromPath(v.PkgPath)
fileName := filepath.Base(pkgPath)
lib = fmt.Sprintf("%s (%s)", v.PkgName, fileName)
r.once.Do(func() {
log.Info("Table result includes only package filenames. Use '--format json' option to get the full path to the package file.")
})
}
title := v.Title
if title == "" {
title = v.Description
}
splitTitle := strings.Split(title, " ")
if len(splitTitle) >= 12 {
title = strings.Join(splitTitle[:12], " ") + "..."
}
if v.PrimaryURL != "" {
if r.isTerminal {
title = tml.Sprintf("%s\n%s", title, v.PrimaryURL)
} else {
title = fmt.Sprintf("%s\n%s", title, v.PrimaryURL)
}
}
var row []string
if r.isTerminal {
row = []string{
lib,
v.VulnerabilityID,
ColorizeSeverity(v.Severity, v.Severity),
v.Status.String(),
v.InstalledVersion,
v.FixedVersion,
strings.TrimSpace(title),
}
} else {
row = []string{
lib,
v.VulnerabilityID,
v.Severity,
v.Status.String(),
v.InstalledVersion,
v.FixedVersion,
strings.TrimSpace(title),
}
}
tw.AddRow(row...)
}
}
func (r *vulnerabilityRenderer) countSeverities(vulns []types.DetectedVulnerability) map[string]int {
severityCount := make(map[string]int)
for _, v := range vulns {
severityCount[v.Severity]++
}
return severityCount
}
func (r *vulnerabilityRenderer) renderModifiedVulnerabilities() {
tw := newTableWriter(r.w, r.isTerminal)
header := []string{
"Library",
"Vulnerability",
"Severity",
"Status",
"Statement",
"Source",
}
tw.SetHeaders(header...)
var total int
for _, m := range r.result.ModifiedFindings {
if m.Type != types.FindingTypeVulnerability {
continue
}
vuln := m.Finding.(types.DetectedVulnerability)
total++
stmt := lo.Ternary(m.Statement != "", m.Statement, "N/A")
tw.AddRow(vuln.PkgName, vuln.VulnerabilityID, vuln.Severity, string(m.Status), stmt, m.Source)
}
if total == 0 {
return
}
title := fmt.Sprintf("Suppressed Vulnerabilities (Total: %d)", total)
if r.isTerminal {
// nolint
_ = tml.Fprintf(r.w, "\n%s\n\n", title)
} else {
_, _ = fmt.Fprintf(r.w, "\n%s\n", title)
_, _ = fmt.Fprintf(r.w, "%s\n", strings.Repeat("=", len(title)))
}
tw.Render()
}
func (r *vulnerabilityRenderer) renderDependencyTree() {
// Get parents of each dependency
parents := ftypes.Packages(r.result.Packages).ParentDeps()
if len(parents) == 0 {
return
}
ancestors := traverseAncestors(r.result.Packages, parents)
root := treeprint.NewWithRoot(fmt.Sprintf(`
Dependency Origin Tree (Reversed)
=================================
%s`, r.result.Target))
// This count is next to the package ID.
// e.g. node-fetch@1.7.3 (MEDIUM: 2, HIGH: 1, CRITICAL: 3)
pkgSeverityCount := make(map[string]map[string]int)
for _, vuln := range r.result.Vulnerabilities {
cnts, ok := pkgSeverityCount[vuln.PkgID]
if !ok {
cnts = make(map[string]int)
}
cnts[vuln.Severity]++
pkgSeverityCount[vuln.PkgID] = cnts
}
// Extract vulnerable packages
vulnPkgs := lo.Filter(r.result.Packages, func(pkg ftypes.Package, _ int) bool {
return lo.ContainsBy(r.result.Vulnerabilities, func(vuln types.DetectedVulnerability) bool {
return pkg.ID == vuln.PkgID
})
})
// Render tree
for _, vulnPkg := range vulnPkgs {
_, summaries := summarize(r.severities, pkgSeverityCount[vulnPkg.ID])
topLvlID := tml.Sprintf("%s, (%s)", vulnPkg.ID, strings.Join(summaries, ", "))
branch := root.AddBranch(topLvlID)
addParents(branch, vulnPkg, parents, ancestors, map[string]struct{}{vulnPkg.ID: {}}, 1)
}
r.printf(root.String())
}
func (r *vulnerabilityRenderer) printf(format string, args ...any) {
// nolint
_ = tml.Fprintf(r.w, format, args...)
}
func addParents(topItem treeprint.Tree, pkg ftypes.Package, parentMap map[string]ftypes.Packages, ancestors map[string][]string,
seen map[string]struct{}, depth int) {
if pkg.Relationship == ftypes.RelationshipDirect {
return
}
roots := make(map[string]struct{})
for _, parent := range parentMap[pkg.ID] {
if _, ok := seen[parent.ID]; ok {
continue
}
seen[parent.ID] = struct{}{} // to avoid infinite loops
if depth == 1 && parent.Relationship == ftypes.RelationshipDirect {
topItem.AddBranch(parent.ID)
} else {
// We omit intermediate dependencies and show only direct dependencies
// as this could make the dependency tree huge.
for _, ancestor := range ancestors[parent.ID] {
roots[ancestor] = struct{}{}
}
}
}
// Omitted
rootIDs := lo.Filter(maps.Keys(roots), func(pkgID string, _ int) bool {
_, ok := seen[pkgID]
return !ok
})
sort.Strings(rootIDs)
if len(rootIDs) > 0 {
branch := topItem.AddBranch("...(omitted)...")
for _, rootID := range rootIDs {
branch.AddBranch(rootID)
}
}
}
func traverseAncestors(pkgs []ftypes.Package, parentMap map[string]ftypes.Packages) map[string][]string {
ancestors := make(map[string][]string)
for _, pkg := range pkgs {
ancestors[pkg.ID] = findAncestor(pkg.ID, parentMap, make(map[string]struct{}))
}
return ancestors
}
func findAncestor(pkgID string, parentMap map[string]ftypes.Packages, seen map[string]struct{}) []string {
ancestors := make(map[string]struct{})
seen[pkgID] = struct{}{}
for _, parent := range parentMap[pkgID] {
if _, ok := seen[parent.ID]; ok {
continue
}
switch {
case parent.Relationship == ftypes.RelationshipDirect:
ancestors[parent.ID] = struct{}{}
case len(parentMap[parent.ID]) == 0:
// Some package managers, such as "package-lock.json" v1, can retrieve package dependencies but not relationships.
// We try to guess direct dependencies in this case. A dependency with no parents must be a direct dependency.
//
// e.g.
// -> styled-components
// -> fbjs
// -> isomorphic-fetch
// -> node-fetch
//
// Even if `styled-components` is not marked as a direct dependency, it must be a direct dependency
// as it has no parents. Note that it doesn't mean `fbjs` is an indirect dependency.
ancestors[parent.ID] = struct{}{}
default:
for _, a := range findAncestor(parent.ID, parentMap, seen) {
ancestors[a] = struct{}{}
}
}
}
return maps.Keys(ancestors)
}
var jarExtensions = []string{
".jar",
".war",
".par",
".ear",
}
func rootJarFromPath(path string) string {
// File paths are always forward-slashed in Trivy
paths := strings.Split(path, "/")
for i, p := range paths {
if slices.Contains(jarExtensions, filepath.Ext(p)) {
return strings.Join(paths[:i+1], "/")
}
}
return path
}