diff --git a/.github/workflows/spdx-cron.yaml b/.github/workflows/spdx-cron.yaml new file mode 100644 index 000000000000..97abf087e85e --- /dev/null +++ b/.github/workflows/spdx-cron.yaml @@ -0,0 +1,33 @@ +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: 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 87e556d86052..a95b7150957a 100644 --- a/magefiles/magefile.go +++ b/magefiles/magefile.go @@ -533,3 +533,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..258afcd0b1cd --- /dev/null +++ b/magefiles/spdx.go @@ -0,0 +1,78 @@ +//go:build mage_spdx + +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + + "github.com/samber/lo" + "golang.org/x/xerrors" + + "github.com/aquasecurity/trivy/pkg/downloader" + "github.com/aquasecurity/trivy/pkg/log" +) + +const ( + exceptionFileName = "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, exceptionFileName)) + 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 ex.ID + }) + sort.Strings(exs) + + exceptionFile := filepath.Join(exceptionDir, exceptionFileName) + 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..d9655516b397 100644 --- a/pkg/licensing/expression/category.go +++ b/pkg/licensing/expression/category.go @@ -1,5 +1,18 @@ package expression +import ( + "encoding/json" + "strings" + "sync" + + "github.com/samber/lo" + + "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/set" + + _ "embed" +) + // Canonical names of the licenses. // ported from https://github.com/google/licenseclassifier/blob/7c62d6fe8d3aa2f39c4affb58c9781d9dc951a2d/license_type.go#L24-L177 const ( @@ -359,3 +372,70 @@ var ( ZeroBSD, } ) + +var spdxLicenses = set.New[string]() + +var initSpdxLicenses = sync.OnceFunc(func() { + if spdxLicenses.Size() > 0 { + return + } + + licenseSlices := [][]string{ + ForbiddenLicenses, + RestrictedLicenses, + ReciprocalLicenses, + NoticeLicenses, + PermissiveLicenses, + UnencumberedLicenses, + } + + for _, licenseSlice := range licenseSlices { + spdxLicenses.Append(licenseSlice...) + } + + // Save GNU licenses with "-or-later" and `"-only" suffixes + for _, l := range GnuLicenses { + license := SimpleExpr{ + License: l, + } + spdxLicenses.Append(license.String()) + + license.HasPlus = true + spdxLicenses.Append(license.String()) + } +}) + +//go:embed exceptions.json +var exceptions []byte + +var spdxExceptions map[string]SimpleExpr + +var initSpdxExceptions = sync.OnceFunc(func() { + if len(spdxExceptions) > 0 { + return + } + + var exs []string + if err := json.Unmarshal(exceptions, &exs); err != nil { + log.WithPrefix(log.PrefixSPDX).Warn("Unable to parse SPDX exception file", log.Err(err)) + return + } + spdxExceptions = lo.SliceToMap(exs, func(exception string) (string, SimpleExpr) { + return strings.ToUpper(exception), SimpleExpr{License: exception} + }) +}) + +// ValidateSPDXLicense returns true if SPDX license list contain licenseID +func ValidateSPDXLicense(license string) bool { + initSpdxLicenses() + + return spdxLicenses.Contains(license) +} + +// ValidateSPDXException returns true if SPDX exception list contain exceptionID +func ValidateSPDXException(exception string) bool { + initSpdxExceptions() + + _, ok := spdxExceptions[strings.ToUpper(exception)] + return ok +} diff --git a/pkg/licensing/expression/exceptions.json b/pkg/licensing/expression/exceptions.json new file mode 100644 index 000000000000..3fb5cc14d958 --- /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","CGAL-linking-exception","CLISP-exception-2.0","Classpath-exception-2.0","DigiRule-FOSS-exception","FLTK-exception","Fawkes-Runtime-exception","Font-exception-2.0","GCC-exception-2.0","GCC-exception-2.0-note","GCC-exception-3.1","GNAT-exception","GNOME-examples-exception","GNU-compiler-exception","GPL-3.0-389-ds-base-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","Gmsh-exception","Independent-modules-exception","KiCad-libraries-exception","LGPL-3.0-linking-exception","LLGPL","LLVM-exception","LZMA-exception","Libtool-exception","Linux-syscall-note","Nokia-Qt-exception-1.1","OCCT-exception-1.0","OCaml-LGPL-linking-exception","OpenJDK-assembly-exception-1.0","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","RRDtool-FLOSS-exception-2.0","SANE-exception","SHL-2.0","SHL-2.1","SWI-exception","Swift-exception","Texinfo-exception","UBDL-exception","Universal-FOSS-exception-1.0","WxWindows-exception-3.1","cryptsetup-OpenSSL-exception","eCos-exception-2.0","erlang-otp-linking-exception","fmt-exception","freertos-exception-2.0","gnu-javamail-exception","harbour-exception","i2p-gpl-java-exception","libpri-OpenH323-exception","mif-exception","mxml-exception","openvpn-openssl-exception","romic-exception","stunnel-exception","u-boot-exception-2.0","vsftpd-openssl-exception","x11vnc-openssl-exception"] \ No newline at end of file diff --git a/pkg/licensing/expression/expression.go b/pkg/licensing/expression/expression.go index 24b19b483d0d..3758408da72c 100644 --- a/pkg/licensing/expression/expression.go +++ b/pkg/licensing/expression/expression.go @@ -59,26 +59,33 @@ func normalize(expr Expression, fn NormalizeFunc) Expression { // There MUST be white space on either side of the operator "WITH". // ref: https://spdx.github.io/spdx-spec/v2.3/SPDX-license-expressions func NormalizeForSPDX(expr Expression) Expression { - e, ok := expr.(SimpleExpr) - if !ok { - return expr // do not normalize compound expressions - } - - var b strings.Builder - for _, c := range e.License { - switch { - // spec: idstring = 1*(ALPHA / DIGIT / "-" / "." ) - case isAlphabet(c) || unicode.IsNumber(c) || c == '-' || c == '.': - _, _ = b.WriteRune(c) - case c == ':': - // TODO: Support DocumentRef - _, _ = b.WriteRune(c) - default: - // Replace invalid characters with '-' - _, _ = b.WriteRune('-') + switch e := expr.(type) { + case SimpleExpr: + var b strings.Builder + for _, c := range e.License { + switch { + // spec: idstring = 1*(ALPHA / DIGIT / "-" / "." ) + case isAlphabet(c) || unicode.IsNumber(c) || c == '-' || c == '.': + _, _ = b.WriteRune(c) + case c == ':': + // TODO: Support DocumentRef + _, _ = b.WriteRune(c) + default: + // Replace invalid characters with '-' + _, _ = b.WriteRune('-') + } + } + return SimpleExpr{License: b.String(), HasPlus: e.HasPlus} + case CompoundExpr: + if e.Conjunction() == TokenWith { + initSpdxExceptions() + // Use correct SPDX exceptionID + if exc, ok := spdxExceptions[strings.ToUpper(e.Right().String())]; ok { + return NewCompoundExpr(e.Left(), e.Conjunction(), exc) + } } } - return SimpleExpr{License: b.String(), HasPlus: e.HasPlus} + return expr } func isAlphabet(r rune) bool { diff --git a/pkg/licensing/expression/types.go b/pkg/licensing/expression/types.go index 280cc7d70687..7399f74624c7 100644 --- a/pkg/licensing/expression/types.go +++ b/pkg/licensing/expression/types.go @@ -56,6 +56,14 @@ func (c CompoundExpr) Conjunction() Token { return c.conjunction } +func (c CompoundExpr) Left() Expression { + return c.left +} + +func (c CompoundExpr) Right() Expression { + return c.right +} + func (c CompoundExpr) String() string { left := c.left.String() if l, ok := c.left.(CompoundExpr); ok { diff --git a/pkg/log/logger.go b/pkg/log/logger.go index 2948886b9559..5600c0d6b3ae 100644 --- a/pkg/log/logger.go +++ b/pkg/log/logger.go @@ -25,6 +25,7 @@ const ( PrefixLicense = "license" PrefixVulnerabilityDB = "vulndb" PrefixJavaDB = "javadb" + PrefixSPDX = "spdx" ) // Logger is an alias of slog.Logger diff --git a/pkg/sbom/spdx/marshal.go b/pkg/sbom/spdx/marshal.go index 51f9144f682d..b99e28a0fc90 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(log.PrefixSPDX), } 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,94 @@ 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 NormalizeLicense(c.Licenses) + return m.normalizeLicenses(c.Licenses) +} + +func (m *Marshaler) normalizeLicenses(licenses []string) (string, []*spdx.OtherLicense) { + var otherLicenses = make(map[string]*spdx.OtherLicense) // licenseID -> OtherLicense + + 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 ") + + replaceOtherLicenses := func(expr expression.Expression) expression.Expression { + var licenseName string + var textLicense bool + switch e := expr.(type) { + case expression.SimpleExpr: + // Trim `text:--` prefix (expression.NormalizeForSPDX normalized `text://` prefix) + if strings.HasPrefix(e.License, "text:--") { + textLicense = true + e.License = strings.TrimPrefix(e.License, "text:--") + } + + if expression.ValidateSPDXLicense(e.License) || expression.ValidateSPDXException(e.License) { + return e + } + + licenseName = e.License + case expression.CompoundExpr: + // Check only CompoundExpr with `WITH` token as one license + if e.Conjunction() != expression.TokenWith { + return expr + } + + // Check that license and exception are valid + if expression.ValidateSPDXLicense(e.Left().String()) && expression.ValidateSPDXException(e.Right().String()) { + // Use SimpleExpr for a valid SPDX license with an exception, + // to avoid parsing the license and exception separately. + return e + } + + licenseName = e.String() + } + + l := m.newOtherLicense(licenseName, textLicense) + otherLicenses[l.LicenseIdentifier] = l + return expression.SimpleExpr{License: l.LicenseIdentifier} + } + + normalizedLicense, err := expression.Normalize(license, licensing.NormalizeLicense, expression.NormalizeForSPDX, replaceOtherLicenses) + if err != nil { + // Not fail on the invalid license + m.logger.Warn("Unable to marshal SPDX licenses", log.String("license", license)) + return "", nil + } + + return normalizedLicense, lo.Ternary(len(otherLicenses) > 0, lo.Values(otherLicenses), 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 { + // This must be an unattainable case. + m.logger.Warn("Unable to calculate SPDX licenses ID", log.String("license", license), log.Err(err)) + licenseID = license + } + otherLicense.LicenseIdentifier = LicenseRefPrefix + "-" + licenseID + + return &otherLicense } func (m *Marshaler) spdxChecksums(digests []digest.Digest) []common.Checksum { @@ -435,7 +529,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 +599,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 +626,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 +658,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..3f2161bd40ed --- /dev/null +++ b/pkg/sbom/spdx/marshal_private_test.go @@ -0,0 +1,136 @@ +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+", + "BSD-4-Clause", + }, + wantLicenseName: "GPL-2.0-or-later AND GPL-3.0-or-later AND BSD-4-Clause", + }, + { + 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 incorrect cases for license and exception", + input: []string{ + "afl 3.0 with autoCONF-exception-3.0", + }, + wantLicenseName: "AFL-3.0 WITH Autoconf-exception-3.0", + }, + { + 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)) - }) - } -}