From 6b88202a052b759c9cf8b927c5bca32f684cc1e8 Mon Sep 17 00:00:00 2001 From: anton Date: Sun, 13 Sep 2026 19:03:41 +0300 Subject: [PATCH 1/2] add license all --- internal/cdxgen/cdxgen.go | 14 ++ internal/output/colors.go | 17 +++ internal/output/fixplan.go | 18 ++- internal/output/licenses.go | 89 +++++++++++ internal/output/licenses_test.go | 120 +++++++++++++++ internal/output/output_test.go | 5 + internal/output/sarif.go | 54 ++++++- internal/output/table.go | 14 +- internal/sbomscan/compare.go | 6 + internal/sbomscan/fixplan.go | 4 + internal/sbomscan/image.go | 23 +++ internal/sbomscan/licenses.go | 195 +++++++++++++++++++++++++ internal/sbomscan/licenses_test.go | 73 +++++++++ internal/sbomscan/reachability_join.go | 4 + internal/sbomscan/report_types.go | 6 + internal/sbomscan/sbomscan.go | 2 + internal/trivy/trivy.go | 1 + 17 files changed, 634 insertions(+), 11 deletions(-) create mode 100644 internal/output/licenses.go create mode 100644 internal/output/licenses_test.go create mode 100644 internal/sbomscan/licenses.go create mode 100644 internal/sbomscan/licenses_test.go diff --git a/internal/cdxgen/cdxgen.go b/internal/cdxgen/cdxgen.go index f591c1b..3d7c9ba 100644 --- a/internal/cdxgen/cdxgen.go +++ b/internal/cdxgen/cdxgen.go @@ -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() @@ -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 @@ -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] ") diff --git a/internal/output/colors.go b/internal/output/colors.go index d76385b..25c3344 100644 --- a/internal/output/colors.go +++ b/internal/output/colors.go @@ -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)": diff --git a/internal/output/fixplan.go b/internal/output/fixplan.go index 1159b0b..9b52998 100644 --- a/internal/output/fixplan.go +++ b/internal/output/fixplan.go @@ -34,6 +34,7 @@ func (f FixPlan) Render(w io.Writer, report any) error { fmt.Fprintln(w) renderFixPlanFindings(w, c, v) + renderLicenseRisks(w, c, fieldSlice(v, "Components")) fmt.Fprintln(w, c.bold("REMEDIATION PLAN")) fmt.Fprintln(w) @@ -62,7 +63,7 @@ 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") @@ -70,7 +71,8 @@ func (f FixPlan) Render(w io.Writer, report any) error { 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) @@ -107,14 +109,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) @@ -155,6 +157,7 @@ type fixPlanFindingRow struct { epss string fix string flags string + license string rank int } @@ -176,7 +179,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), }) } } @@ -195,9 +199,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) diff --git a/internal/output/licenses.go b/internal/output/licenses.go new file mode 100644 index 0000000..0d805a6 --- /dev/null +++ b/internal/output/licenses.go @@ -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) +} diff --git a/internal/output/licenses_test.go b/internal/output/licenses_test.go new file mode 100644 index 0000000..d24c241 --- /dev/null +++ b/internal/output/licenses_test.go @@ -0,0 +1,120 @@ +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/gpl-lib@1.0.0", System: "NPM", Name: "gpl-lib", Version: "1.0.0", LayerDigest: layer, + License: "AGPL-3.0-only", LicenseRisk: "high"}, + {PURL: "pkg:npm/mpl-lib@2.0.0", 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/mit-lib@3.0.0", 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, "gpl-lib@1.0.0") || !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_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: "qs@6.11.0", 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()) + } +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 0ef333b..10da301 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -31,6 +31,8 @@ type tFixGroup struct { } type tFixPackage struct { Package string + License string + LicenseRisk string Vulnerabilities []tFixVulnerability DependencyPaths [][]string } @@ -42,10 +44,13 @@ type tFixVulnerability struct { } type tTotals struct { Components, Scanned, Skipped, WithVulns, Malware, Toxic int + LicenseHigh, LicenseMedium int CRITICAL, HIGH, MEDIUM, LOW int } type tComponent struct { PURL string + License string + LicenseRisk string System string Name string Version string diff --git a/internal/output/sarif.go b/internal/output/sarif.go index 10af6cf..28c6c8e 100644 --- a/internal/output/sarif.go +++ b/internal/output/sarif.go @@ -118,11 +118,30 @@ func (SARIF) Render(w io.Writer, report any) error { pkgLabel := fmt.Sprintf("%s/%s@%s", pkgEco, pkgName, pkgVer) origin := stringField(c, "Origin") + license := stringField(c, "License") + licenseRisk := stringField(c, "LicenseRisk") packageProps := func() map[string]any { - if origin == "" { + props := map[string]any{} + if origin != "" { + props["origin"] = origin + } + if license != "" { + props["license"] = license + } + if licenseRisk != "" { + props["licenseRisk"] = licenseRisk + } + if len(props) == 0 { return nil } - return map[string]any{"origin": origin} + return props + } + + if res, rule, ok := licenseResult(pkgLabel, license, licenseRisk, packageProps()); ok { + if _, seen := rulesByID[rule.ID]; !seen { + rulesByID[rule.ID] = rule + } + results = append(results, res) } if mal := c.FieldByName("Malware"); mal.IsValid() && mal.FieldByName("Found").Bool() { @@ -194,6 +213,37 @@ func (SARIF) Render(w io.Writer, report any) error { return encodeJSON(w, doc) } +// licenseResult emits a compliance finding for a component whose license is +// risky in production: error for strong/network copyleft or non-commercial +// terms, warning for weak copyleft. +func licenseResult(pkgLabel, license, risk string, props map[string]any) (sarifResult, sarifRule, bool) { + var id, level, desc string + switch risk { + case "high": + id, level = "WOLFEE-LICENSE-HIGH-RISK", "error" + desc = "Package uses a strong/network copyleft, source-available or non-commercial license" + case "medium": + id, level = "WOLFEE-LICENSE-WEAK-COPYLEFT", "warning" + desc = "Package uses a weak copyleft license" + default: + return sarifResult{}, sarifRule{}, false + } + rule := sarifRule{ + ID: id, + Name: "LicenseRisk", + ShortDescription: sarifMessage{Text: desc}, + Properties: map[string]any{"tags": []string{"license", "compliance"}}, + } + res := sarifResult{ + RuleID: id, + Level: level, + Message: sarifMessage{Text: fmt.Sprintf("%s is licensed under %s (license risk: %s)", pkgLabel, license, risk)}, + Locations: []sarifLocation{{LogicalLocations: []sarifLogicalLocation{{Name: pkgLabel, Kind: "package"}}}}, + Properties: props, + } + return res, rule, true +} + func resultProps(v reflect.Value) map[string]any { props := map[string]any{} if reachable := stringField(v, "Reachable"); reachable != "" { diff --git a/internal/output/table.go b/internal/output/table.go index f347375..e10f835 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -83,6 +83,12 @@ func (t Table) Render(w io.Writer, report any) error { if tox := intField(totals, "Toxic"); tox > 0 { fmt.Fprintf(tw, " Toxic packages\t%d\n", tox) } + if n := intField(totals, "LicenseHigh"); n > 0 { + fmt.Fprintf(tw, " Risky licenses (copyleft / non-commercial)\t%s\n", c.crit(fmt.Sprintf("%d", n))) + } + if n := intField(totals, "LicenseMedium"); n > 0 { + fmt.Fprintf(tw, " Weak-copyleft licenses\t%s\n", c.med(fmt.Sprintf("%d", n))) + } if kev := intField(totals, "KEV"); kev > 0 { fmt.Fprintf(tw, " In CISA KEV\t%s\n", c.high(fmt.Sprintf("%d", kev))) } @@ -134,6 +140,7 @@ func (t Table) Render(w io.Writer, report any) error { fmt.Fprintln(w, c.green("✓ No vulnerabilities or malware detected")) fmt.Fprintln(w) if !showAll { + renderLicenseRisks(w, c, components) return nil } } @@ -146,7 +153,7 @@ func (t Table) Render(w io.Writer, report any) error { showLang := anyLanguageRelevance(components) fg := &grid{} if showLayer { - header := []string{"PACKAGE", "VERSION", "ECO", "LAYER", "VULNS", "FLAGS", "TOXIC", "ORIGIN"} + header := []string{"PACKAGE", "VERSION", "ECO", "LAYER", "VULNS", "FLAGS", "TOXIC", "LICENSE", "ORIGIN"} if showLang { header = append(header, "LANG") } @@ -154,7 +161,7 @@ func (t Table) Render(w io.Writer, report any) error { } else { // Non-image scans (SBOM / reachable): ORIGIN tells direct vs transitive, // LANG the ecosystem language. Both are always shown here. - fg.add("PACKAGE", "VERSION", "ECO", "VULNS", "FLAGS", "TOXIC", "ORIGIN", "LANG") + fg.add("PACKAGE", "VERSION", "ECO", "VULNS", "FLAGS", "TOXIC", "LICENSE", "ORIGIN", "LANG") } // Vulnerable-first ordering is preserved by the earlier sort; in showAll // mode the clean components simply trail after the affected ones. @@ -225,6 +232,7 @@ func (t Table) Render(w io.Writer, report any) error { fmt.Sprintf("%d", intField(comp, "VulnCount")), strings.Join(flags, " "), toxic, + licenseCell(c, comp), c.origin(originLabel(stringField(comp, "System"), stringField(comp, "Origin"), transitive)), } if showLang { @@ -239,6 +247,7 @@ func (t Table) Render(w io.Writer, report any) error { fmt.Sprintf("%d", intField(comp, "VulnCount")), strings.Join(flags, " "), toxic, + licenseCell(c, comp), depScopeCell(c, comp, transitive), langCell(c, comp), } @@ -344,6 +353,7 @@ func (t Table) Render(w io.Writer, report any) error { } renderDependencyPaths(w, c, components) + renderLicenseRisks(w, c, components) return nil } diff --git a/internal/sbomscan/compare.go b/internal/sbomscan/compare.go index f50efc3..47d40ad 100644 --- a/internal/sbomscan/compare.go +++ b/internal/sbomscan/compare.go @@ -121,6 +121,10 @@ func MergeSourceVulns(image, source *Report, reach *reachability.Result) { ic.Scope = sc.Scope } + // cdxgen's license data for the source tree is richer than trivy's. + if len(sc.Licenses) > 0 { + ic.Licenses = sc.Licenses + } if len(ic.DependencyPaths) == 0 { ic.DependencyPaths = sc.DependencyPaths } @@ -140,7 +144,9 @@ func MergeSourceVulns(image, source *Report, reach *reachability.Result) { } markImageLibs(image.Components) filterVulnsByVersion(image.Components) + annotateLicenses(image.Components) computeImageTotals(image, reach, true) + countLicenseTotals(image) } func unionVulns(into, extra []onlinescan.Vulnerability) []onlinescan.Vulnerability { diff --git a/internal/sbomscan/fixplan.go b/internal/sbomscan/fixplan.go index 31f2544..425ffc8 100644 --- a/internal/sbomscan/fixplan.go +++ b/internal/sbomscan/fixplan.go @@ -30,6 +30,8 @@ type FixPlanGroup struct { type FixPlanPackage struct { Package string `json:"package"` PURL string `json:"purl,omitempty"` + License string `json:"license,omitempty"` + LicenseRisk string `json:"licenseRisk,omitempty"` Vulnerabilities []FixPlanVulnerability `json:"vulnerabilities"` DependencyPaths [][]string `json:"dependencyPaths,omitempty"` DependencyPathsTruncated bool `json:"dependencyPathsTruncated,omitempty"` @@ -98,6 +100,7 @@ func BuildFixPlan(r *Report) *FixPlan { pkg := groupPackages[groupKey][pkgKey] if pkg == nil { pkg = &FixPlanPackage{Package: pkgLabel(c.Name, c.Version), PURL: c.PURL, + License: c.License, LicenseRisk: c.LicenseRisk, DependencyPaths: c.DependencyPaths, DependencyPathsTruncated: c.DependencyPathsTruncated} groupPackages[groupKey][pkgKey] = pkg group.Packages = append(group.Packages, *pkg) @@ -121,6 +124,7 @@ func BuildFixPlan(r *Report) *FixPlan { pkg := unresolved[pkgKey] if pkg == nil { pkg = &FixPlanPackage{Package: pkgLabel(c.Name, c.Version), PURL: c.PURL, + License: c.License, LicenseRisk: c.LicenseRisk, DependencyPaths: c.DependencyPaths, DependencyPathsTruncated: c.DependencyPathsTruncated} unresolved[pkgKey] = pkg } diff --git a/internal/sbomscan/image.go b/internal/sbomscan/image.go index 4440323..cd05633 100644 --- a/internal/sbomscan/image.go +++ b/internal/sbomscan/image.go @@ -209,10 +209,31 @@ func buildImageReport(source string, ros *ReportOS, tr *trivy.Report, results [] target = source } + licensesByKey := map[string][]string{} + for _, res := range tr.Results { + for _, p := range res.Packages { + if len(p.Licenses) == 0 { + continue + } + k := p.Identifier.PURL + if k == "" { + k = p.Name + "@" + p.Version + } + if _, ok := licensesByKey[k]; !ok { + licensesByKey[k] = p.Licenses + } + } + } + for _, res := range results { vulns := dedupeVulns(res.Vulnerabilities) topSev, vc := topAndCount(vulns) + licKey := res.PURL + if licKey == "" { + licKey = res.Name + "@" + res.Version + } cr := ComponentReport{ + Licenses: trivyLicenses(licensesByKey[licKey]), PURL: res.PURL, System: res.System, Name: res.Name, @@ -253,7 +274,9 @@ func buildImageReport(source string, ros *ReportOS, tr *trivy.Report, results [] } markImageLibs(r.Components) filterVulnsByVersion(r.Components) + annotateLicenses(r.Components) computeImageTotals(r, reach, sourceLibs != nil) + countLicenseTotals(r) return r } diff --git a/internal/sbomscan/licenses.go b/internal/sbomscan/licenses.go new file mode 100644 index 0000000..8572968 --- /dev/null +++ b/internal/sbomscan/licenses.go @@ -0,0 +1,195 @@ +package sbomscan + +import ( + "regexp" + "strings" +) + +// License risk levels for running a component in production / shipping it. +const ( + // LicenseRiskHigh: strong or network copyleft, source-available or + // non-commercial terms (GPL, AGPL, SSPL, BUSL, CC-BY-NC, ...). + LicenseRiskHigh = "high" + // LicenseRiskMedium: weak / file-level copyleft (LGPL, MPL, EPL, CDDL, ...). + LicenseRiskMedium = "medium" + // LicenseRiskLow: permissive (MIT, Apache-2.0, BSD, ISC, ...). + LicenseRiskLow = "low" + // LicenseRiskUnknown: a license is declared but not recognised. + LicenseRiskUnknown = "unknown" +) + +func licenseRiskRank(r string) int { + switch r { + case LicenseRiskHigh: + return 3 + case LicenseRiskMedium: + return 2 + case LicenseRiskUnknown: + return 1 + case LicenseRiskLow: + return 0 + } + return -1 +} + +// annotateLicenses fills the flat License / LicenseRisk fields from the +// structured cdxgen (or trivy) license list on every component. +func annotateLicenses(components []ComponentReport) { + for i := range components { + components[i].License, components[i].LicenseRisk = summarizeLicenses(components[i].Licenses) + } +} + +func countLicenseTotals(r *Report) { + r.Totals.LicenseHigh, r.Totals.LicenseMedium = 0, 0 + for _, c := range r.Components { + switch c.LicenseRisk { + case LicenseRiskHigh: + r.Totals.LicenseHigh++ + case LicenseRiskMedium: + r.Totals.LicenseMedium++ + } + } +} + +// summarizeLicenses returns a display string ("MIT, GPL-2.0-only") and the +// worst risk across the declared entries. Several entries in a CycloneDX +// licenses array are treated conservatively (all apply); an explicit SPDX +// "A OR B" expression picks the least risky alternative. +func summarizeLicenses(ls []LicenseChoice) (display, risk string) { + var parts []string + seen := map[string]bool{} + for _, l := range ls { + label := licenseChoiceLabel(l) + if label == "" || seen[strings.ToLower(label)] { + continue + } + seen[strings.ToLower(label)] = true + parts = append(parts, label) + if r := classifyLicenseExpression(label); licenseRiskRank(r) > licenseRiskRank(risk) { + risk = r + } + } + return strings.Join(parts, ", "), risk +} + +func licenseChoiceLabel(l LicenseChoice) string { + if e := strings.TrimSpace(l.Expression); e != "" { + return e + } + if l.License == nil { + return "" + } + if id := strings.TrimSpace(l.License.ID); id != "" { + return id + } + return strings.TrimSpace(l.License.Name) +} + +var ( + reOr = regexp.MustCompile(`(?i)\s+or\s+|\s*/\s*`) + reAnd = regexp.MustCompile(`(?i)\s+and\s+`) + reWith = regexp.MustCompile(`(?i)\s+with\s+`) +) + +// classifyLicenseExpression handles SPDX expressions: OR takes the least risky +// alternative, AND the most risky term. +func classifyLicenseExpression(expr string) string { + expr = strings.NewReplacer("(", " ", ")", " ").Replace(expr) + best := "" + for _, alt := range reOr.Split(expr, -1) { + worst := "" + for _, term := range reAnd.Split(alt, -1) { + term = strings.TrimSpace(term) + if term == "" { + continue + } + if r := classifyLicenseTerm(term); licenseRiskRank(r) > licenseRiskRank(worst) { + worst = r + } + } + if worst == "" { + continue + } + if best == "" || licenseRiskRank(worst) < licenseRiskRank(best) { + best = worst + } + } + return best +} + +func classifyLicenseTerm(term string) string { + base, exception := term, "" + if parts := reWith.Split(term, 2); len(parts) == 2 { + base, exception = parts[0], parts[1] + } + risk := classifyLicenseID(base) + // GPL + linking exception (Classpath, GCC runtime, ...) behaves like weak copyleft. + if risk == LicenseRiskHigh && exception != "" { + return LicenseRiskMedium + } + return risk +} + +func classifyLicenseID(raw string) string { + id := strings.ToUpper(strings.TrimSpace(raw)) + if id == "" { + return "" + } + has := func(subs ...string) bool { + for _, s := range subs { + if strings.Contains(id, s) { + return true + } + } + return false + } + pfx := func(prefixes ...string) bool { + for _, p := range prefixes { + if strings.HasPrefix(id, p) { + return true + } + } + return false + } + + switch { + // Weak copyleft first: "LGPL" contains "GPL", "LESSER GENERAL PUBLIC" contains "GENERAL PUBLIC". + case pfx("LGPL", "MPL", "EPL", "CDDL", "CPL-", "EUPL", "CC-BY-SA", "MS-RL", "APSL", "ERPL", "NPL-"), + has("LESSER GENERAL PUBLIC", "LIBRARY GENERAL PUBLIC", "MOZILLA PUBLIC", "ECLIPSE PUBLIC", + "COMMON DEVELOPMENT AND DISTRIBUTION", "EUROPEAN UNION PUBLIC"): + return LicenseRiskMedium + + case pfx("AGPL", "GPL", "SSPL", "OSL-", "RPL-", "CPAL", "SISSL", "BUSL", "ELASTIC-", "CC-BY-NC", + "SLEEPYCAT", "QPL", "WATCOM", "PARITY", "POLYFORM", "CONFLUENT-COMMUNITY"), + has("AFFERO", "GENERAL PUBLIC LICENSE", "SERVER SIDE PUBLIC", "BUSINESS SOURCE", "COMMONS CLAUSE", + "COMMONS-CLAUSE", "NON-COMMERCIAL", "NONCOMMERCIAL", "PROPRIETARY", "COMMERCIAL"), + id == "GPL" || id == "AGPL": + return LicenseRiskHigh + + case pfx("MIT", "APACHE", "BSD", "0BSD", "ISC", "UNLICENSE", "CC0", "ZLIB", "PYTHON", "PSF", + "ARTISTIC-2", "BLUEOAK", "WTFPL", "X11", "CURL", "OPENSSL", "BSL-1.0", "UPL", "HPND", "PUBLIC-DOMAIN", + "PUBLIC DOMAIN", "CC-BY-", "BOOST", "UNICODE", "W3C", "OFL", "POSTGRESQL", "VIM", "RUBY", "PHP-", "NCSA", + "ICU", "JSON", "MIT-0", "BZIP2", "LIBPNG", "IJG", "SSLEAY", "FTL", "TCL", "ZPL", "AFL", "EFL", "ECL", "NTP"), + has("APACHE LICENSE", "BSD LICENSE", "MIT LICENSE"): + return LicenseRiskLow + } + return LicenseRiskUnknown +} + +// trivyLicenses converts trivy's flat license strings into CycloneDX choices. +func trivyLicenses(names []string) []LicenseChoice { + var out []LicenseChoice + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + l := &License{Name: n} + if !strings.ContainsAny(n, " \t") { + l = &License{ID: n} + } + out = append(out, LicenseChoice{License: l}) + } + return out +} diff --git a/internal/sbomscan/licenses_test.go b/internal/sbomscan/licenses_test.go new file mode 100644 index 0000000..168454f --- /dev/null +++ b/internal/sbomscan/licenses_test.go @@ -0,0 +1,73 @@ +package sbomscan + +import "testing" + +func TestClassifyLicenseExpression(t *testing.T) { + cases := []struct { + in, want string + }{ + {"MIT", LicenseRiskLow}, + {"Apache-2.0", LicenseRiskLow}, + {"BSD-3-Clause", LicenseRiskLow}, + {"BSL-1.0", LicenseRiskLow}, + {"ISC", LicenseRiskLow}, + {"GPL-3.0-only", LicenseRiskHigh}, + {"GPL-2.0+", LicenseRiskHigh}, + {"AGPL-3.0-or-later", LicenseRiskHigh}, + {"SSPL-1.0", LicenseRiskHigh}, + {"BUSL-1.1", LicenseRiskHigh}, + {"CC-BY-NC-4.0", LicenseRiskHigh}, + {"GNU General Public License v3", LicenseRiskHigh}, + {"LGPL-2.1-or-later", LicenseRiskMedium}, + {"GNU Lesser General Public License", LicenseRiskMedium}, + {"MPL-2.0", LicenseRiskMedium}, + {"EPL-2.0", LicenseRiskMedium}, + {"GPL-2.0-only WITH Classpath-exception-2.0", LicenseRiskMedium}, + {"MIT OR GPL-3.0", LicenseRiskLow}, + {"(MIT AND GPL-2.0)", LicenseRiskHigh}, + {"MIT AND MPL-2.0", LicenseRiskMedium}, + {"SEE LICENSE IN LICENSE.md", LicenseRiskUnknown}, + {"", ""}, + } + for _, c := range cases { + if got := classifyLicenseExpression(c.in); got != c.want { + t.Errorf("classifyLicenseExpression(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestSummarizeLicenses(t *testing.T) { + display, risk := summarizeLicenses([]LicenseChoice{ + {License: &License{ID: "MIT"}}, + {License: &License{Name: "GPL-2.0-only"}}, + {Expression: "Apache-2.0 OR LGPL-3.0"}, + {License: &License{ID: "mit"}}, + }) + if display != "MIT, GPL-2.0-only, Apache-2.0 OR LGPL-3.0" { + t.Errorf("display = %q", display) + } + if risk != LicenseRiskHigh { + t.Errorf("risk = %q, want high", risk) + } + if d, r := summarizeLicenses(nil); d != "" || r != "" { + t.Errorf("empty licenses should give empty summary, got %q/%q", d, r) + } +} + +func TestAnnotateLicensesAndTotals(t *testing.T) { + r := &Report{Components: []ComponentReport{ + {Name: "a", Licenses: trivyLicenses([]string{"AGPL-3.0"})}, + {Name: "b", Licenses: trivyLicenses([]string{"MPL-2.0"})}, + {Name: "c", Licenses: trivyLicenses([]string{"MIT"})}, + {Name: "d"}, + }} + annotateLicenses(r.Components) + countLicenseTotals(r) + if r.Components[0].LicenseRisk != LicenseRiskHigh || r.Components[1].LicenseRisk != LicenseRiskMedium || + r.Components[2].LicenseRisk != LicenseRiskLow || r.Components[3].LicenseRisk != "" { + t.Errorf("unexpected risks: %+v", r.Components) + } + if r.Totals.LicenseHigh != 1 || r.Totals.LicenseMedium != 1 { + t.Errorf("totals = %+v", r.Totals) + } +} diff --git a/internal/sbomscan/reachability_join.go b/internal/sbomscan/reachability_join.go index 3b1be63..2cd8a60 100644 --- a/internal/sbomscan/reachability_join.go +++ b/internal/sbomscan/reachability_join.go @@ -129,7 +129,11 @@ func injectGovulncheckFindings(components *[]ComponentReport, oracle *reachabili Relevant: &relevant, Class: "lang-pkgs", Type: "golang", + // The Go standard library is BSD-3-Clause; govulncheck injects it without + // an SBOM entry, so there is no cdxgen license to carry over. + Licenses: []LicenseChoice{{License: &License{ID: "BSD-3-Clause"}}}, } + stdlib.License, stdlib.LicenseRisk = summarizeLicenses(stdlib.Licenses) for _, goID := range stdlibIDs { primaryID, aliasIDs := bestVulnID(goID, oracle.GOAliases[goID]) stdlib.Vulnerabilities = append(stdlib.Vulnerabilities, onlinescan.Vulnerability{ diff --git a/internal/sbomscan/report_types.go b/internal/sbomscan/report_types.go index 0a55708..de7c8c1 100644 --- a/internal/sbomscan/report_types.go +++ b/internal/sbomscan/report_types.go @@ -39,6 +39,8 @@ type Totals struct { PackageUnused int `json:"packageUnused,omitempty"` Malware int `json:"malware"` Toxic int `json:"toxic"` + LicenseHigh int `json:"licenseHigh"` + LicenseMedium int `json:"licenseMedium"` KEV int `json:"kev"` PoC int `json:"poc"` CRITICAL int `json:"CRITICAL"` @@ -204,6 +206,10 @@ type ComponentReport struct { Hashes []Hash `json:"hashes,omitempty"` Licenses []LicenseChoice `json:"licenses,omitempty"` + // License is the flattened, human-readable license list; LicenseRisk is + // high / medium / low / unknown for production use (see licenses.go). + License string `json:"license,omitempty"` + LicenseRisk string `json:"licenseRisk,omitempty"` Occurrences []string `json:"occurrences,omitempty"` diff --git a/internal/sbomscan/sbomscan.go b/internal/sbomscan/sbomscan.go index 1d79657..3ba1fe3 100644 --- a/internal/sbomscan/sbomscan.go +++ b/internal/sbomscan/sbomscan.go @@ -278,6 +278,7 @@ func ScanBOM(ctx context.Context, o Options) (*Report, error) { if cr.Type == "" { cr.Type = strings.ToLower(cr.System) } + cr.License, cr.LicenseRisk = summarizeLicenses(cr.Licenses) cr.TopSeverity, cr.VulnCount = topAndCount(res.Vulnerabilities) applyReachability(&cr, o.Reachability) cr.Vulnerabilities = dedupeVulns(cr.Vulnerabilities) @@ -363,6 +364,7 @@ func ScanBOM(ctx context.Context, o Options) (*Report, error) { } r.Totals.Scanned++ } + countLicenseTotals(r) return r, nil } diff --git a/internal/trivy/trivy.go b/internal/trivy/trivy.go index f1c066a..0256974 100644 --- a/internal/trivy/trivy.go +++ b/internal/trivy/trivy.go @@ -114,6 +114,7 @@ type Package struct { Version string Arch string SrcName string + Licenses []string Identifier Identifier Layer Layer } From f509ba87ff5fd352a60a7a21ef304410ec2bd8fd Mon Sep 17 00:00:00 2001 From: anton Date: Mon, 14 Sep 2026 11:22:25 +0300 Subject: [PATCH 2/2] fix(licenses): Respect SPDX grouping and show license risks without a fix plan - Parse SPDX license expressions with a precedence-aware parser (AND binds tighter than OR, parentheses override both) instead of stripping parentheses. "GPL-3.0-only AND (MIT OR Apache-2.0)" is now classified as high risk instead of low. - Render the "License risks" section in --format fix-plan even when the report has no vulnerabilities and therefore no remediation plan. - Add tests for grouped expressions and the no-plan fix-plan output. --- internal/output/fixplan.go | 12 ++- internal/output/licenses_test.go | 22 ++++++ internal/sbomscan/licenses.go | 116 ++++++++++++++++++++++------- internal/sbomscan/licenses_test.go | 9 +++ 4 files changed, 130 insertions(+), 29 deletions(-) diff --git a/internal/output/fixplan.go b/internal/output/fixplan.go index 9b52998..91d12a1 100644 --- a/internal/output/fixplan.go +++ b/internal/output/fixplan.go @@ -20,13 +20,19 @@ 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) diff --git a/internal/output/licenses_test.go b/internal/output/licenses_test.go index d24c241..aa947f9 100644 --- a/internal/output/licenses_test.go +++ b/internal/output/licenses_test.go @@ -103,6 +103,28 @@ func TestSARIF_Render_Licenses(t *testing.T) { } } +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, "gpl-lib@1.0.0") { + 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 diff --git a/internal/sbomscan/licenses.go b/internal/sbomscan/licenses.go index 8572968..65c4b99 100644 --- a/internal/sbomscan/licenses.go +++ b/internal/sbomscan/licenses.go @@ -86,43 +86,107 @@ func licenseChoiceLabel(l LicenseChoice) string { return strings.TrimSpace(l.License.Name) } -var ( - reOr = regexp.MustCompile(`(?i)\s+or\s+|\s*/\s*`) - reAnd = regexp.MustCompile(`(?i)\s+and\s+`) - reWith = regexp.MustCompile(`(?i)\s+with\s+`) -) +var reLicenseToken = regexp.MustCompile(`[()/]|[^\s()/]+`) -// classifyLicenseExpression handles SPDX expressions: OR takes the least risky -// alternative, AND the most risky term. +// classifyLicenseExpression evaluates an SPDX expression with its grouping +// intact: OR takes the least risky alternative, AND the most risky term, AND +// binds tighter than OR, and parentheses override both. "/" is accepted as OR +// (common in non-SPDX metadata such as "MIT/GPL-2.0"). func classifyLicenseExpression(expr string) string { - expr = strings.NewReplacer("(", " ", ")", " ").Replace(expr) - best := "" - for _, alt := range reOr.Split(expr, -1) { - worst := "" - for _, term := range reAnd.Split(alt, -1) { - term = strings.TrimSpace(term) - if term == "" { - continue - } - if r := classifyLicenseTerm(term); licenseRiskRank(r) > licenseRiskRank(worst) { - worst = r - } + p := &licenseExprParser{tokens: reLicenseToken.FindAllString(expr, -1)} + risk := p.parseOr() + // Unbalanced ")" or other leftovers: evaluate the rest conservatively. + for p.pos < len(p.tokens) { + p.pos++ + if r := p.parseOr(); licenseRiskRank(r) > licenseRiskRank(risk) { + risk = r } - if worst == "" { + } + return risk +} + +type licenseExprParser struct { + tokens []string + pos int +} + +func (p *licenseExprParser) peekOp(op string) bool { + if p.pos >= len(p.tokens) { + return false + } + t := p.tokens[p.pos] + if op == "OR" && t == "/" { + return true + } + return strings.EqualFold(t, op) +} + +func (p *licenseExprParser) parseOr() string { + best := p.parseAnd() + for p.peekOp("OR") { + p.pos++ + r := p.parseAnd() + if r == "" { continue } - if best == "" || licenseRiskRank(worst) < licenseRiskRank(best) { - best = worst + if best == "" || licenseRiskRank(r) < licenseRiskRank(best) { + best = r } } return best } -func classifyLicenseTerm(term string) string { - base, exception := term, "" - if parts := reWith.Split(term, 2); len(parts) == 2 { - base, exception = parts[0], parts[1] +func (p *licenseExprParser) parseAnd() string { + worst := p.parseAtom() + for p.peekOp("AND") { + p.pos++ + if r := p.parseAtom(); licenseRiskRank(r) > licenseRiskRank(worst) { + worst = r + } + } + return worst +} + +func (p *licenseExprParser) parseAtom() string { + if p.pos >= len(p.tokens) { + return "" + } + if p.tokens[p.pos] == "(" { + p.pos++ + r := p.parseOr() + if p.pos < len(p.tokens) && p.tokens[p.pos] == ")" { + p.pos++ + } + return r + } + // A term is every word up to the next operator or parenthesis, so + // free-form names like "GNU General Public License v3" stay together. + base := p.words() + exception := "" + if p.peekOp("WITH") { + p.pos++ + exception = p.words() + } + if base == "" { + return "" + } + return classifyLicenseTerm(base, exception) +} + +func (p *licenseExprParser) words() string { + var parts []string + for p.pos < len(p.tokens) { + t := p.tokens[p.pos] + if t == "(" || t == ")" || p.peekOp("OR") || p.peekOp("AND") || p.peekOp("WITH") { + break + } + parts = append(parts, t) + p.pos++ } + return strings.Join(parts, " ") +} + +func classifyLicenseTerm(base, exception string) string { risk := classifyLicenseID(base) // GPL + linking exception (Classpath, GCC runtime, ...) behaves like weak copyleft. if risk == LicenseRiskHigh && exception != "" { diff --git a/internal/sbomscan/licenses_test.go b/internal/sbomscan/licenses_test.go index 168454f..f902c7d 100644 --- a/internal/sbomscan/licenses_test.go +++ b/internal/sbomscan/licenses_test.go @@ -26,6 +26,15 @@ func TestClassifyLicenseExpression(t *testing.T) { {"MIT OR GPL-3.0", LicenseRiskLow}, {"(MIT AND GPL-2.0)", LicenseRiskHigh}, {"MIT AND MPL-2.0", LicenseRiskMedium}, + {"GPL-3.0-only AND (MIT OR Apache-2.0)", LicenseRiskHigh}, + {"(MIT OR Apache-2.0) AND GPL-3.0-only", LicenseRiskHigh}, + {"MIT OR GPL-3.0 AND Apache-2.0", LicenseRiskLow}, + {"(MIT OR GPL-3.0) AND Apache-2.0", LicenseRiskLow}, + {"LGPL-2.1 OR (GPL-2.0 AND MIT)", LicenseRiskMedium}, + {"(GPL-2.0-only WITH Classpath-exception-2.0) OR AGPL-3.0", LicenseRiskMedium}, + {"((MIT))", LicenseRiskLow}, + {"MIT/GPL-2.0", LicenseRiskLow}, + {"GPL-3.0 AND (MIT", LicenseRiskHigh}, {"SEE LICENSE IN LICENSE.md", LicenseRiskUnknown}, {"", ""}, }