mirror of
https://github.com/aquasecurity/trivy.git
synced 2025-12-12 07:40:48 -08:00
fix(spdx): use the hasExtractedLicensingInfos field for licenses that are not listed in the SPDX (#8077)
This commit is contained in:
33
.github/workflows/spdx-cron.yaml
vendored
Normal file
33
.github/workflows/spdx-cron.yaml
vendored
Normal file
@@ -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) }}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
78
magefiles/spdx.go
Normal file
78
magefiles/spdx.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
1
pkg/licensing/expression/exceptions.json
Normal file
1
pkg/licensing/expression/exceptions.json
Normal file
@@ -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"]
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
PrefixLicense = "license"
|
||||
PrefixVulnerabilityDB = "vulndb"
|
||||
PrefixJavaDB = "javadb"
|
||||
PrefixSPDX = "spdx"
|
||||
)
|
||||
|
||||
// Logger is an alias of slog.Logger
|
||||
|
||||
@@ -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 {
|
||||
if len(c.Licenses) == 0 {
|
||||
return noAssertionField
|
||||
func (m *Marshaler) spdxLicense(c *core.Component) (string, []*spdx.OtherLicense) {
|
||||
// Only library components contain licenses
|
||||
if c.Type != core.TypeLibrary {
|
||||
return "", nil
|
||||
}
|
||||
return NormalizeLicense(c.Licenses)
|
||||
if len(c.Licenses) == 0 {
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
136
pkg/sbom/spdx/marshal_private_test.go
Normal file
136
pkg/sbom/spdx/marshal_private_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user