feat: update syft license construction to be able to look up by URL (#4132)

---------
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
This commit is contained in:
Christopher Angelo Phillips
2025-08-12 14:30:32 -04:00
committed by GitHub
parent 104df88143
commit 89470ecdd3
10 changed files with 1446 additions and 15 deletions
@@ -62,6 +62,9 @@ func decodeLicenses(c *cyclonedx.Component) []pkg.License {
licenses = append(licenses, pkg.NewLicenseFromURLsWithContext(context.TODO(), l.License.ID, l.License.URL))
case l.License != nil && l.License.Name != "":
licenses = append(licenses, pkg.NewLicenseFromURLsWithContext(context.TODO(), l.License.Name, l.License.URL))
case l.License != nil && l.License.URL != "":
// Try to enrich license from URL when ID and Name are empty
licenses = append(licenses, pkg.NewLicenseFromURLsWithContext(context.TODO(), "", l.License.URL))
case l.Expression != "":
licenses = append(licenses, pkg.NewLicenseWithContext(context.TODO(), l.Expression))
default:
@@ -163,10 +166,18 @@ func processCustomLicense(l pkg.License) cyclonedx.Licenses {
func processLicenseURLs(l pkg.License, spdxID string, populate *cyclonedx.Licenses) {
for _, url := range l.URLs {
if spdxID == "" {
// CycloneDX requires either an id or name to be present for a license
// If l.Value is empty, use the URL as the name to ensure schema compliance
// at this point we've already tried to enrich the license we just don't want the format
// conversion to be lossy here
name := l.Value
if name == "" {
name = url
}
*populate = append(*populate, cyclonedx.LicenseChoice{
License: &cyclonedx.License{
URL: url,
Name: l.Value,
Name: name,
},
})
} else {
@@ -121,6 +121,22 @@ func Test_encodeLicense(t *testing.T) {
},
},
},
{
name: "with URL only and no name or SPDX ID",
input: pkg.Package{
Licenses: pkg.NewLicenseSet(
pkg.NewLicenseFromURLsWithContext(ctx, "", "http://jaxen.codehaus.org/license.html"),
),
},
expected: &cyclonedx.Licenses{
{
License: &cyclonedx.License{
Name: "http://jaxen.codehaus.org/license.html",
URL: "http://jaxen.codehaus.org/license.html",
},
},
},
},
{
name: "with multiple values licenses are deduplicated",
input: pkg.Package{
+29
View File
@@ -4,6 +4,7 @@ package license
import (
"fmt"
"runtime/debug"
"strings"
"github.com/github/go-spdx/v2/spdxexp"
@@ -17,6 +18,18 @@ const (
Concluded Type = "concluded"
)
// trimFileSuffix removes common file extensions from the end of a string
func trimFileSuffix(s string) string {
suffixes := []string{".txt", ".pdf", ".html", ".htm", ".md", ".markdown", ".rst", ".doc", ".docx", ".rtf", ".tex", ".xml", ".json"}
lower := strings.ToLower(s)
for _, suffix := range suffixes {
if strings.HasSuffix(lower, suffix) {
return s[:len(s)-len(suffix)]
}
}
return s
}
func ParseExpression(expression string) (ex string, err error) {
// https://github.com/anchore/syft/issues/1837
// The current spdx library can panic when parsing some expressions
@@ -28,10 +41,26 @@ func ParseExpression(expression string) (ex string, err error) {
}
}()
// Try with the original expression first
licenseID, exists := spdxlicense.ID(expression)
if exists {
return licenseID, nil
}
// Check if the expression is a URL and try to look it up
if info, found := spdxlicense.LicenseByURL(expression); found {
return info.ID, nil
}
// Try with trimmed file suffix
trimmed := trimFileSuffix(expression)
if trimmed != expression {
// Try as a URL with the trimmed version
if info, found := spdxlicense.LicenseByURL(trimmed); found {
return info.ID, nil
}
}
// If it doesn't exist initially in the SPDX list it might be a more complex expression
// ignored variable is any invalid expressions
// TODO: contribute to spdxexp to expose deprecated license IDs
+21 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/anchore/syft/internal/licenses"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/spdxlicense"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/license"
@@ -132,6 +133,13 @@ func stripUnwantedCharacters(rawURL string) (string, error) {
}
func NewLicenseFromFieldsWithContext(ctx context.Context, value, url string, location *file.Location) License {
// If value is empty but URL is provided, try to enrich from SPDX database
if value == "" && url != "" {
if info, found := spdxlicense.LicenseByURL(url); found {
value = info.ID
}
}
lics := newLicenseBuilder().WithValues(value).WithURLs(url).WithOptionalLocation(location).Build(ctx).ToSlice()
if len(lics) > 0 {
return lics[0]
@@ -294,11 +302,22 @@ func (b *licenseBuilder) Build(ctx context.Context) LicenseSet {
if set.Empty() && len(b.urls) > 0 {
// if we have no values or contents, but we do have URLs, let's make a license with the URLs
set.Add(License{
// try to enrich the license by looking up the URL in the SPDX database
license := License{
Type: b.tp,
URLs: b.urls,
Locations: locations,
})
}
// attempt to fill in missing license information from the first URL
if len(b.urls) > 0 {
if info, found := spdxlicense.LicenseByURL(b.urls[0]); found {
license.Value = info.ID
license.SPDXExpression = info.ID
}
}
set.Add(license)
}
return set
+180
View File
@@ -0,0 +1,180 @@
package pkg
import (
"context"
"testing"
)
func TestNewLicenseFromFieldsWithContext_URLEnrichment(t *testing.T) {
tests := []struct {
name string
value string
url string
wantValue string
wantHasURL bool
}{
{
name: "Empty value with MIT URL should enrich",
value: "",
url: "http://opensource.org/licenses/MIT",
wantValue: "MIT",
wantHasURL: true,
},
{
name: "Empty value with Apache URL should enrich",
value: "",
url: "https://www.apache.org/licenses/LICENSE-2.0",
wantValue: "Apache-2.0",
wantHasURL: true,
},
{
name: "Non-empty value should not be overridden",
value: "Custom-License",
url: "http://opensource.org/licenses/MIT",
wantValue: "Custom-License",
wantHasURL: true,
},
{
name: "Unknown URL should not enrich",
value: "",
url: "https://example.com/unknown-license",
wantValue: "",
wantHasURL: true,
},
{
name: "Empty value and empty URL",
value: "",
url: "",
wantValue: "",
wantHasURL: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
license := NewLicenseFromFieldsWithContext(ctx, tt.value, tt.url, nil)
if license.Value != tt.wantValue {
t.Errorf("NewLicenseFromFieldsWithContext() Value = %v, want %v", license.Value, tt.wantValue)
}
hasURL := len(license.URLs) > 0
if hasURL != tt.wantHasURL {
t.Errorf("NewLicenseFromFieldsWithContext() has URL = %v, want %v", hasURL, tt.wantHasURL)
}
if tt.wantHasURL && tt.url != "" && license.URLs[0] != tt.url {
t.Errorf("NewLicenseFromFieldsWithContext() URL = %v, want %v", license.URLs[0], tt.url)
}
})
}
}
func TestLicenseBuilder_URLOnlyEnrichment(t *testing.T) {
tests := []struct {
name string
urls []string
wantValue string
wantSPDX string
wantHasURL bool
}{
{
name: "MIT URL only should enrich",
urls: []string{"http://opensource.org/licenses/MIT"},
wantValue: "MIT",
wantSPDX: "MIT",
wantHasURL: true,
},
{
name: "Apache URL only should enrich",
urls: []string{"https://www.apache.org/licenses/LICENSE-2.0"},
wantValue: "Apache-2.0",
wantSPDX: "Apache-2.0",
wantHasURL: true,
},
{
name: "Multiple URLs should use first",
urls: []string{"http://opensource.org/licenses/MIT", "https://example.com/other"},
wantValue: "MIT",
wantSPDX: "MIT",
wantHasURL: true,
},
{
name: "Unknown URL should not enrich",
urls: []string{"https://example.com/unknown-license"},
wantValue: "",
wantSPDX: "",
wantHasURL: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
builder := newLicenseBuilder().WithURLs(tt.urls...)
licenses := builder.Build(ctx).ToSlice()
if len(licenses) == 0 {
t.Fatal("Expected at least one license")
}
license := licenses[0]
if license.Value != tt.wantValue {
t.Errorf("License Value = %v, want %v", license.Value, tt.wantValue)
}
if license.SPDXExpression != tt.wantSPDX {
t.Errorf("License SPDXExpression = %v, want %v", license.SPDXExpression, tt.wantSPDX)
}
hasURL := len(license.URLs) > 0
if hasURL != tt.wantHasURL {
t.Errorf("License has URL = %v, want %v", hasURL, tt.wantHasURL)
}
if tt.wantHasURL && len(tt.urls) > 0 {
if len(license.URLs) != len(tt.urls) {
t.Errorf("License URL count = %v, want %v", len(license.URLs), len(tt.urls))
}
if license.URLs[0] != tt.urls[0] {
t.Errorf("License first URL = %v, want %v", license.URLs[0], tt.urls[0])
}
}
})
}
}
func TestNewLicenseFromURLsWithContext_URLEnrichment(t *testing.T) {
tests := []struct {
name string
value string
urls []string
wantValue string
}{
{
name: "Empty value with MIT URL should enrich via builder",
value: "",
urls: []string{"http://opensource.org/licenses/MIT"},
wantValue: "MIT",
},
{
name: "Non-empty value should not be changed",
value: "Custom-License",
urls: []string{"http://opensource.org/licenses/MIT"},
wantValue: "Custom-License",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
license := NewLicenseFromURLsWithContext(ctx, tt.value, tt.urls...)
if license.Value != tt.wantValue {
t.Errorf("NewLicenseFromURLsWithContext() Value = %v, want %v", license.Value, tt.wantValue)
}
})
}
}