Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/cdxgen/cdxgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func GenerateFilesystemSBOM(ctx context.Context, dir string, o Options) ([]byte,
}

cmd := exec.CommandContext(ctx, bin, args...)
cmd.Env = licenseEnv()
if o.Logger != nil {
lw := output.LineWriter(o.Logger.Debug, "[cdxgen] ")
defer lw.Close()
Expand Down Expand Up @@ -90,6 +91,18 @@ func GenerateFilesystemSBOM(ctx context.Context, dir string, o Options) ([]byte,
return bom, nil
}

// licenseEnv enables cdxgen's registry license lookup (npm, PyPI, Go, Maven,
// ...) so every component carries its license. Without FETCH_LICENSE=true most
// ecosystems come back with no license at all. An explicit value set by the
// user (e.g. FETCH_LICENSE=false for offline runs) is respected.
func licenseEnv() []string {
env := os.Environ()
if _, set := os.LookupEnv("FETCH_LICENSE"); !set {
env = append(env, "FETCH_LICENSE=true")
}
return env
}

func resolveBin(explicit string) (string, error) {
if explicit != "" {
return explicit, nil
Expand Down Expand Up @@ -145,6 +158,7 @@ func GenerateImageSBOM(ctx context.Context, o Options) ([]byte, error) {
}

cmd := exec.CommandContext(ctx, bin, args...)
cmd.Env = licenseEnv()

if o.Logger != nil {
cmd.Stderr = output.LineWriter(o.Logger.Debug, "[cdxgen] ")
Expand Down
17 changes: 17 additions & 0 deletions internal/output/colors.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ func (c colors) sev(s string) string {
}
}

// license colours a license label by production risk: red for strong/network
// copyleft or non-commercial terms, yellow for weak copyleft.
func (c colors) license(label, risk string) string {
if label == "" {
return "-"
}
switch risk {
case "high":
return c.crit(label)
case "medium":
return c.med(label)
case "unknown":
return c.low(label)
}
return label
}

func (c colors) origin(label string) string {
switch label {
case "APP", "APP(T)":
Expand Down
30 changes: 20 additions & 10 deletions internal/output/fixplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,27 @@ func (f FixPlan) Render(w io.Writer, report any) error {
if v.Kind() != reflect.Struct {
return fmt.Errorf("fix-plan: unexpected report type %T", report)
}
c := newColors(!f.NoColor && os.Getenv("NO_COLOR") == "")
plan := indirectField(v, "FixPlan")
if !plan.IsValid() {
_, err := fmt.Fprintln(w, "No remediation plan available.")
return err
// No vulnerabilities to remediate, but license risks still need to be
// reported - they are findings of their own.
if _, err := fmt.Fprintln(w, "No remediation plan available."); err != nil {
return err
}
fmt.Fprintln(w)
renderLicenseRisks(w, c, fieldSlice(v, "Components"))
return nil
}

c := newColors(!f.NoColor && os.Getenv("NO_COLOR") == "")
fmt.Fprintln(w, c.bold("FIX PLAN"))
if source := stringField(v, "Source"); source != "" {
fmt.Fprintf(w, "%s %s\n", c.bold("Source:"), source)
}
fmt.Fprintln(w)

renderFixPlanFindings(w, c, v)
renderLicenseRisks(w, c, fieldSlice(v, "Components"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render license risks before returning for an empty fix plan

When a report contains risky licenses but no vulnerabilities, BuildFixPlan returns nil and the earlier !plan.IsValid() branch exits before this new call executes. Consequently, --format fix-plan reports only “No remediation plan available” and omits every license risk precisely for license-only findings; render the license section before that return or otherwise handle a nil remediation plan.

Useful? React with 👍 / 👎.


fmt.Fprintln(w, c.bold("REMEDIATION PLAN"))
fmt.Fprintln(w)
Expand Down Expand Up @@ -62,15 +69,16 @@ func (f FixPlan) Render(w io.Writer, report any) error {
}
fmt.Fprintf(w, " %s %d %s\n", c.bold("Fixes:"), fixCount, label)
fg := &grid{}
fg.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS")
fg.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS", "LICENSE")
for j := 0; j < packages.Len(); j++ {
pkg := packages.Index(j)
name := stringField(pkg, "Package")
vulns := fieldSlice(pkg, "Vulnerabilities")
for k := 0; k < vulns.Len(); k++ {
vuln := vulns.Index(k)
fg.add(c.high(name), coloredAdvisory(c, vuln), c.sev(stringField(vuln, "Severity")),
stringFieldValue(vuln, "EPSS"), c.green(stringField(vuln, "FixVersion")), vulnFlags(c, vuln))
stringFieldValue(vuln, "EPSS"), c.green(stringField(vuln, "FixVersion")), vulnFlags(c, vuln),
licenseCell(c, pkg))
}
}
fg.render(w)
Expand Down Expand Up @@ -107,14 +115,14 @@ func (f FixPlan) Render(w io.Writer, report any) error {
if unresolved.Len() > 0 {
fmt.Fprintln(w, c.bold("UNRESOLVED REMEDIATIONS"))
fg := &grid{}
fg.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS")
fg.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS", "LICENSE")
for i := 0; i < unresolved.Len(); i++ {
pkg := unresolved.Index(i)
vulns := fieldSlice(pkg, "Vulnerabilities")
for j := 0; j < vulns.Len(); j++ {
vuln := vulns.Index(j)
fg.add(c.high(stringField(pkg, "Package")), coloredAdvisory(c, vuln), c.sev(stringField(vuln, "Severity")),
stringFieldValue(vuln, "EPSS"), "", vulnFlags(c, vuln))
stringFieldValue(vuln, "EPSS"), "", vulnFlags(c, vuln), licenseCell(c, pkg))
}
}
fg.render(w)
Expand Down Expand Up @@ -155,6 +163,7 @@ type fixPlanFindingRow struct {
epss string
fix string
flags string
license string
rank int
}

Expand All @@ -176,7 +185,8 @@ func renderFixPlanFindings(w io.Writer, c colors, report reflect.Value) {
pkg: pkg, id: id, severity: severity,
epss: stringFieldValue(vuln, "EPSS"),
fix: firstStringField(vuln, "Fixed"), flags: vulnFlags(c, vuln),
rank: severityRank(severity),
license: licenseCell(c, component),
rank: severityRank(severity),
})
}
}
Expand All @@ -195,9 +205,9 @@ func renderFixPlanFindings(w io.Writer, c colors, report reflect.Value) {

fmt.Fprintf(w, "%s (%d)\n", c.bold("VULNERABILITIES"), len(rows))
grid := &grid{}
grid.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS")
grid.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS", "LICENSE")
for _, row := range rows {
grid.add(c.high(row.pkg), colorAdvisory(c, row.id, row.severity), c.sev(row.severity), row.epss, c.green(row.fix), row.flags)
grid.add(c.high(row.pkg), colorAdvisory(c, row.id, row.severity), c.sev(row.severity), row.epss, c.green(row.fix), row.flags, row.license)
}
grid.render(w)
fmt.Fprintln(w)
Expand Down
89 changes: 89 additions & 0 deletions internal/output/licenses.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package output

import (
"fmt"
"io"
"reflect"
"sort"
"strings"
)

const maxLicenseCellLen = 40

// licenseCell renders a component's license, coloured by production risk.
func licenseCell(c colors, comp reflect.Value) string {
label := stringField(comp, "License")
if len(label) > maxLicenseCellLen {
label = label[:maxLicenseCellLen-3] + "..."
}
return c.license(label, stringField(comp, "LicenseRisk"))
}

func licenseRiskLabel(c colors, risk string) string {
switch risk {
case "high":
return c.crit("HIGH")
case "medium":
return c.med("MEDIUM")
}
return risk
}

// renderLicenseRisks lists every component whose license is risky in
// production (strong/network copyleft, non-commercial = red; weak copyleft =
// yellow). Image scans hide clean components from Findings, so this section is
// the only place such packages are guaranteed to show up.
func renderLicenseRisks(w io.Writer, c colors, components reflect.Value) {
if !components.IsValid() || components.Kind() != reflect.Slice {
return
}
type row struct {
pkg, eco, license, risk string
rank int
}
var rows []row
seen := map[string]bool{}
for i := 0; i < components.Len(); i++ {
comp := components.Index(i)
risk := stringField(comp, "LicenseRisk")
rank := 0
switch risk {
case "high":
rank = 2
case "medium":
rank = 1
default:
continue
}
pkg := fmt.Sprintf("%s@%s", stringField(comp, "Name"), stringField(comp, "Version"))
if seen[pkg] {
continue
}
seen[pkg] = true
rows = append(rows, row{pkg: pkg, eco: strings.ToLower(stringField(comp, "System")),
license: stringField(comp, "License"), risk: risk, rank: rank})
}
if len(rows) == 0 {
return
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].rank != rows[j].rank {
return rows[i].rank > rows[j].rank
}
return rows[i].pkg < rows[j].pkg
})

fmt.Fprintln(w, c.bold("License risks")+c.low(" (red = copyleft / non-commercial, yellow = weak copyleft)"))
g := &grid{}
g.add("RISK", "PACKAGE", "ECO", "LICENSE")
const maxRows = 200
for i, r := range rows {
if i >= maxRows {
g.add(fmt.Sprintf("... (%d more - use --format json for the full list)", len(rows)-i))
break
}
g.add(licenseRiskLabel(c, r.risk), r.pkg, r.eco, c.license(r.license, r.risk))
}
g.render(w)
fmt.Fprintln(w)
}
142 changes: 142 additions & 0 deletions internal/output/licenses_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package output

import (
"bytes"
"encoding/json"
"strings"
"testing"
)

func licenseReport(source, layer string) *tReport {
return &tReport{
Source: source,
Totals: tTotals{Components: 3, Scanned: 3, WithVulns: 1, HIGH: 1, LicenseHigh: 1, LicenseMedium: 1},
Components: []tComponent{
{PURL: "pkg:npm/[email protected]", System: "NPM", Name: "gpl-lib", Version: "1.0.0", LayerDigest: layer,
License: "AGPL-3.0-only", LicenseRisk: "high"},
{PURL: "pkg:npm/[email protected]", System: "NPM", Name: "mpl-lib", Version: "2.0.0", LayerDigest: layer,
License: "MPL-2.0", LicenseRisk: "medium", TopSeverity: "HIGH", VulnCount: 1,
Vulnerabilities: []tVuln{{Severity: "HIGH", ID: "CVE-2024-1", CVE: "CVE-2024-1"}}},
{PURL: "pkg:npm/[email protected]", System: "NPM", Name: "mit-lib", Version: "3.0.0", LayerDigest: layer,
License: "MIT", LicenseRisk: "low"},
},
}
}

func TestTable_Render_LicenseColumnAndRisks(t *testing.T) {
for _, tc := range []struct{ name, source, layer string }{
{"sbom", "sbom:app.cdx.json", ""},
{"image", "image:app:latest", "sha256:layer0"},
} {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
if err := (Table{NoColor: true}).Render(&buf, licenseReport(tc.source, tc.layer)); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, want := range []string{"LICENSE", "MPL-2.0", "Risky licenses", "Weak-copyleft licenses", "License risks"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q:\n%s", want, out)
}
}
// Image scans hide clean components from Findings, but the GPL lib
// must still surface in the License risks section.
risks := out[strings.Index(out, "License risks"):]
if !strings.Contains(risks, "[email protected]") || !strings.Contains(risks, "AGPL-3.0-only") {
t.Errorf("high-risk license missing from License risks:\n%s", risks)
}
if strings.Contains(risks, "mit-lib") {
t.Errorf("permissive license must not be listed as a risk:\n%s", risks)
}
})
}
}

func TestTable_Render_HighRiskLicenseIsRed(t *testing.T) {
t.Setenv("NO_COLOR", "")
var buf bytes.Buffer
if err := (Table{}).Render(&buf, licenseReport("sbom:app.cdx.json", "")); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "\x1b[1;31mAGPL-3.0-only\x1b[0m") {
t.Errorf("high-risk license should be bold red:\n%q", out)
}
if !strings.Contains(out, "\x1b[33mMPL-2.0\x1b[0m") {
t.Errorf("weak-copyleft license should be yellow:\n%q", out)
}
if strings.Contains(out, "\x1b[1;31mMIT\x1b[0m") {
t.Errorf("permissive license must not be red:\n%q", out)
}
}

