diff --git a/.github/workflows/spdx-cron.yaml b/.github/workflows/spdx-cron.yaml new file mode 100644 index 000000000000..c8081db1b49e --- /dev/null +++ b/.github/workflows/spdx-cron.yaml @@ -0,0 +1,41 @@ +name: SPDX licenses cron +on: + schedule: + - cron: '0 0 * * 0' # every Sunday at 00:00 + workflow_dispatch: + +jobs: + build: + name: Check if SPDX exceptions + runs-on: ubuntu-24.04 + steps: + - name: Check out code + uses: actions/checkout@v4.1.6 + + - name: Check if SPDX exceptions are up-to-date + run: | + mage spdx:updateLicenseExceptions + if [ -n "$(git status --porcelain)" ]; then + echo "Run 'mage spdx:updateLicenseExceptions' and push it" + exit 1 + fi + + - name: Check if SPDX exceptions are up-to-date + run: | + mage spdx:updateLicenseExceptions + if [ -n "$(git status --porcelain)" ]; then + echo "Run 'mage spdx:updateLicenseExceptions' and push it" + exit 1 + fi + + - name: Microsoft Teams Notification + ## Until the PR with the fix for the AdaptivCard version is merged yet + ## https://github.com/Skitionek/notify-microsoft-teams/pull/96 + ## Use the aquasecurity fork + uses: aquasecurity/notify-microsoft-teams@master + if: failure() + with: + webhook_url: ${{ secrets.TRIVY_MSTEAMS_WEBHOOK }} + needs: ${{ toJson(needs) }} + job: ${{ toJson(job) }} + steps: ${{ toJson(steps) }} \ No newline at end of file diff --git a/magefiles/magefile.go b/magefiles/magefile.go index 6adff2d92864..900528b514dc 100644 --- a/magefiles/magefile.go +++ b/magefiles/magefile.go @@ -540,3 +540,10 @@ type Helm mg.Namespace func (Helm) UpdateVersion() error { return sh.RunWith(ENV, "go", "run", "-tags=mage_helm", "./magefiles") } + +type SPDX mg.Namespace + +// UpdateLicenseExceptions updates 'exception.json' with SPDX license exceptions +func (SPDX) UpdateLicenseExceptions() error { + return sh.RunWith(ENV, "go", "run", "-tags=mage_spdx", "./magefiles/spdx.go") +} diff --git a/magefiles/spdx.go b/magefiles/spdx.go new file mode 100644 index 000000000000..f169add48684 --- /dev/null +++ b/magefiles/spdx.go @@ -0,0 +1,79 @@ +//go:build mage_spdx + +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/samber/lo" + "golang.org/x/xerrors" + + "github.com/aquasecurity/trivy/pkg/downloader" + "github.com/aquasecurity/trivy/pkg/log" +) + +const ( + exceptionFile = "exceptions.json" + exceptionDir = "./pkg/licensing/expression" + exceptionURL = "https://spdx.org/licenses/exceptions.json" +) + +type Exceptions struct { + Exceptions []Exception `json:"exceptions"` +} + +type Exception struct { + ID string `json:"licenseExceptionId"` +} + +func main() { + if err := run(); err != nil { + log.Fatal("Fatal error", log.Err(err)) + } + +} + +// run downloads exceptions.json file, takes only IDs and saves into `expression` package. +func run() error { + tmpDir, err := downloader.DownloadToTempDir(context.Background(), exceptionURL, downloader.Options{}) + if err != nil { + return xerrors.Errorf("unable to download exceptions.json file: %w", err) + } + tmpFile, err := os.ReadFile(filepath.Join(tmpDir, exceptionFile)) + if err != nil { + return xerrors.Errorf("unable to read exceptions.json file: %w", err) + } + + exceptions := Exceptions{} + if err = json.Unmarshal(tmpFile, &exceptions); err != nil { + return xerrors.Errorf("unable to unmarshal exceptions.json file: %w", err) + } + + exs := lo.Map(exceptions.Exceptions, func(ex Exception, _ int) string { + return strings.ToUpper(ex.ID) + }) + sort.Strings(exs) + + exceptionFile := filepath.Join(exceptionDir, exceptionFile) + f, err := os.Create(exceptionFile) + if err != nil { + return xerrors.Errorf("unable to create file %s: %w", exceptionFile, err) + } + defer f.Close() + + e, err := json.Marshal(exs) + if err != nil { + return xerrors.Errorf("unable to marshal exceptions list: %w", err) + } + + if _, err = f.Write(e); err != nil { + return xerrors.Errorf("unable to write exceptions list: %w", err) + } + + return nil +} diff --git a/pkg/licensing/expression/category.go b/pkg/licensing/expression/category.go index c32f228c07d8..f81d10cec7f2 100644 --- a/pkg/licensing/expression/category.go +++ b/pkg/licensing/expression/category.go @@ -1,5 +1,17 @@ package expression +import ( + "encoding/json" + "strings" + "sync" + + "github.com/samber/lo" + + "github.com/aquasecurity/trivy/pkg/log" + + _ "embed" +) + // Canonical names of the licenses. // ported from https://github.com/google/licenseclassifier/blob/7c62d6fe8d3aa2f39c4affb58c9781d9dc951a2d/license_type.go#L24-L177 const ( @@ -359,3 +371,79 @@ var ( ZeroBSD, } ) + +var spdxLicenses map[string]struct{} + +var initSpdxLicenses = sync.OnceFunc(func() { + if len(spdxLicenses) > 0 { + return + } + + licenseSlices := [][]string{ + ForbiddenLicenses, + RestrictedLicenses, + ReciprocalLicenses, + NoticeLicenses, + PermissiveLicenses, + UnencumberedLicenses, + } + + for _, licenseSlice := range licenseSlices { + spdxLicenses = lo.Assign(spdxLicenses, lo.SliceToMap(licenseSlice, func(l string) (string, struct{}) { + return l, struct{}{} + })) + } + + // Save GNU licenses with "-or-later" and `"-only" suffixes + for _, l := range GnuLicenses { + license := SimpleExpr{ + License: l, + } + spdxLicenses[license.String()] = struct{}{} + + license.HasPlus = true + spdxLicenses[license.String()] = struct{}{} + } +}) + +//go:embed exceptions.json +var exceptions []byte + +var spdxExceptions map[string]struct{} + +var initSpdxExceptions = sync.OnceFunc(func() { + if len(spdxExceptions) > 0 { + return + } + + var exs []string + if err := json.Unmarshal(exceptions, &exs); err != nil { + log.WithPrefix("SPDX").Warn("Unable to parse SPDX exception file", log.Err(err)) + return + } + + spdxExceptions = lo.SliceToMap(exs, func(e string) (string, struct{}) { + return e, struct{}{} + }) +}) + +// ValidSpdxLicense returns true if SPDX license lists contain licenseID and license exception (if exists) +func ValidSpdxLicense(license string) bool { + if spdxLicenses == nil { + initSpdxLicenses() + } + if spdxExceptions == nil { + initSpdxExceptions() + } + + id, exception, ok := strings.Cut(license, " WITH ") + if _, licenseIdFound := spdxLicenses[id]; licenseIdFound { + if !ok { + return true + } + if _, exceptionFound := spdxExceptions[strings.ToUpper(exception)]; exceptionFound { + return true + } + } + return false +} diff --git a/pkg/licensing/expression/exceptions.json b/pkg/licensing/expression/exceptions.json new file mode 100644 index 000000000000..c3e85cbde825 --- /dev/null +++ b/pkg/licensing/expression/exceptions.json @@ -0,0 +1 @@ +["389-EXCEPTION","ASTERISK-EXCEPTION","ASTERISK-LINKING-PROTOCOLS-EXCEPTION","AUTOCONF-EXCEPTION-2.0","AUTOCONF-EXCEPTION-3.0","AUTOCONF-EXCEPTION-GENERIC","AUTOCONF-EXCEPTION-GENERIC-3.0","AUTOCONF-EXCEPTION-MACRO","BISON-EXCEPTION-1.24","BISON-EXCEPTION-2.2","BOOTLOADER-EXCEPTION","CLASSPATH-EXCEPTION-2.0","CLISP-EXCEPTION-2.0","CRYPTSETUP-OPENSSL-EXCEPTION","DIGIRULE-FOSS-EXCEPTION","ECOS-EXCEPTION-2.0","ERLANG-OTP-LINKING-EXCEPTION","FAWKES-RUNTIME-EXCEPTION","FLTK-EXCEPTION","FMT-EXCEPTION","FONT-EXCEPTION-2.0","FREERTOS-EXCEPTION-2.0","GCC-EXCEPTION-2.0","GCC-EXCEPTION-2.0-NOTE","GCC-EXCEPTION-3.1","GMSH-EXCEPTION","GNAT-EXCEPTION","GNOME-EXAMPLES-EXCEPTION","GNU-COMPILER-EXCEPTION","GNU-JAVAMAIL-EXCEPTION","GPL-3.0-INTERFACE-EXCEPTION","GPL-3.0-LINKING-EXCEPTION","GPL-3.0-LINKING-SOURCE-EXCEPTION","GPL-CC-1.0","GSTREAMER-EXCEPTION-2005","GSTREAMER-EXCEPTION-2008","I2P-GPL-JAVA-EXCEPTION","KICAD-LIBRARIES-EXCEPTION","LGPL-3.0-LINKING-EXCEPTION","LIBPRI-OPENH323-EXCEPTION","LIBTOOL-EXCEPTION","LINUX-SYSCALL-NOTE","LLGPL","LLVM-EXCEPTION","LZMA-EXCEPTION","MIF-EXCEPTION","NOKIA-QT-EXCEPTION-1.1","OCAML-LGPL-LINKING-EXCEPTION","OCCT-EXCEPTION-1.0","OPENJDK-ASSEMBLY-EXCEPTION-1.0","OPENVPN-OPENSSL-EXCEPTION","PCRE2-EXCEPTION","PS-OR-PDF-FONT-EXCEPTION-20170817","QPL-1.0-INRIA-2004-EXCEPTION","QT-GPL-EXCEPTION-1.0","QT-LGPL-EXCEPTION-1.1","QWT-EXCEPTION-1.0","ROMIC-EXCEPTION","RRDTOOL-FLOSS-EXCEPTION-2.0","SANE-EXCEPTION","SHL-2.0","SHL-2.1","STUNNEL-EXCEPTION","SWI-EXCEPTION","SWIFT-EXCEPTION","TEXINFO-EXCEPTION","U-BOOT-EXCEPTION-2.0","UBDL-EXCEPTION","UNIVERSAL-FOSS-EXCEPTION-1.0","VSFTPD-OPENSSL-EXCEPTION","WXWINDOWS-EXCEPTION-3.1","X11VNC-OPENSSL-EXCEPTION"] \ No newline at end of file diff --git a/pkg/sbom/spdx/marshal.go b/pkg/sbom/spdx/marshal.go index 51f9144f682d..e1f7ae54b804 100644 --- a/pkg/sbom/spdx/marshal.go +++ b/pkg/sbom/spdx/marshal.go @@ -60,6 +60,8 @@ const ( ElementApplication = "Application" ElementPackage = "Package" ElementFile = "File" + + LicenseRefPrefix = "LicenseRef" ) var ( @@ -82,6 +84,7 @@ type Marshaler struct { format spdx.Document hasher Hash appVersion string // Trivy version. It needed for `creator` field + logger *log.Logger } type Hash func(v any, format hashstructure.Format, opts *hashstructure.HashOptions) (uint64, error) @@ -99,6 +102,7 @@ func NewMarshaler(version string, opts ...marshalOption) *Marshaler { format: spdx.Document{}, hasher: hashstructure.Hash, appVersion: version, + logger: log.WithPrefix("SPDX"), } for _, opt := range opts { @@ -145,6 +149,7 @@ func (m *Marshaler) Marshal(ctx context.Context, bom *core.BOM) (*spdx.Document, packageIDs[root.ID()] = rootPkg.PackageSPDXIdentifier var files []*spdx.File + var otherLicenses []*spdx.OtherLicense for _, c := range bom.Components() { if c.Root { continue @@ -165,6 +170,14 @@ func (m *Marshaler) Marshal(ctx context.Context, bom *core.BOM) (*spdx.Document, packages = append(packages, &spdxPackage) packageIDs[c.ID()] = spdxPackage.PackageSPDXIdentifier + // Fill licenses + license, others := m.spdxLicense(c) + // The Declared License is what the authors of a project believe govern the package + spdxPackage.PackageLicenseConcluded = license + // The Concluded License field is the license the SPDX file creator believes governs the package + spdxPackage.PackageLicenseDeclared = license + otherLicenses = append(otherLicenses, others...) + spdxFiles, err := m.spdxFiles(c) if err != nil { return nil, xerrors.Errorf("spdx files error: %w", err) @@ -203,6 +216,7 @@ func (m *Marshaler) Marshal(ctx context.Context, bom *core.BOM) (*spdx.Document, sortPackages(packages) sortRelationships(relationShips) sortFiles(files) + otherLicenses = sortOtherLicenses(otherLicenses) return &spdx.Document{ SPDXVersion: spdx.Version, @@ -226,6 +240,7 @@ func (m *Marshaler) Marshal(ctx context.Context, bom *core.BOM) (*spdx.Document, Packages: packages, Relationships: relationShips, Files: files, + OtherLicenses: otherLicenses, }, nil } @@ -249,7 +264,7 @@ func (m *Marshaler) rootSPDXPackage(root *core.Component, timeNow, pkgDownloadLo externalReferences = append(externalReferences, m.purlExternalReference(root.PkgIdentifier.PURL.String())) } - pkgID, err := calcPkgID(m.hasher, fmt.Sprintf("%s-%s", root.Name, root.Type)) + pkgID, err := calcSPDXID(m.hasher, fmt.Sprintf("%s-%s", root.Name, root.Type)) if err != nil { return nil, xerrors.Errorf("failed to get %s package ID: %w", pkgID, err) } @@ -301,12 +316,12 @@ func (m *Marshaler) advisoryExternalReference(primaryURL string) *spdx.PackageEx } func (m *Marshaler) spdxPackage(c *core.Component, timeNow, pkgDownloadLocation string) (spdx.Package, error) { - pkgID, err := calcPkgID(m.hasher, c) + pkgID, err := calcSPDXID(m.hasher, c) if err != nil { return spdx.Package{}, xerrors.Errorf("failed to get os metadata package ID: %w", err) } - var elementType, purpose, license, sourceInfo string + var elementType, purpose, sourceInfo string var supplier *spdx.Supplier switch c.Type { case core.TypeOS: @@ -318,7 +333,9 @@ func (m *Marshaler) spdxPackage(c *core.Component, timeNow, pkgDownloadLocation case core.TypeLibrary: elementType = ElementPackage purpose = PackagePurposeLibrary - license = m.spdxLicense(c) + + // We need to create a new `LicesenRef-*` component for licenses that are not in the SPDX license list + // So we will fill licenses later if c.SrcName != "" { sourceInfo = fmt.Sprintf("%s: %s %s", SourcePackagePrefix, c.SrcName, c.SrcVersion) @@ -360,12 +377,6 @@ func (m *Marshaler) spdxPackage(c *core.Component, timeNow, pkgDownloadLocation PackageSourceInfo: sourceInfo, PackageSupplier: supplier, PackageChecksums: m.spdxChecksums(digests), - - // The Declared License is what the authors of a project believe govern the package - PackageLicenseConcluded: license, - - // The Concluded License field is the license the SPDX file creator believes governs the package - PackageLicenseDeclared: license, }, nil } @@ -389,11 +400,96 @@ func (m *Marshaler) spdxAnnotations(c *core.Component, timeNow string) []spdx.An return annotations } -func (m *Marshaler) spdxLicense(c *core.Component) string { +func (m *Marshaler) spdxLicense(c *core.Component) (string, []*spdx.OtherLicense) { + // Only library components contain licenses + if c.Type != core.TypeLibrary { + return "", nil + } if len(c.Licenses) == 0 { - return noAssertionField + return noAssertionField, nil + } + return m.normalizeLicenses(c.Licenses) +} + +func (m *Marshaler) normalizeLicenses(licenses []string) (string, []*spdx.OtherLicense) { + var otherLicenses = make(map[string]*spdx.OtherLicense) // licenseID -> OtherLicense + + // Save text licenses as OtherLicense + for i, license := range licenses { + if strings.HasPrefix(license, licensing.LicenseTextPrefix) { + otherLicense := m.newOtherLicense(strings.TrimPrefix(license, licensing.LicenseTextPrefix), true) + otherLicenses[otherLicense.LicenseIdentifier] = otherLicense + licenses[i] = otherLicense.LicenseIdentifier + } } - return NormalizeLicense(c.Licenses) + + license := strings.Join(lo.Map(licenses, func(license string, index int) string { + // e.g. GPL-3.0-with-autoconf-exception + license = strings.ReplaceAll(license, "-with-", " WITH ") + license = strings.ReplaceAll(license, "-WITH-", " WITH ") + return fmt.Sprintf("(%s)", license) + }), " AND ") + + normalizedLicense, err := expression.Normalize(license, licensing.NormalizeLicense, expression.NormalizeForSPDX) + if err != nil { + // Not fail on the invalid license + m.logger.Warn("Unable to marshal SPDX licenses", log.String("license", license)) + return "", nil + } + + license, others := m.processNonSpdxLicenses(normalizedLicense) + allOtherLicenses := lo.Assign(otherLicenses, others) + + return license, lo.Ternary(len(allOtherLicenses) > 0, lo.Values(allOtherLicenses), nil) +} + +// newOtherLicense create new OtherLicense for license not included in the SPDX license list +func (m *Marshaler) newOtherLicense(license string, text bool) *spdx.OtherLicense { + otherLicense := spdx.OtherLicense{} + if text { + otherLicense.LicenseName = noAssertionField + otherLicense.ExtractedText = license + otherLicense.LicenseComment = "The license text represents text found in package metadata and may not represent the full text of the license" + } else { + otherLicense.LicenseName = license + otherLicense.ExtractedText = fmt.Sprintf("This component is licensed under %q", license) + } + licenseID, err := calcSPDXID(m.hasher, otherLicense) + if err != nil { + m.logger.Warn("Unable to calculate SPDX licenses ID", log.String("license", license), log.Err(err)) + return nil + } + otherLicense.LicenseIdentifier = LicenseRefPrefix + "-" + licenseID + + return &otherLicense +} + +// processNonSpdxLicenses detects licenses that are not on the SPDX list and creates OtherLicense for them +func (m *Marshaler) processNonSpdxLicenses(license string) (string, map[string]*spdx.OtherLicense) { + otherLicenses := make(map[string]*spdx.OtherLicense) + var andLicenses []string + for _, andLicense := range strings.Split(license, " AND ") { + var orLicenses []string + for _, orLicense := range strings.Split(andLicense, " OR ") { + // Handle brackets - e.g. GPL-2.0-or-later AND (LGPL-2.0-only OR LGPL-2.1-only) + startRune := lo.Ternary(strings.HasPrefix(orLicense, "("), "(", "") + endRune := lo.Ternary(strings.HasSuffix(orLicense, ")"), ")", "") + trimmedLicense := strings.TrimPrefix(strings.TrimSuffix(orLicense, ")"), "(") + // This is license from SPDX list of text license (already processed) + if strings.HasPrefix(trimmedLicense, LicenseRefPrefix) || expression.ValidSpdxLicense(trimmedLicense) { + orLicenses = append(orLicenses, orLicense) + continue + } + + // Save this license as OtherLicense + otherLicense := m.newOtherLicense(trimmedLicense, false) + otherLicenses[otherLicense.LicenseIdentifier] = otherLicense + orLicenses = append(orLicenses, startRune+otherLicense.LicenseIdentifier+endRune) + } + andLicenses = append(andLicenses, strings.Join(orLicenses, " OR ")) + } + + return strings.Join(andLicenses, " AND "), otherLicenses } func (m *Marshaler) spdxChecksums(digests []digest.Digest) []common.Checksum { @@ -435,7 +531,7 @@ func (m *Marshaler) spdxFiles(c *core.Component) ([]*spdx.File, error) { } func (m *Marshaler) spdxFile(filePath string, digests []digest.Digest) (*spdx.File, error) { - pkgID, err := calcPkgID(m.hasher, filePath) + pkgID, err := calcSPDXID(m.hasher, filePath) if err != nil { return nil, xerrors.Errorf("failed to get %s package ID: %w", filePath, err) } @@ -505,6 +601,20 @@ func sortFiles(files []*spdx.File) { }) } +// sortOtherLicenses removes duplicates and sorts result slice +func sortOtherLicenses(licenses []*spdx.OtherLicense) []*spdx.OtherLicense { + if len(licenses) == 0 { + return nil + } + licenses = lo.UniqBy(licenses, func(license *spdx.OtherLicense) string { + return license.LicenseIdentifier + }) + sort.Slice(licenses, func(i, j int) bool { + return licenses[i].LicenseIdentifier < licenses[j].LicenseIdentifier + }) + return licenses +} + func elementID(elementType, pkgID string) spdx.ElementID { return spdx.ElementID(fmt.Sprintf("%s-%s", elementType, pkgID)) } @@ -518,13 +628,13 @@ func getDocumentNamespace(root *core.Component) string { ) } -func calcPkgID(h Hash, v any) (string, error) { +func calcSPDXID(h Hash, v any) (string, error) { f, err := h(v, hashstructure.FormatV2, &hashstructure.HashOptions{ ZeroNil: true, SlicesAsSets: true, }) if err != nil { - return "", xerrors.Errorf("could not build package ID for %+v: %w", v, err) + return "", xerrors.Errorf("could not build component ID for %+v: %w", v, err) } return fmt.Sprintf("%x", f), nil @@ -550,20 +660,3 @@ func camelCase(inputUnderScoreStr string) (camelCase string) { } return } - -func NormalizeLicense(licenses []string) string { - license := strings.Join(lo.Map(licenses, func(license string, index int) string { - // e.g. GPL-3.0-with-autoconf-exception - license = strings.ReplaceAll(license, "-with-", " WITH ") - license = strings.ReplaceAll(license, "-WITH-", " WITH ") - - return fmt.Sprintf("(%s)", license) - }), " AND ") - s, err := expression.Normalize(license, licensing.NormalizeLicense, expression.NormalizeForSPDX) - if err != nil { - // Not fail on the invalid license - log.Warn("Unable to marshal SPDX licenses", log.String("license", license)) - return "" - } - return s -} diff --git a/pkg/sbom/spdx/marshal_private_test.go b/pkg/sbom/spdx/marshal_private_test.go new file mode 100644 index 000000000000..ec6db8afa812 --- /dev/null +++ b/pkg/sbom/spdx/marshal_private_test.go @@ -0,0 +1,128 @@ +package spdx + +import ( + "sort" + "testing" + + "github.com/spdx/tools-golang/spdx" + "github.com/stretchr/testify/assert" +) + +func TestMarshaler_normalizeLicenses(t *testing.T) { + tests := []struct { + name string + input []string + wantLicenseName string + wantOtherLicenses []*spdx.OtherLicense + }{ + { + name: "happy path", + input: []string{ + "GPLv2+", + }, + wantLicenseName: "GPL-2.0-or-later", + }, + { + name: "happy path with multi license", + input: []string{ + "GPLv2+", + "GPLv3+", + }, + wantLicenseName: "GPL-2.0-or-later AND GPL-3.0-or-later", + }, + { + name: "happy path with OR operator", + input: []string{ + "GPLv2+", + "LGPL 2.0 or GNU LESSER", + }, + wantLicenseName: "GPL-2.0-or-later AND (LGPL-2.0-only OR LGPL-2.1-only)", + }, + { + name: "happy path with OR operator with non-SPDX license", + input: []string{ + "GPLv2+", + "wrong-license or unknown-license", + }, + wantLicenseName: "GPL-2.0-or-later AND (LicenseRef-c581e42fe705aa48 OR LicenseRef-a0bb0951a6dfbdbe)", + wantOtherLicenses: []*spdx.OtherLicense{ + { + LicenseIdentifier: "LicenseRef-a0bb0951a6dfbdbe", + LicenseName: "unknown-license", + ExtractedText: `This component is licensed under "unknown-license"`, + }, + { + LicenseIdentifier: "LicenseRef-c581e42fe705aa48", + LicenseName: "wrong-license", + ExtractedText: `This component is licensed under "wrong-license"`, + }, + }, + }, + { + name: "happy path with AND operator", + input: []string{ + "GPLv2+", + "LGPL 2.0 and GNU LESSER", + }, + wantLicenseName: "GPL-2.0-or-later AND LGPL-2.0-only AND LGPL-2.1-only", + }, + { + name: "happy path with WITH operator", + input: []string{ + "AFL 2.0", + "AFL 3.0 with Autoconf-exception-3.0", + }, + wantLicenseName: "AFL-2.0 AND AFL-3.0 WITH Autoconf-exception-3.0", + }, + { + name: "happy path with non-SPDX exception", + input: []string{ + "AFL 2.0", + "AFL 3.0 with wrong-exceptions", + }, + wantLicenseName: "AFL-2.0 AND LicenseRef-51373b28fab165e9", + wantOtherLicenses: []*spdx.OtherLicense{ + { + LicenseIdentifier: "LicenseRef-51373b28fab165e9", + LicenseName: "AFL-3.0 WITH wrong-exceptions", + ExtractedText: `This component is licensed under "AFL-3.0 WITH wrong-exceptions"`, + }, + }, + }, + { + name: "happy path with text of license", + input: []string{ + "text://unknown-license", + "AFL 2.0", + "unknown-license", + }, + wantLicenseName: "LicenseRef-ffca10435cadded4 AND AFL-2.0 AND LicenseRef-a0bb0951a6dfbdbe", + wantOtherLicenses: []*spdx.OtherLicense{ + { + LicenseIdentifier: "LicenseRef-a0bb0951a6dfbdbe", + LicenseName: "unknown-license", + ExtractedText: `This component is licensed under "unknown-license"`, + }, + { + LicenseIdentifier: "LicenseRef-ffca10435cadded4", + LicenseName: "NOASSERTION", + ExtractedText: "unknown-license", + LicenseComment: "The license text represents text found in package metadata and may not represent the full text of the license", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewMarshaler("") + gotLicenseName, gotOtherLicenses := m.normalizeLicenses(tt.input) + // We will sort all OtherLicenses for SPDX document + // So we need to sort OtherLicenses for this test + sort.Slice(gotOtherLicenses, func(i, j int) bool { + return gotOtherLicenses[i].LicenseIdentifier < gotOtherLicenses[j].LicenseIdentifier + }) + assert.Equal(t, tt.wantLicenseName, gotLicenseName) + assert.Equal(t, tt.wantOtherLicenses, gotOtherLicenses) + }) + } +} diff --git a/pkg/sbom/spdx/marshal_test.go b/pkg/sbom/spdx/marshal_test.go index 4d9d33c013a7..5c8e3ab4902e 100644 --- a/pkg/sbom/spdx/marshal_test.go +++ b/pkg/sbom/spdx/marshal_test.go @@ -842,6 +842,148 @@ func TestMarshaler_Marshal(t *testing.T) { }, }, }, + { + name: "happy path with various licenses", + inputReport: types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "pom.xml", + ArtifactType: artifact.TypeFilesystem, + Results: types.Results{ + { + Target: "pom.xml", + Class: types.ClassLangPkg, + Type: ftypes.Pom, + Packages: []ftypes.Package{ + { + ID: "com.example:example:1.0.0", + Name: "com.example:example", + Version: "1.0.0", + Identifier: ftypes.PkgIdentifier{ + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "com.example", + Name: "example", + Version: "1.0.0", + }, + }, + Licenses: []string{ + "text://BSD-4-clause", + "BSD-4-clause or LGPL-2.0-only", + "AFL 3.0 with wrong-exceptions", + "AFL 3.0 with Autoconf-exception-3.0", + "text://UNKNOWN", + "UNKNOWN", + }, + }, + }, + }, + }, + }, + wantSBOM: &spdx.Document{ + SPDXVersion: spdx.Version, + DataLicense: spdx.DataLicense, + SPDXIdentifier: "DOCUMENT", + DocumentName: "pom.xml", + DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/pom.xml-3ff14136-e09f-4df9-80ea-000000000004", + CreationInfo: &spdx.CreationInfo{ + Creators: []common.Creator{ + { + Creator: "aquasecurity", + CreatorType: "Organization", + }, + { + Creator: "trivy-0.56.2", + CreatorType: "Tool", + }, + }, + Created: "2021-08-25T12:20:30Z", + }, + Packages: []*spdx.Package{ + { + PackageSPDXIdentifier: spdx.ElementID("Application-800d9e6e0f88ab3a"), + PackageDownloadLocation: "NONE", + PackageName: "pom.xml", + PrimaryPackagePurpose: tspdx.PackagePurposeApplication, + Annotations: []spdx.Annotation{ + annotation(t, "Class: lang-pkgs"), + annotation(t, "Type: pom"), + }, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Package-69cd7625c68537c7"), + PackageDownloadLocation: "NONE", + PackageName: "com.example:example", + PackageVersion: "1.0.0", + PackageLicenseConcluded: "LicenseRef-14b1606fb243e2b6 AND (BSD-4-Clause OR LGPL-2.0-only) AND LicenseRef-77bdf77d8292ce5b AND AFL-3.0 WITH Autoconf-exception-3.0 AND LicenseRef-229659393343e160 AND LicenseRef-a8d01765900624d3", + PackageLicenseDeclared: "LicenseRef-14b1606fb243e2b6 AND (BSD-4-Clause OR LGPL-2.0-only) AND LicenseRef-77bdf77d8292ce5b AND AFL-3.0 WITH Autoconf-exception-3.0 AND LicenseRef-229659393343e160 AND LicenseRef-a8d01765900624d3", + PackageExternalReferences: []*spdx.PackageExternalReference{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:maven/com.example/example@1.0.0", + }, + }, + PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, + PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, + PackageSourceInfo: "package found in: pom.xml", + Annotations: []spdx.Annotation{ + annotation(t, "PkgID: com.example:example:1.0.0"), + annotation(t, "PkgType: pom"), + }, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Filesystem-340a6f62df359d6a"), + PackageDownloadLocation: "NONE", + PackageName: "pom.xml", + Annotations: []spdx.Annotation{ + annotation(t, "SchemaVersion: 2"), + }, + PrimaryPackagePurpose: tspdx.PackagePurposeSource, + }, + }, + Relationships: []*spdx.Relationship{ + { + RefA: spdx.DocElementID{ElementRefID: "Application-800d9e6e0f88ab3a"}, + RefB: spdx.DocElementID{ElementRefID: "Package-69cd7625c68537c7"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "Filesystem-340a6f62df359d6a"}, + Relationship: "DESCRIBES", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Filesystem-340a6f62df359d6a"}, + RefB: spdx.DocElementID{ElementRefID: "Application-800d9e6e0f88ab3a"}, + Relationship: "CONTAINS", + }, + }, + OtherLicenses: []*spdx.OtherLicense{ + { + LicenseIdentifier: "LicenseRef-14b1606fb243e2b6", + LicenseName: "NOASSERTION", + ExtractedText: "BSD-4-clause", + LicenseComment: "The license text represents text found in package metadata and may not represent the full text of the license", + }, + { + LicenseIdentifier: "LicenseRef-229659393343e160", + LicenseName: "NOASSERTION", + ExtractedText: "UNKNOWN", + LicenseComment: "The license text represents text found in package metadata and may not represent the full text of the license", + }, + { + LicenseIdentifier: "LicenseRef-77bdf77d8292ce5b", + LicenseName: "AFL-3.0 WITH wrong-exceptions", + ExtractedText: `This component is licensed under "AFL-3.0 WITH wrong-exceptions"`, + }, + { + LicenseIdentifier: "LicenseRef-a8d01765900624d3", + LicenseName: "UNKNOWN", + ExtractedText: `This component is licensed under "UNKNOWN"`, + }, + }, + }, + }, { name: "happy path with vulnerability", inputReport: types.Report{ @@ -1324,6 +1466,8 @@ func TestMarshaler_Marshal(t *testing.T) { for _, f := range vv.Files { str += f.Path } + case spdx.OtherLicense: + str = vv.ExtractedText + vv.LicenseName case string: str = vv default: @@ -1349,56 +1493,3 @@ func TestMarshaler_Marshal(t *testing.T) { }) } } - -func Test_GetLicense(t *testing.T) { - tests := []struct { - name string - input []string - want string - }{ - { - name: "happy path", - input: []string{ - "GPLv2+", - }, - want: "GPL-2.0-or-later", - }, - { - name: "happy path with multi license", - input: []string{ - "GPLv2+", - "GPLv3+", - }, - want: "GPL-2.0-or-later AND GPL-3.0-or-later", - }, - { - name: "happy path with OR operator", - input: []string{ - "GPLv2+", - "LGPL 2.0 or GNU LESSER", - }, - want: "GPL-2.0-or-later AND (LGPL-2.0-only OR LGPL-2.1-only)", - }, - { - name: "happy path with AND operator", - input: []string{ - "GPLv2+", - "LGPL 2.0 and GNU LESSER", - }, - want: "GPL-2.0-or-later AND LGPL-2.0-only AND LGPL-2.1-only", - }, - { - name: "happy path with WITH operator", - input: []string{ - "AFL 2.0", - "AFL 3.0 with distribution exception", - }, - want: "AFL-2.0 AND AFL-3.0 WITH distribution-exception", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, tspdx.NormalizeLicense(tt.input)) - }) - } -}