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
30 changes: 28 additions & 2 deletions pkg/generator/copyright.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,10 @@ func extractNpmCopyright(nodeModulesDir, purl string) string {
pkgDir := filepath.Join(nodeModulesDir, filepath.FromSlash(name))

//nolint:misspell // support British spelling
for _, filename := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md"} {
if c := firstCopyrightLine(readFileText(filepath.Join(pkgDir, filename))); c != "" {
licenseFileNames := []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md"}

if filename := findCaseInsensitiveFile(pkgDir, licenseFileNames); filename != "" {
if c := firstCopyrightLine(readFileText(filename)); c != "" {
return c
}
}
Expand Down Expand Up @@ -300,3 +302,27 @@ func readFileText(path string) string {

return string(data)
}

// findCaseInsensitiveFile returns the path of the first file in dir whose name
// matches one of names case-insensitively. Preference follows the order of
// names: "LICENSE" beats "LICENSE.md" even if the latter appears first in dir.
func findCaseInsensitiveFile(dir string, names []string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}

for _, name := range names {
for _, entry := range entries {
if entry.IsDir() {
continue
}

if strings.EqualFold(entry.Name(), name) {
return filepath.Join(dir, entry.Name())
}
}
}

return ""
}
2 changes: 0 additions & 2 deletions pkg/generator/data/license-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
"BSD license": "BSD-2-Clause",
"BSD*": "BSD-3-Clause",
"LicenseRef-CC0-1-0": "CC0-1.0",
"LicenseRef-License-OSI-Approved-Apache-Software-License": "Apache-2.0",
"LicenseRef-License-OSI-Approved-BSD-License": "BSD-3-Clause",
"LicenseRef-MIT-X11": "MIT",
"MIT*": "MIT",
"MIT-X11": "MIT",
Expand Down
28 changes: 26 additions & 2 deletions pkg/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ func buildNotices(byKey map[string]OutComponent) []OutComponent {
}

sort.Slice(notices, func(i, j int) bool {
return notices[i].Name+notices[i].Version < notices[j].Name+notices[j].Version
return sortComponents(notices[i], notices[j])
})

return notices
Expand All @@ -204,7 +204,7 @@ func buildLicenseBlocks(ctx context.Context, cfg Config, byLicense map[string][]
for _, id := range licenseIDs {
comps := byLicense[id]
sort.Slice(comps, func(i, j int) bool {
return comps[i].Name+comps[i].Version < comps[j].Name+comps[j].Version
return sortComponents(comps[i], comps[j])
})

name := spdxNames[id]
Expand Down Expand Up @@ -281,6 +281,30 @@ func buildIndex(components []Component, filters Filters, licenseMap, licenseCorr
return byLicense, byKey
}

// sortComponents orders components by name, version, PURL, URL, copyright in
// turn. Concatenating these into one string would conflate boundaries — e.g.
// ("ab", "") and ("a", "b") would compare equal — so the comparison cascades
// field by field.
func sortComponents(a, b OutComponent) bool {
if a.Name != b.Name {
return a.Name < b.Name
}

if a.Version != b.Version {
return a.Version < b.Version
}

if a.PURL != b.PURL {
return a.PURL < b.PURL
}

if a.URL != b.URL {
return a.URL < b.URL
}

return a.Copyright < b.Copyright
}

func mergeOrInsert(byKey map[string]OutComponent, c Component, out OutComponent) OutComponent {
key := c.PURL
if key == "" {
Expand Down
43 changes: 43 additions & 0 deletions pkg/generator/licenses.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package generator

import (
"slices"
"strings"

"github.com/aquasecurity/trivy/pkg/licensing/expression"
Expand Down Expand Up @@ -41,6 +42,15 @@ func resolveExpression(item LicenseChoice, licenseMap map[string]string) []strin
return nil
}

// cyclonedx-py emits PyPI Trove classifiers (e.g. "License :: OSI Approved ::
// Apache Software License") verbatim. Strip the classifier prefix so the
// remaining human name can be resolved through licenseMap or SPDX instead of
// degenerating into an opaque LicenseRef-License-OSI-Approved-* identifier.
expr = stripTrovePrefix(expr)
if expr == "" {
return nil
}

if mapped, ok := licenseMap[expr]; ok && mapped != "" {
return []string{mapped}
}
Expand Down Expand Up @@ -148,3 +158,36 @@ func firstNonEmpty(a string, b func() string) string {

return b()
}

// stripTrovePrefix removes a PyPI Trove classifier prefix
// (https://pypi.org/classifiers/) from s, returning the trailing
// human-readable license name (e.g. "Apache Software License"). It is a no-op
// when no known prefix is present.
//
// "Bare" meta-classifiers such as "License :: OSI Approved" (without a
// specific license after) carry no attribution value on their own and are
// reduced to the empty string so the caller can skip them.
func stripTrovePrefix(s string) string {
bareMetaClassifiers := []string{
"License :: OSI Approved",
"License :: DFSG approved",
}

if slices.Contains(bareMetaClassifiers, s) {
return ""
}

prefixes := []string{
"License :: OSI Approved :: ",
"License :: DFSG approved :: ",
"License :: ",
}

for _, prefix := range prefixes {
if rest, ok := strings.CutPrefix(s, prefix); ok {
return strings.TrimSpace(rest)
}
}

return s
}
63 changes: 63 additions & 0 deletions pkg/generator/licenses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,69 @@ func TestNormalizeLicenseIDs_LicenseRefIDWithoutMapping(t *testing.T) {
assert.Equal(t, []string{"LicenseRef-Unknown"}, ids)
}

func TestNormalizeLicenseIDs_TroveClassifierOSIApproved(t *testing.T) {
t.Parallel()

licenses := []LicenseChoice{{Expression: "License :: OSI Approved :: Apache Software License"}}
licenseMap := map[string]string{"Apache Software License": "Apache-2.0"}

ids := normalizeLicenseIDs(licenses, licenseMap)
assert.Equal(t, []string{"Apache-2.0"}, ids)
}

func TestNormalizeLicenseIDs_TroveClassifierFromLicenseName(t *testing.T) {
t.Parallel()

licenses := []LicenseChoice{{License: &struct {
ID string `json:"id"`
Name string `json:"name"`
}{Name: "License :: OSI Approved :: BSD License"}}}
licenseMap := map[string]string{"BSD License": "BSD-2-Clause"}

ids := normalizeLicenseIDs(licenses, licenseMap)
assert.Equal(t, []string{"BSD-2-Clause"}, ids)
}

func TestNormalizeLicenseIDs_BareOSIApprovedAlongsideSPDX(t *testing.T) {
t.Parallel()

licenses := []LicenseChoice{
{License: &struct {
ID string `json:"id"`
Name string `json:"name"`
}{ID: "MIT"}},
{License: &struct {
ID string `json:"id"`
Name string `json:"name"`
}{Name: "License :: OSI Approved"}},
}

ids := normalizeLicenseIDs(licenses, nil)
assert.Equal(t, []string{"MIT"}, ids)
}

func TestStripTrovePrefix(t *testing.T) {
t.Parallel()

testCases := []struct {
in string
want string
}{
{"License :: OSI Approved :: Apache Software License", "Apache Software License"},
{"License :: OSI Approved :: BSD License", "BSD License"},
{"License :: DFSG approved :: GNU General Public License (GPL)", "GNU General Public License (GPL)"},
{"License :: Public Domain", "Public Domain"},
{"License :: OSI Approved", ""},
{"License :: DFSG approved", ""},
{"MIT", "MIT"},
{"", ""},
}

for _, tc := range testCases {
assert.Equal(t, tc.want, stripTrovePrefix(tc.in))
}
}

func TestMatchLicenseOverride_ExactPURL(t *testing.T) {
t.Parallel()

Expand Down
Loading