func TestSARIF_Render_Licenses(t *testing.T) {
var buf bytes.Buffer
if err := (SARIF{}).Render(&buf, licenseReport("sbom:app.cdx.json", "")); err != nil {
t.Fatal(err)
}
var doc sarifDoc
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Fatalf("SARIF not valid JSON: %v", err)
}
levels := map[string]string{}
var vulnLicense any
for _, res := range doc.Runs[0].Results {
levels[res.RuleID] = res.Level
if res.RuleID == "CVE-2024-1" {
vulnLicense = res.Properties["license"]
}
}
if levels["WOLFEE-LICENSE-HIGH-RISK"] != "error" {
t.Errorf("high-risk license result missing or not error: %v", levels)
}
if levels["WOLFEE-LICENSE-WEAK-COPYLEFT"] != "warning" {
t.Errorf("weak-copyleft license result missing or not warning: %v", levels)
}
if vulnLicense != "MPL-2.0" {
t.Errorf("vulnerability result must carry the package license, got %v", vulnLicense)
}
for id := range levels {
if strings.Contains(id, "LICENSE") && strings.Contains(buf.String(), "mit-lib is licensed") {
t.Errorf("permissive license must not produce a SARIF result")
}
}
}

func TestFixPlan_Render_LicenseRisksWithoutPlan(t *testing.T) {
type report struct {
Source string
FixPlan *tFixPlan
Components []tComponent
}
r := report{Components: []tComponent{
{Name: "gpl-lib", Version: "1.0.0", System: "NPM", License: "GPL-3.0-only", LicenseRisk: "high"},
}}
var buf bytes.Buffer
if err := (FixPlan{NoColor: true}).Render(&buf, r); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "No remediation plan available.") {
t.Errorf("expected the no-plan notice:\n%s", out)
}
if !strings.Contains(out, "License risks") || !strings.Contains(out, "[email protected]") {
t.Errorf("license risks must be rendered even without a remediation plan:\n%s", out)
}
}

func TestFixPlan_Render_License(t *testing.T) {
t.Setenv("NO_COLOR", "")
var buf bytes.Buffer
report := tFixPlanReport{FixPlan: &tFixPlan{Groups: []tFixGroup{{
Direct: "express", CurrentVersion: "4.18.2", FixVersion: "4.21.2",
Packages: []tFixPackage{{Package: "[email protected]", License: "GPL-3.0", LicenseRisk: "high",
Vulnerabilities: []tFixVulnerability{{CVE: "CVE-1", Severity: "HIGH"}}}},
}}}}
if err := (FixPlan{}).Render(&buf, report); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "LICENSE") || !strings.Contains(buf.String(), "\x1b[1;31mGPL-3.0\x1b[0m") {
t.Errorf("fix-plan must show the license in red:\n%q", buf.String())
}
}
Loading
Loading