From a119800bff72d31193405f311c3561d3355361b4 Mon Sep 17 00:00:00 2001 From: Eljees <3.14hell@gmail.com> Date: Tue, 28 Jul 2026 09:58:13 +0300 Subject: [PATCH] fix: resolve SPDX expressions that use the WITH operator The SPDX parser returns " WITH " as a single atom, but the SPDX license list is indexed by license ID alone, so looking the atom up always failed. Every affected component logged "unable to get license by ID" and fell back to an unresolved license, which is what users see as Unknown. When the lookup fails and the atom is a WITH expression, retry with the license the exception applies to. The full expression stays on the resulting License, so policy matching -- which goes through License.String() and therefore the SPDX expression -- is unchanged; what changes is that the license is now resolved and carries its name, reference and OSI status instead of nothing. Signed-off-by: Eljees <3.14hell@gmail.com> --- grant/license.go | 25 +++++++++++++++++++++ grant/license_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/grant/license.go b/grant/license.go index b4dd49b..3889af0 100644 --- a/grant/license.go +++ b/grant/license.go @@ -112,6 +112,15 @@ func handleSPDXLicense(license syftPkg.License, licenses []License, licenseLocat // we have what seems to be a valid SPDX license ID, let's try and get more info about it spdxLicense, err := spdxlicense.GetLicenseByID(extractedLicense) + if err != nil { + // The parser returns " WITH " as a single atom, but the + // SPDX license list is indexed by license ID alone, so the lookup above can + // only fail for these. Retry with the license the exception applies to; the + // full expression is still what ends up on the resulting License. + if base, ok := licenseFromExceptionExpression(extractedLicense); ok { + spdxLicense, err = spdxlicense.GetLicenseByID(base) + } + } if err != nil { log.Errorf("unable to get license by ID: %s; no matching spdx id found", extractedLicense) // if we can't find a matching SPDX license, just add the license as-is @@ -136,6 +145,22 @@ func handleSPDXLicense(license syftPkg.License, licenses []License, licenseLocat return licenses } +// licenseFromExceptionExpression returns the license an SPDX "WITH" expression +// applies to, for example "Apache-2.0" from "Apache-2.0 WITH LLVM-exception". +func licenseFromExceptionExpression(expression string) (string, bool) { + parts := strings.SplitN(expression, " WITH ", 2) + if len(parts) != 2 { + return "", false + } + + license := strings.TrimRight(strings.TrimSpace(parts[0]), "+") + if license == "" || strings.TrimSpace(parts[1]) == "" { + return "", false + } + + return license, true +} + func addNonSPDXLicense(licenses []License, license syftPkg.License, locations []string) []License { // Filter out sha256: licenses - these are content hashes from Syft when license detection fails if strings.HasPrefix(license.Value, "sha256:") { diff --git a/grant/license_test.go b/grant/license_test.go index 949f6d5..585346f 100644 --- a/grant/license_test.go +++ b/grant/license_test.go @@ -42,3 +42,54 @@ func TestConvertSyftLicenses_MalformedSPDXExpressionDoesNotPanic(t *testing.T) { }) } } + +// TestConvertSyftLicenses_SPDXExpressionWithException covers SPDX expressions +// that use the WITH operator. The upstream parser hands these back as a single +// atom ("Apache-2.0 WITH LLVM-exception"), which is not a key in the SPDX +// license list, so grant used to log "unable to get license by ID" and fall back +// to an unresolved license. +func TestConvertSyftLicenses_SPDXExpressionWithException(t *testing.T) { + tests := []struct { + expression string + wantSPDX []string + wantLicense []string + }{ + { + expression: "Apache-2.0 WITH LLVM-exception", + wantSPDX: []string{"Apache-2.0 WITH LLVM-exception"}, + wantLicense: []string{"Apache-2.0"}, + }, + { + expression: "GPL-2.0-only WITH Classpath-exception-2.0", + wantSPDX: []string{"GPL-2.0-only WITH Classpath-exception-2.0"}, + wantLicense: []string{"GPL-2.0-only"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.expression, func(t *testing.T) { + set := syftPkg.NewLicenseSet(syftPkg.License{Value: tt.expression, SPDXExpression: tt.expression}) + + got := ConvertSyftLicenses(set) + + if len(got) != len(tt.wantSPDX) { + t.Fatalf("expected %d licenses, got %d", len(tt.wantSPDX), len(got)) + } + for i, want := range tt.wantSPDX { + if !got[i].IsSPDX() { + t.Fatalf("expected %q to resolve to an SPDX license, got name %q", tt.expression, got[i].Name) + } + if got[i].SPDXExpression != want { + t.Errorf("got SPDX expression %q, want %q", got[i].SPDXExpression, want) + } + if got[i].LicenseID != tt.wantLicense[i] { + t.Errorf("got license id %q, want %q", got[i].LicenseID, tt.wantLicense[i]) + } + if got[i].Name == "" { + t.Errorf("expected the license name to be populated from the SPDX list") + } + } + }) + } +}