feat(cyclonedx): preserve SBOM structure when scanning SBOM files with vulnerability updates (#9439)

Signed-off-by: knqyf263 <knqyf263@gmail.com>
Co-authored-by: DmitriyLewen <dmitriy.lewen@smartforce.io>
This commit is contained in:
Teppei Fukuda
2025-09-20 18:26:53 +04:00
committed by GitHub
parent 8b2575bd27
commit aff03ebab2
14 changed files with 452 additions and 171 deletions

View File

@@ -3,7 +3,7 @@
"dataLicense": "CC0-1.0", "dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT", "SPDXID": "SPDXRef-DOCUMENT",
"name": "testdata/fixtures/repo/conda", "name": "testdata/fixtures/repo/conda",
"documentNamespace": "http://trivy.dev/filesystem/testdata/fixtures/repo/conda-3ff14136-e09f-4df9-80ea-000000000004", "documentNamespace": "http://trivy.dev/filesystem/testdata/fixtures/repo/conda-3ff14136-e09f-4df9-80ea-000000000005",
"creationInfo": { "creationInfo": {
"creators": [ "creators": [
"Organization: aquasecurity", "Organization: aquasecurity",
@@ -14,7 +14,7 @@
"packages": [ "packages": [
{ {
"name": "openssl", "name": "openssl",
"SPDXID": "SPDXRef-Package-22a178da112ac20a", "SPDXID": "SPDXRef-Package-cb268df467bc826c",
"versionInfo": "1.1.1q", "versionInfo": "1.1.1q",
"supplier": "NOASSERTION", "supplier": "NOASSERTION",
"downloadLocation": "NONE", "downloadLocation": "NONE",
@@ -43,7 +43,7 @@
}, },
{ {
"name": "pip", "name": "pip",
"SPDXID": "SPDXRef-Package-c22b9ee9a601ba6", "SPDXID": "SPDXRef-Package-1378bb10fcebba63",
"versionInfo": "22.2.2", "versionInfo": "22.2.2",
"supplier": "NOASSERTION", "supplier": "NOASSERTION",
"downloadLocation": "NONE", "downloadLocation": "NONE",
@@ -118,23 +118,23 @@
}, },
{ {
"spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef", "spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef",
"relatedSpdxElement": "SPDXRef-Package-22a178da112ac20a", "relatedSpdxElement": "SPDXRef-Package-1378bb10fcebba63",
"relationshipType": "CONTAINS" "relationshipType": "CONTAINS"
}, },
{ {
"spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef", "spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef",
"relatedSpdxElement": "SPDXRef-Package-c22b9ee9a601ba6", "relatedSpdxElement": "SPDXRef-Package-cb268df467bc826c",
"relationshipType": "CONTAINS" "relationshipType": "CONTAINS"
}, },
{ {
"spdxElementId": "SPDXRef-Package-22a178da112ac20a", "spdxElementId": "SPDXRef-Package-1378bb10fcebba63",
"relatedSpdxElement": "SPDXRef-File-600e5e0110a84891",
"relationshipType": "CONTAINS"
},
{
"spdxElementId": "SPDXRef-Package-c22b9ee9a601ba6",
"relatedSpdxElement": "SPDXRef-File-7eb62e2a3edddc0a", "relatedSpdxElement": "SPDXRef-File-7eb62e2a3edddc0a",
"relationshipType": "CONTAINS" "relationshipType": "CONTAINS"
},
{
"spdxElementId": "SPDXRef-Package-cb268df467bc826c",
"relatedSpdxElement": "SPDXRef-File-600e5e0110a84891",
"relationshipType": "CONTAINS"
} }
] ]
} }

View File

@@ -2,7 +2,7 @@
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX", "bomFormat": "CycloneDX",
"specVersion": "1.6", "specVersion": "1.6",
"serialNumber": "urn:uuid:3ff14136-e09f-4df9-80ea-000000000010", "serialNumber": "urn:uuid:3ff14136-e09f-4df9-80ea-000000000006",
"version": 1, "version": 1,
"metadata": { "metadata": {
"timestamp": "2021-08-25T12:20:30+00:00", "timestamp": "2021-08-25T12:20:30+00:00",
@@ -91,14 +91,6 @@
"name": "aquasecurity:trivy:LayerDigest", "name": "aquasecurity:trivy:LayerDigest",
"value": "sha256:000eee12ec04cc914bf96e8f5dee7767510c2aca3816af6078bd9fbe3150920c" "value": "sha256:000eee12ec04cc914bf96e8f5dee7767510c2aca3816af6078bd9fbe3150920c"
}, },
{
"name": "aquasecurity:trivy:PkgID",
"value": "bash@5.0-4"
},
{
"name": "aquasecurity:trivy:PkgType",
"value": "debian"
},
{ {
"name": "aquasecurity:trivy:SrcName", "name": "aquasecurity:trivy:SrcName",
"value": "bash" "value": "bash"
@@ -124,14 +116,6 @@
"name": "aquasecurity:trivy:LayerDigest", "name": "aquasecurity:trivy:LayerDigest",
"value": "sha256:000eee12ec04cc914bf96e8f5dee7767510c2aca3816af6078bd9fbe3150920c" "value": "sha256:000eee12ec04cc914bf96e8f5dee7767510c2aca3816af6078bd9fbe3150920c"
}, },
{
"name": "aquasecurity:trivy:PkgID",
"value": "libidn2-0@2.0.5-1"
},
{
"name": "aquasecurity:trivy:PkgType",
"value": "debian"
},
{ {
"name": "aquasecurity:trivy:SrcName", "name": "aquasecurity:trivy:SrcName",
"value": "libidn2" "value": "libidn2"
@@ -169,11 +153,7 @@
"value": "sha256:a8877cad19f14a7044524a145ce33170085441a7922458017db1631dcd5f7602" "value": "sha256:a8877cad19f14a7044524a145ce33170085441a7922458017db1631dcd5f7602"
}, },
{ {
"name": "aquasecurity:trivy:PkgID", "name": "aquasecurity:trivy:Type",
"value": "activesupport@6.0.2.1"
},
{
"name": "aquasecurity:trivy:PkgType",
"value": "gemspec" "value": "gemspec"
} }
] ]
@@ -193,18 +173,6 @@
"353f2470-9c8b-4647-9d0d-96d893838dc8", "353f2470-9c8b-4647-9d0d-96d893838dc8",
"pkg:gem/activesupport@6.0.2.1?file_path=var%2Flib%2Fgems%2F2.5.0%2Fspecifications%2Factivesupport-6.0.2.1.gemspec" "pkg:gem/activesupport@6.0.2.1?file_path=var%2Flib%2Fgems%2F2.5.0%2Fspecifications%2Factivesupport-6.0.2.1.gemspec"
] ]
},
{
"ref": "pkg:deb/debian/bash@5.0-4?distro=debian-10.2",
"dependsOn": []
},
{
"ref": "pkg:deb/debian/libidn2-0@2.0.5-1?distro=debian-10.2",
"dependsOn": []
},
{
"ref": "pkg:gem/activesupport@6.0.2.1?file_path=var%2Flib%2Fgems%2F2.5.0%2Fspecifications%2Factivesupport-6.0.2.1.gemspec",
"dependsOn": []
} }
], ],
"vulnerabilities": [ "vulnerabilities": [

View File

@@ -14,7 +14,7 @@
"packages": [ "packages": [
{ {
"name": "Manifest.toml", "name": "Manifest.toml",
"SPDXID": "SPDXRef-Application-18fc3597717a3e56", "SPDXID": "SPDXRef-Application-c39d15beb6bdf085",
"downloadLocation": "NONE", "downloadLocation": "NONE",
"filesAnalyzed": false, "filesAnalyzed": false,
"primaryPackagePurpose": "APPLICATION", "primaryPackagePurpose": "APPLICATION",
@@ -35,7 +35,7 @@
}, },
{ {
"name": "A", "name": "A",
"SPDXID": "SPDXRef-Package-761ce79b41d8f121", "SPDXID": "SPDXRef-Package-3aea0b160c3af98d",
"versionInfo": "1.9.0", "versionInfo": "1.9.0",
"supplier": "NOASSERTION", "supplier": "NOASSERTION",
"downloadLocation": "NONE", "downloadLocation": "NONE",
@@ -68,7 +68,7 @@
}, },
{ {
"name": "B", "name": "B",
"SPDXID": "SPDXRef-Package-28f04edc422602a", "SPDXID": "SPDXRef-Package-2264d5c424c073e7",
"versionInfo": "1.9.0", "versionInfo": "1.9.0",
"supplier": "NOASSERTION", "supplier": "NOASSERTION",
"downloadLocation": "NONE", "downloadLocation": "NONE",
@@ -101,7 +101,7 @@
}, },
{ {
"name": "B", "name": "B",
"SPDXID": "SPDXRef-Package-6e0b0d1825d8c02c", "SPDXID": "SPDXRef-Package-e29bcba688483642",
"versionInfo": "1.9.0", "versionInfo": "1.9.0",
"supplier": "NOASSERTION", "supplier": "NOASSERTION",
"downloadLocation": "NONE", "downloadLocation": "NONE",
@@ -150,13 +150,13 @@
], ],
"relationships": [ "relationships": [
{ {
"spdxElementId": "SPDXRef-Application-18fc3597717a3e56", "spdxElementId": "SPDXRef-Application-c39d15beb6bdf085",
"relatedSpdxElement": "SPDXRef-Package-6e0b0d1825d8c02c", "relatedSpdxElement": "SPDXRef-Package-3aea0b160c3af98d",
"relationshipType": "CONTAINS" "relationshipType": "CONTAINS"
}, },
{ {
"spdxElementId": "SPDXRef-Application-18fc3597717a3e56", "spdxElementId": "SPDXRef-Application-c39d15beb6bdf085",
"relatedSpdxElement": "SPDXRef-Package-761ce79b41d8f121", "relatedSpdxElement": "SPDXRef-Package-e29bcba688483642",
"relationshipType": "CONTAINS" "relationshipType": "CONTAINS"
}, },
{ {
@@ -166,12 +166,12 @@
}, },
{ {
"spdxElementId": "SPDXRef-Filesystem-1be792dd0077c431", "spdxElementId": "SPDXRef-Filesystem-1be792dd0077c431",
"relatedSpdxElement": "SPDXRef-Application-18fc3597717a3e56", "relatedSpdxElement": "SPDXRef-Application-c39d15beb6bdf085",
"relationshipType": "CONTAINS" "relationshipType": "CONTAINS"
}, },
{ {
"spdxElementId": "SPDXRef-Package-761ce79b41d8f121", "spdxElementId": "SPDXRef-Package-3aea0b160c3af98d",
"relatedSpdxElement": "SPDXRef-Package-28f04edc422602a", "relatedSpdxElement": "SPDXRef-Package-2264d5c424c073e7",
"relationshipType": "DEPENDS_ON" "relationshipType": "DEPENDS_ON"
} }
] ]

View File

@@ -374,15 +374,6 @@ func Run(ctx context.Context, opts flag.Options, targetKind TargetKind) (err err
} }
}() }()
if opts.ServerAddr != "" && opts.Scanners.AnyEnabled(types.MisconfigScanner, types.SecretScanner) {
log.WarnContext(ctx,
fmt.Sprintf(
"Trivy runs in client/server mode, but misconfiguration and license scanning will be done on the client side, see %s",
doc.URL("/docs/references/modes/client-server", ""),
),
)
}
if opts.GenerateDefaultConfig { if opts.GenerateDefaultConfig {
log.Info("Writing the default config to trivy-default.yaml...") log.Info("Writing the default config to trivy-default.yaml...")
@@ -423,6 +414,9 @@ func Run(ctx context.Context, opts flag.Options, targetKind TargetKind) (err err
} }
func run(ctx context.Context, opts flag.Options, targetKind TargetKind) (types.Report, error) { func run(ctx context.Context, opts flag.Options, targetKind TargetKind) (types.Report, error) {
// Perform validation checks
checkOptions(ctx, opts, targetKind)
r, err := NewRunner(ctx, opts, targetKind) r, err := NewRunner(ctx, opts, targetKind)
if err != nil { if err != nil {
if errors.Is(err, SkipScan) { if errors.Is(err, SkipScan) {
@@ -466,6 +460,27 @@ func run(ctx context.Context, opts flag.Options, targetKind TargetKind) (types.R
return report, nil return report, nil
} }
// checkOptions performs various checks on scan options and shows warnings
func checkOptions(ctx context.Context, opts flag.Options, targetKind TargetKind) {
// Check client/server mode with misconfiguration and secret scanning
if opts.ServerAddr != "" && opts.Scanners.AnyEnabled(types.MisconfigScanner, types.SecretScanner) {
log.WarnContext(ctx,
fmt.Sprintf(
"Trivy runs in client/server mode, but misconfiguration and license scanning will be done on the client side, see %s",
doc.URL("/docs/references/modes/client-server", ""),
),
)
}
// Check SBOM to SBOM scanning with package filtering flags
// For SBOM-to-SBOM scanning (for example, to add vulnerabilities to the SBOM file), we should not modify the scanned file.
// cf. https://github.com/aquasecurity/trivy/pull/9439#issuecomment-3295533665
if targetKind == TargetSBOM && slices.Contains(types.SupportedSBOMFormats, opts.Format) &&
(!slices.Equal(opts.PkgTypes, types.PkgTypes) || !slices.Equal(opts.PkgRelationships, ftypes.Relationships)) {
log.Warn("'--pkg-types' and '--pkg-relationships' options will be ignored when scanning SBOM and outputting SBOM format.")
}
}
func disabledAnalyzers(opts flag.Options) []analyzer.Type { func disabledAnalyzers(opts flag.Options) []analyzer.Type {
// Specified analyzers to be disabled depending on scanning modes // Specified analyzers to be disabled depending on scanning modes
// e.g. The 'image' subcommand should disable the lock file scanning. // e.g. The 'image' subcommand should disable the lock file scanning.

View File

@@ -367,6 +367,89 @@ func TestArtifact_Inspect(t *testing.T) {
}, },
}, },
}, },
{
name: "components with missing BOM-REF",
filePath: filepath.Join("testdata", "bom-missing-refs.json"),
wantBlobs: []cachetest.WantBlob{
{
ID: "sha256:512b9e999c9d7b4880c63ce55c2c74ea5c22b05cdbcb486097a16ec692c746a0",
BlobInfo: types.BlobInfo{
SchemaVersion: types.BlobJSONSchemaVersion,
OS: types.OS{
Family: "alpine",
Name: "3.16.0",
},
PackageInfos: []types.PackageInfo{
{
Packages: types.Packages{
{
ID: "musl@1.2.3-r0",
Name: "musl",
Version: "1.2.3-r0",
SrcName: "musl",
SrcVersion: "1.2.3-r0",
Licenses: []string{"MIT"},
Layer: types.Layer{
DiffID: "sha256:dd565ff850e7003356e2b252758f9bdc1ff2803f61e995e24c7844f6297f8fc3",
},
Identifier: types.PkgIdentifier{
PURL: &packageurl.PackageURL{
Type: packageurl.TypeApk,
Namespace: "alpine",
Name: "musl",
Version: "1.2.3-r0",
Qualifiers: packageurl.Qualifiers{
{
Key: "distro",
Value: "3.16.0",
},
},
},
// BOM-Ref should be auto-generated from PURL
BOMRef: "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0",
},
},
},
},
},
Applications: []types.Application{
{
Type: "composer",
FilePath: "",
Packages: types.Packages{
{
ID: "pear/log@1.13.1",
Name: "pear/log",
Version: "1.13.1",
Layer: types.Layer{
DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1",
},
Identifier: types.PkgIdentifier{
PURL: &packageurl.PackageURL{
Type: packageurl.TypeComposer,
Namespace: "pear",
Name: "log",
Version: "1.13.1",
},
// BOM-Ref should be auto-generated from PURL
BOMRef: "pkg:composer/pear/log@1.13.1",
},
},
},
},
},
},
},
},
want: artifact.Reference{
Name: filepath.Join("testdata", "bom-missing-refs.json"),
Type: types.TypeCycloneDX,
ID: "sha256:512b9e999c9d7b4880c63ce55c2c74ea5c22b05cdbcb486097a16ec692c746a0",
BlobIDs: []string{
"sha256:512b9e999c9d7b4880c63ce55c2c74ea5c22b05cdbcb486097a16ec692c746a0",
},
},
},
{ {
name: "sad path with no such directory", name: "sad path with no such directory",
filePath: filepath.Join("testdata", "unknown.json"), filePath: filepath.Join("testdata", "unknown.json"),

View File

@@ -0,0 +1,89 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:c986ba94-e37d-49c8-9e30-96daccd0415b",
"version": 1,
"metadata": {
"timestamp": "2022-05-28T10:20:03.79527Z",
"tools": {
"components": [
{
"type": "application",
"group": "aquasecurity",
"name": "trivy",
"version": "dev"
}
]
},
"component": {
"bom-ref": "0f585d64-4815-4b72-92c5-97dae191fa4a",
"type": "container",
"name": "test-project",
"properties": [
{
"name": "aquasecurity:trivy:SchemaVersion",
"value": "2"
}
]
}
},
"components": [
{
"type": "library",
"name": "musl",
"version": "1.2.3-r0",
"licenses": [
{
"expression": "MIT"
}
],
"purl": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0",
"properties": [
{
"name": "aquasecurity:trivy:LayerDiffID",
"value": "sha256:dd565ff850e7003356e2b252758f9bdc1ff2803f61e995e24c7844f6297f8fc3"
}
]
},
{
"bom-ref": "60e9f57b-d4a6-4f71-ad14-0893ac609182",
"type": "operating-system",
"name": "alpine",
"version": "3.16.0",
"properties": [
{
"name": "aquasecurity:trivy:Type",
"value": "alpine"
},
{
"name": "aquasecurity:trivy:Class",
"value": "os-pkgs"
}
]
},
{
"type": "library",
"name": "pear/log",
"version": "1.13.1",
"purl": "pkg:composer/pear/log@1.13.1",
"properties": [
{
"name": "aquasecurity:trivy:LayerDiffID",
"value": "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1"
}
]
}
],
"dependencies": [
{
"ref": "60e9f57b-d4a6-4f71-ad14-0893ac609182"
},
{
"ref": "0f585d64-4815-4b72-92c5-97dae191fa4a",
"dependsOn": [
"60e9f57b-d4a6-4f71-ad14-0893ac609182"
]
}
],
"vulnerabilities": []
}

View File

@@ -1,8 +1,11 @@
package core package core
import ( import (
"slices"
"sort" "sort"
"github.com/samber/lo"
dtypes "github.com/aquasecurity/trivy-db/pkg/types" dtypes "github.com/aquasecurity/trivy-db/pkg/types"
"github.com/aquasecurity/trivy/pkg/digest" "github.com/aquasecurity/trivy/pkg/digest"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
@@ -164,6 +167,18 @@ func (c *Component) ID() uuid.UUID {
return c.id return c.id
} }
// Clone creates a deep copy of the Component
func (c *Component) Clone() *Component {
clone := *c // Shallow copy
// Deep copy slices using slices.Clone
clone.Licenses = slices.Clone(c.Licenses)
clone.Files = slices.Clone(c.Files)
clone.Properties = slices.Clone(c.Properties)
return &clone
}
type File struct { type File struct {
// Path is a path of the file. // Path is a path of the file.
// CycloneDX: N/A // CycloneDX: N/A
@@ -181,6 +196,9 @@ type Property struct {
Name string Name string
Value string Value string
Namespace string Namespace string
// External indicates if this property came from external source (not generated by Trivy)
// When false (default), Trivy namespace prefix will be applied during marshaling
External bool
} }
type Properties []Property type Properties []Property
@@ -352,3 +370,43 @@ func (b *BOM) bomRef(c *Component) string {
} }
return p return p
} }
// Clone creates a deep copy of the BOM, including all components, relationships, and vulnerabilities.
// This ensures that modifications to the cloned BOM do not affect the original BOM.
func (b *BOM) Clone() *BOM {
return &BOM{
SerialNumber: b.SerialNumber,
Version: b.Version,
rootID: b.rootID,
// Deep copy components
components: lo.MapValues(b.components, func(c *Component, _ uuid.UUID) *Component {
return c.Clone()
}),
// Deep copy relationships
relationships: lo.MapValues(b.relationships, func(rels []Relationship, _ uuid.UUID) []Relationship {
return slices.Clone(rels)
}),
// Deep copy vulnerabilities
vulnerabilities: lo.MapValues(b.vulnerabilities, func(vulns []Vulnerability, _ uuid.UUID) []Vulnerability {
return slices.Clone(vulns)
}),
// Deep copy external references
externalReferences: slices.Clone(b.externalReferences),
// Deep copy purls
purls: lo.MapValues(b.purls, func(ids []uuid.UUID, _ string) []uuid.UUID {
return slices.Clone(ids)
}),
// Deep copy parents
parents: lo.MapValues(b.parents, func(parentIds []uuid.UUID, _ uuid.UUID) []uuid.UUID {
return slices.Clone(parentIds)
}),
opts: b.opts,
}
}

View File

@@ -1,6 +1,7 @@
package cyclonedx package cyclonedx
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"net/url" "net/url"
@@ -358,13 +359,13 @@ func (m *Marshaler) normalizeLicense(license string) expression.Expression {
func (*Marshaler) Properties(properties []core.Property) *[]cdx.Property { func (*Marshaler) Properties(properties []core.Property) *[]cdx.Property {
cdxProps := make([]cdx.Property, 0, len(properties)) cdxProps := make([]cdx.Property, 0, len(properties))
for _, property := range properties { for _, property := range properties {
namespace := Namespace namespace := cmp.Or(property.Namespace, Namespace)
if property.Namespace != "" {
namespace = property.Namespace // External property preserves original name, Trivy property gets namespace prefix
} name := lo.Ternary(property.External, property.Name, namespace+property.Name)
cdxProps = append(cdxProps, cdx.Property{ cdxProps = append(cdxProps, cdx.Property{
Name: namespace + property.Name, Name: name,
Value: property.Value, Value: property.Value,
}) })
} }

View File

@@ -64,7 +64,9 @@ var (
func TestMarshaler_MarshalReport(t *testing.T) { func TestMarshaler_MarshalReport(t *testing.T) {
testSBOM := core.NewBOM(core.Options{GenerateBOMRef: true}) testSBOM := core.NewBOM(core.Options{GenerateBOMRef: true})
testSBOM.AddComponent(&core.Component{
// Add root component
rootComponent := &core.Component{
Root: true, Root: true,
Type: core.TypeApplication, Type: core.TypeApplication,
Name: "jackson-databind-2.13.4.1.jar", Name: "jackson-databind-2.13.4.1.jar",
@@ -77,7 +79,40 @@ func TestMarshaler_MarshalReport(t *testing.T) {
Value: "2", Value: "2",
}, },
}, },
}) }
testSBOM.AddComponent(rootComponent)
// Add the jackson-databind component that matches scan results
jacksonComponent := &core.Component{
Type: core.TypeLibrary,
Name: "jackson-databind",
Group: "com.fasterxml.jackson.core",
Version: "2.13.4.1",
PkgIdentifier: ftypes.PkgIdentifier{
BOMRef: "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.4.1",
PURL: &packageurl.PackageURL{
Type: packageurl.TypeMaven,
Namespace: "com.fasterxml.jackson.core",
Name: "jackson-databind",
Version: "2.13.4.1",
},
},
Properties: []core.Property{
{
Name: core.PropertyPkgType,
Value: "jar",
},
{
Name: core.PropertyFilePath,
Value: "jackson-databind-2.13.4.1.jar",
},
},
}
testSBOM.AddComponent(jacksonComponent)
// Establish relationships
testSBOM.AddRelationship(rootComponent, jacksonComponent, core.RelationshipContains)
testSBOM.AddRelationship(jacksonComponent, nil, core.RelationshipDependsOn)
tests := []struct { tests := []struct {
name string name string
@@ -1533,7 +1568,7 @@ func TestMarshaler_MarshalReport(t *testing.T) {
BOMFormat: "CycloneDX", BOMFormat: "CycloneDX",
SpecVersion: cdx.SpecVersion1_6, SpecVersion: cdx.SpecVersion1_6,
JSONSchema: "http://cyclonedx.org/schema/bom-1.6.schema.json", JSONSchema: "http://cyclonedx.org/schema/bom-1.6.schema.json",
SerialNumber: "urn:uuid:3ff14136-e09f-4df9-80ea-000000000002", SerialNumber: "urn:uuid:3ff14136-e09f-4df9-80ea-000000000001",
Version: 1, Version: 1,
Metadata: &cdx.Metadata{ Metadata: &cdx.Metadata{
Timestamp: "2021-08-25T12:20:30+00:00", Timestamp: "2021-08-25T12:20:30+00:00",

View File

@@ -80,12 +80,20 @@ func (b *BOM) parseBOM(bom *cdx.BOM) error {
if !ok { if !ok {
continue continue
} }
for _, depRef := range lo.FromPtr(dep.Dependencies) {
dependency, ok := components[depRef] dependencies := lo.FromPtr(dep.Dependencies)
if !ok { if len(dependencies) == 0 {
continue // Empty dependsOn array - create empty relationship to preserve this information
b.BOM.AddRelationship(ref, nil, core.RelationshipDependsOn)
} else {
// Process actual dependencies
for _, depRef := range dependencies {
dependency, ok := components[depRef]
if !ok {
continue
}
b.BOM.AddRelationship(ref, dependency, core.RelationshipDependsOn)
} }
b.BOM.AddRelationship(ref, dependency, core.RelationshipDependsOn)
} }
} }
@@ -281,10 +289,21 @@ func (b *BOM) unmarshalSupplier(supplier *cdx.OrganizationalEntity) string {
func (b *BOM) unmarshalProperties(properties *[]cdx.Property) []core.Property { func (b *BOM) unmarshalProperties(properties *[]cdx.Property) []core.Property {
var props []core.Property var props []core.Property
for _, p := range lo.FromPtr(properties) { for _, p := range lo.FromPtr(properties) {
props = append(props, core.Property{ prop := core.Property{
Name: strings.TrimPrefix(p.Name, Namespace),
Value: p.Value, Value: p.Value,
}) }
// If the property has the Trivy namespace prefix, it's a Trivy property
if name, found := strings.CutPrefix(p.Name, Namespace); found {
prop.Name = name
prop.External = false // Trivy property (default)
} else {
// External property - preserve the original name and mark as external
prop.Name = p.Name
prop.External = true
}
props = append(props, prop)
} }
return props return props
} }

View File

@@ -12,6 +12,7 @@ import (
"github.com/aquasecurity/trivy/pkg/digest" "github.com/aquasecurity/trivy/pkg/digest"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/purl" "github.com/aquasecurity/trivy/pkg/purl"
"github.com/aquasecurity/trivy/pkg/sbom/core" "github.com/aquasecurity/trivy/pkg/sbom/core"
"github.com/aquasecurity/trivy/pkg/scan/utils" "github.com/aquasecurity/trivy/pkg/scan/utils"
@@ -19,9 +20,8 @@ import (
) )
type Encoder struct { type Encoder struct {
bom *core.BOM bom *core.BOM
opts core.Options opts core.Options
components map[uuid.UUID]*core.Component
} }
func NewEncoder(opts core.Options) *Encoder { func NewEncoder(opts core.Options) *Encoder {
@@ -29,8 +29,12 @@ func NewEncoder(opts core.Options) *Encoder {
} }
func (e *Encoder) Encode(report types.Report) (*core.BOM, error) { func (e *Encoder) Encode(report types.Report) (*core.BOM, error) {
// When report.BOM is not nil, reuse the existing BOM structure.
// This happens in two scenarios:
// 1. SBOM scanning: When scanning an existing SBOM file to refresh vulnerabilities
// 2. Library usage: When using Trivy as a library with a custom BOM in the report
if report.BOM != nil { if report.BOM != nil {
e.components = report.BOM.Components() return e.reuseExistingBOM(report)
} }
// Metadata component // Metadata component
root, err := e.rootComponent(report) root, err := e.rootComponent(report)
@@ -101,11 +105,6 @@ func (e *Encoder) rootComponent(r types.Report) (*core.Component, error) {
case ftypes.TypeRepository: case ftypes.TypeRepository:
root.Type = core.TypeRepository root.Type = core.TypeRepository
case ftypes.TypeCycloneDX, ftypes.TypeSPDX: case ftypes.TypeCycloneDX, ftypes.TypeSPDX:
// When we scan SBOM file
// If SBOM file doesn't contain root component - use filesystem
if r.BOM != nil && r.BOM.Root() != nil {
return r.BOM.Root(), nil
}
// When we scan a `json` file (meaning a file in `json` format) which was created from the SBOM file. // When we scan a `json` file (meaning a file in `json` format) which was created from the SBOM file.
// e.g. for use in `convert` mode. // e.g. for use in `convert` mode.
// See https://github.com/aquasecurity/trivy/issues/6780 // See https://github.com/aquasecurity/trivy/issues/6780
@@ -253,14 +252,50 @@ func (e *Encoder) encodePackages(parent *core.Component, result types.Result) {
} }
} }
// existedPkgIdentifier tries to look for package identifier (BOM-ref, PURL) by component name and component type // reuseExistingBOM preserves the original SBOM structure and only updates the vulnerabilities section
func (e *Encoder) existedPkgIdentifier(name string, componentType core.ComponentType) ftypes.PkgIdentifier { // with newly detected vulnerabilities. This method handles two use cases:
for _, c := range e.components { // 1. SBOM scanning (CycloneDX): When scanning an existing SBOM file to refresh vulnerability data while
if c.Name == name && c.Type == componentType { // preserving the original structure, components, and relationships
return c.PkgIdentifier // e.g. $ trivy sbom sbom.cdx.json --scanners vuln --format cyclonedx
// 2. Library usage: When using Trivy as a library with a pre-existing custom BOM that needs
// to be enriched with vulnerability information
//
// For SBOM scanning (case 1), this approach is CycloneDX-specific
// because: SPDX 2.3 does not include vulnerabilities in the SBOM specification.
// Therefore, the method uses BOM-Ref for component-vulnerability lookup rather than SPDX-ID.
func (e *Encoder) reuseExistingBOM(report types.Report) (*core.BOM, error) {
bom := report.BOM.Clone()
// Create a lookup map from BOM-Ref to component for efficient vulnerability assignment
// BOM-Ref is used as the key because it's the standard identifier in CycloneDX format
// and is guaranteed to be present in components from CycloneDX SBOMs
components := lo.MapKeys(report.BOM.Components(), func(v *core.Component, _ uuid.UUID) string {
return v.PkgIdentifier.BOMRef
})
for _, result := range report.Results {
// Group newly detected vulnerabilities by their component's BOM-Ref
vulns := make(map[string][]core.Vulnerability)
for _, vuln := range result.Vulnerabilities {
vulns[vuln.PkgIdentifier.BOMRef] = append(vulns[vuln.PkgIdentifier.BOMRef], e.vulnerability(vuln))
}
// Associate vulnerabilities with their corresponding components in the SBOM
for bomRef, componentVulns := range vulns {
c, ok := components[bomRef]
if !ok {
// This should never happen in proper SBOM rescanning because vulnerabilities
// should only be detected for components that exist in the original SBOM
log.Warn("Skipping vulnerabilities for component not found in SBOM",
log.String("bom-ref", bomRef),
log.Int("vulnerabilities", len(componentVulns)))
continue
}
bom.AddVulnerabilities(c, componentVulns)
} }
} }
return ftypes.PkgIdentifier{}
return bom, nil
} }
func (e *Encoder) resultComponent(root *core.Component, r types.Result, osFound *ftypes.OS) *core.Component { func (e *Encoder) resultComponent(root *core.Component, r types.Result, osFound *ftypes.OS) *core.Component {
@@ -285,10 +320,8 @@ func (e *Encoder) resultComponent(root *core.Component, r types.Result, osFound
component.Version = osFound.Name component.Version = osFound.Name
} }
component.Type = core.TypeOS component.Type = core.TypeOS
component.PkgIdentifier = e.existedPkgIdentifier(component.Name, component.Type)
case types.ClassLangPkg: case types.ClassLangPkg:
component.Type = core.TypeApplication component.Type = core.TypeApplication
component.PkgIdentifier = e.existedPkgIdentifier(component.Name, component.Type)
} }
e.bom.AddRelationship(root, component, core.RelationshipContains) e.bom.AddRelationship(root, component, core.RelationshipContains)

View File

@@ -1000,44 +1000,20 @@ func TestEncoder_Encode(t *testing.T) {
SchemaVersion: 2, SchemaVersion: 2,
ArtifactName: "report.cdx.json", ArtifactName: "report.cdx.json",
ArtifactType: ftypes.TypeCycloneDX, ArtifactType: ftypes.TypeCycloneDX,
Results: []types.Result{ BOM: newTestBOM(t),
{
Target: "Java",
Type: ftypes.Jar,
Class: types.ClassLangPkg,
Packages: []ftypes.Package{
{
ID: "org.apache.logging.log4j:log4j-core:2.23.1",
Name: "org.apache.logging.log4j:log4j-core",
Version: "2.23.1",
Identifier: ftypes.PkgIdentifier{
UID: "6C0AE96901617503",
PURL: &packageurl.PackageURL{
Type: packageurl.TypeMaven,
Namespace: "org.apache.logging.log4j",
Name: "log4j-core",
Version: "2.23.1",
},
},
FilePath: "log4j-core-2.23.1.jar",
},
},
},
},
BOM: newTestBOM(t),
}, },
wantComponents: map[uuid.UUID]*core.Component{ wantComponents: map[uuid.UUID]*core.Component{
uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): appComponent, uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): appComponent,
uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): libComponent, uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000002"): libComponent,
}, },
wantRels: map[uuid.UUID][]core.Relationship{ wantRels: map[uuid.UUID][]core.Relationship{
uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): { uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): {
{ {
Dependency: uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"), Dependency: uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000002"),
Type: core.RelationshipContains, Type: core.RelationshipContains,
}, },
}, },
uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): nil, uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000002"): nil,
}, },
wantVulns: make(map[uuid.UUID][]core.Vulnerability), wantVulns: make(map[uuid.UUID][]core.Vulnerability),
}, },
@@ -1047,44 +1023,13 @@ func TestEncoder_Encode(t *testing.T) {
SchemaVersion: 2, SchemaVersion: 2,
ArtifactName: "report.cdx.json", ArtifactName: "report.cdx.json",
ArtifactType: ftypes.TypeCycloneDX, ArtifactType: ftypes.TypeCycloneDX,
Results: []types.Result{ BOM: newTestBOM2(t),
{
Target: "Java",
Type: ftypes.Jar,
Class: types.ClassLangPkg,
Packages: []ftypes.Package{
{
ID: "org.apache.logging.log4j:log4j-core:2.23.1",
Name: "org.apache.logging.log4j:log4j-core",
Version: "2.23.1",
Identifier: ftypes.PkgIdentifier{
UID: "6C0AE96901617503",
PURL: &packageurl.PackageURL{
Type: packageurl.TypeMaven,
Namespace: "org.apache.logging.log4j",
Name: "log4j-core",
Version: "2.23.1",
},
},
FilePath: "log4j-core-2.23.1.jar",
},
},
},
},
BOM: newTestBOM2(t),
}, },
wantComponents: map[uuid.UUID]*core.Component{ wantComponents: map[uuid.UUID]*core.Component{
uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): fsComponent, uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): libComponent,
uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000002"): libComponent,
}, },
wantRels: map[uuid.UUID][]core.Relationship{ wantRels: map[uuid.UUID][]core.Relationship{
uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): { uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): nil,
{
Dependency: uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000002"),
Type: core.RelationshipContains,
},
},
uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000002"): nil,
}, },
wantVulns: make(map[uuid.UUID][]core.Vulnerability), wantVulns: make(map[uuid.UUID][]core.Vulnerability),
}, },
@@ -1600,7 +1545,17 @@ var (
func newTestBOM(t *testing.T) *core.BOM { func newTestBOM(t *testing.T) *core.BOM {
uuid.SetFakeUUID(t, "2ff14136-e09f-4df9-80ea-%012d") uuid.SetFakeUUID(t, "2ff14136-e09f-4df9-80ea-%012d")
bom := core.NewBOM(core.Options{}) bom := core.NewBOM(core.Options{})
bom.AddComponent(appComponent)
// Copy components to avoid UUID conflicts between tests
appComp := appComponent.Clone()
libComp := libComponent.Clone()
bom.AddComponent(appComp)
bom.AddComponent(libComp)
// Add Contains relationship between appComponent and libComponent
bom.AddRelationship(appComp, libComp, core.RelationshipContains)
// Add empty relationship for libComponent to preserve structure for SBOM rescanning
bom.AddRelationship(libComp, nil, core.RelationshipDependsOn)
return bom return bom
} }
@@ -1608,6 +1563,12 @@ func newTestBOM(t *testing.T) *core.BOM {
func newTestBOM2(t *testing.T) *core.BOM { func newTestBOM2(t *testing.T) *core.BOM {
uuid.SetFakeUUID(t, "2ff14136-e09f-4df9-80ea-%012d") uuid.SetFakeUUID(t, "2ff14136-e09f-4df9-80ea-%012d")
bom := core.NewBOM(core.Options{}) bom := core.NewBOM(core.Options{})
bom.AddComponent(libComponent)
// Copy component to avoid UUID conflicts between tests
libComp := libComponent.Clone()
bom.AddComponent(libComp)
// Add empty relationship for libComponent to preserve structure for SBOM rescanning
bom.AddRelationship(libComp, nil, core.RelationshipDependsOn)
return bom return bom
} }

View File

@@ -184,18 +184,20 @@ func decodeAttestCycloneDXJSONFormat(r io.ReadSeeker) (Format, bool) {
func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error) { func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error) {
var ( var (
v any v any
bom = core.NewBOM(core.Options{}) bom *core.BOM
decoder interface{ Decode(any) error } decoder interface{ Decode(any) error }
) )
switch format { switch format {
case FormatCycloneDXJSON: case FormatCycloneDXJSON:
bom = core.NewBOM(core.Options{GenerateBOMRef: true})
v = &cyclonedx.BOM{BOM: bom} v = &cyclonedx.BOM{BOM: bom}
decoder = json.NewDecoder(f) decoder = json.NewDecoder(f)
case FormatAttestCycloneDXJSON: case FormatAttestCycloneDXJSON:
// dsse envelope // dsse envelope
// => in-toto attestation // => in-toto attestation
// => CycloneDX JSON // => CycloneDX JSON
bom = core.NewBOM(core.Options{GenerateBOMRef: true})
v = &attestation.Statement{ v = &attestation.Statement{
Predicate: &cyclonedx.BOM{BOM: bom}, Predicate: &cyclonedx.BOM{BOM: bom},
} }
@@ -205,6 +207,7 @@ func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error)
// => in-toto attestation // => in-toto attestation
// => cosign predicate // => cosign predicate
// => CycloneDX JSON // => CycloneDX JSON
bom = core.NewBOM(core.Options{GenerateBOMRef: true})
v = &attestation.Statement{ v = &attestation.Statement{
Predicate: &attestation.CosignPredicate{ Predicate: &attestation.CosignPredicate{
Data: &cyclonedx.BOM{BOM: bom}, Data: &cyclonedx.BOM{BOM: bom},
@@ -212,9 +215,11 @@ func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error)
} }
decoder = json.NewDecoder(f) decoder = json.NewDecoder(f)
case FormatSPDXJSON: case FormatSPDXJSON:
bom = core.NewBOM(core.Options{})
v = &spdx.SPDX{BOM: bom} v = &spdx.SPDX{BOM: bom}
decoder = json.NewDecoder(f) decoder = json.NewDecoder(f)
case FormatSPDXTV: case FormatSPDXTV:
bom = core.NewBOM(core.Options{})
v = &spdx.SPDX{BOM: bom} v = &spdx.SPDX{BOM: bom}
decoder = spdx.NewTVDecoder(f) decoder = spdx.NewTVDecoder(f)
default: default:

View File

@@ -17,6 +17,7 @@ import (
"github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/sbom/core" "github.com/aquasecurity/trivy/pkg/sbom/core"
"github.com/aquasecurity/trivy/pkg/types" "github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/uuid"
"github.com/aquasecurity/trivy/pkg/vex" "github.com/aquasecurity/trivy/pkg/vex"
) )
@@ -156,6 +157,9 @@ func TestFilter(t *testing.T) {
// Set up the OCI registry // Set up the OCI registry
tr, d := setUpRegistry(t) tr, d := setUpRegistry(t)
uuid.SetFakeUUID(t, "3ff14136-e09f-4df9-80ea-%012d")
testCycloneDXSBOM := createCycloneDXBOMWithSpringComponent()
type args struct { type args struct {
report *types.Report report *types.Report
opts vex.Options opts vex.Options
@@ -329,10 +333,7 @@ func TestFilter(t *testing.T) {
args: args{ args: args{
report: &types.Report{ report: &types.Report{
ArtifactType: ftypes.TypeCycloneDX, ArtifactType: ftypes.TypeCycloneDX,
BOM: &core.BOM{ BOM: testCycloneDXSBOM,
SerialNumber: "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
Version: 1,
},
Results: []types.Result{ Results: []types.Result{
springResult(types.Result{ springResult(types.Result{
Vulnerabilities: []types.DetectedVulnerability{vuln1}, Vulnerabilities: []types.DetectedVulnerability{vuln1},
@@ -350,10 +351,7 @@ func TestFilter(t *testing.T) {
}, },
want: &types.Report{ want: &types.Report{
ArtifactType: ftypes.TypeCycloneDX, ArtifactType: ftypes.TypeCycloneDX,
BOM: &core.BOM{ BOM: testCycloneDXSBOM,
SerialNumber: "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
Version: 1,
},
Results: []types.Result{ Results: []types.Result{
springResult(types.Result{ springResult(types.Result{
Vulnerabilities: []types.DetectedVulnerability{}, Vulnerabilities: []types.DetectedVulnerability{},
@@ -617,6 +615,22 @@ func ociPURLString(ts *httptest.Server, d v1.Hash) string {
return p.String() return p.String()
} }
func createCycloneDXBOMWithSpringComponent() *core.BOM {
bom := core.NewBOM(core.Options{})
bom.SerialNumber = "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"
bom.Version = 1
// Add the spring component to match vuln1's BOM-Ref
springComponent := &core.Component{
Type: core.TypeLibrary,
Name: springPackage.Identifier.PURL.Name,
Group: springPackage.Identifier.PURL.Namespace,
Version: springPackage.Version,
PkgIdentifier: springPackage.Identifier,
}
bom.AddComponent(springComponent)
return bom
}
func fsReport(results types.Results) *types.Report { func fsReport(results types.Results) *types.Report {
return &types.Report{ return &types.Report{
ArtifactName: ".", ArtifactName: ".",