diff --git a/integration/testdata/conda-spdx.json.golden b/integration/testdata/conda-spdx.json.golden index ed4cffddb766..dc980b75a3e0 100644 --- a/integration/testdata/conda-spdx.json.golden +++ b/integration/testdata/conda-spdx.json.golden @@ -3,7 +3,7 @@ "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "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": { "creators": [ "Organization: aquasecurity", @@ -14,7 +14,7 @@ "packages": [ { "name": "openssl", - "SPDXID": "SPDXRef-Package-22a178da112ac20a", + "SPDXID": "SPDXRef-Package-cb268df467bc826c", "versionInfo": "1.1.1q", "supplier": "NOASSERTION", "downloadLocation": "NONE", @@ -43,7 +43,7 @@ }, { "name": "pip", - "SPDXID": "SPDXRef-Package-c22b9ee9a601ba6", + "SPDXID": "SPDXRef-Package-1378bb10fcebba63", "versionInfo": "22.2.2", "supplier": "NOASSERTION", "downloadLocation": "NONE", @@ -118,22 +118,22 @@ }, { "spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef", - "relatedSpdxElement": "SPDXRef-Package-22a178da112ac20a", + "relatedSpdxElement": "SPDXRef-Package-1378bb10fcebba63", "relationshipType": "CONTAINS" }, { "spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef", - "relatedSpdxElement": "SPDXRef-Package-c22b9ee9a601ba6", + "relatedSpdxElement": "SPDXRef-Package-cb268df467bc826c", "relationshipType": "CONTAINS" }, { - "spdxElementId": "SPDXRef-Package-22a178da112ac20a", - "relatedSpdxElement": "SPDXRef-File-600e5e0110a84891", + "spdxElementId": "SPDXRef-Package-1378bb10fcebba63", + "relatedSpdxElement": "SPDXRef-File-7eb62e2a3edddc0a", "relationshipType": "CONTAINS" }, { - "spdxElementId": "SPDXRef-Package-c22b9ee9a601ba6", - "relatedSpdxElement": "SPDXRef-File-7eb62e2a3edddc0a", + "spdxElementId": "SPDXRef-Package-cb268df467bc826c", + "relatedSpdxElement": "SPDXRef-File-600e5e0110a84891", "relationshipType": "CONTAINS" } ] diff --git a/integration/testdata/fluentd-multiple-lockfiles-short.cdx.json.golden b/integration/testdata/fluentd-multiple-lockfiles-short.cdx.json.golden index aae2a448ebc7..bef8a43cbef4 100644 --- a/integration/testdata/fluentd-multiple-lockfiles-short.cdx.json.golden +++ b/integration/testdata/fluentd-multiple-lockfiles-short.cdx.json.golden @@ -2,7 +2,7 @@ "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", "specVersion": "1.6", - "serialNumber": "urn:uuid:3ff14136-e09f-4df9-80ea-000000000010", + "serialNumber": "urn:uuid:3ff14136-e09f-4df9-80ea-000000000006", "version": 1, "metadata": { "timestamp": "2021-08-25T12:20:30+00:00", @@ -91,14 +91,6 @@ "name": "aquasecurity:trivy:LayerDigest", "value": "sha256:000eee12ec04cc914bf96e8f5dee7767510c2aca3816af6078bd9fbe3150920c" }, - { - "name": "aquasecurity:trivy:PkgID", - "value": "bash@5.0-4" - }, - { - "name": "aquasecurity:trivy:PkgType", - "value": "debian" - }, { "name": "aquasecurity:trivy:SrcName", "value": "bash" @@ -124,14 +116,6 @@ "name": "aquasecurity:trivy:LayerDigest", "value": "sha256:000eee12ec04cc914bf96e8f5dee7767510c2aca3816af6078bd9fbe3150920c" }, - { - "name": "aquasecurity:trivy:PkgID", - "value": "libidn2-0@2.0.5-1" - }, - { - "name": "aquasecurity:trivy:PkgType", - "value": "debian" - }, { "name": "aquasecurity:trivy:SrcName", "value": "libidn2" @@ -169,11 +153,7 @@ "value": "sha256:a8877cad19f14a7044524a145ce33170085441a7922458017db1631dcd5f7602" }, { - "name": "aquasecurity:trivy:PkgID", - "value": "activesupport@6.0.2.1" - }, - { - "name": "aquasecurity:trivy:PkgType", + "name": "aquasecurity:trivy:Type", "value": "gemspec" } ] @@ -193,18 +173,6 @@ "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" ] - }, - { - "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": [ diff --git a/integration/testdata/julia-spdx.json.golden b/integration/testdata/julia-spdx.json.golden index 3a4ea79a3ed2..a2ded3e93395 100644 --- a/integration/testdata/julia-spdx.json.golden +++ b/integration/testdata/julia-spdx.json.golden @@ -14,7 +14,7 @@ "packages": [ { "name": "Manifest.toml", - "SPDXID": "SPDXRef-Application-18fc3597717a3e56", + "SPDXID": "SPDXRef-Application-c39d15beb6bdf085", "downloadLocation": "NONE", "filesAnalyzed": false, "primaryPackagePurpose": "APPLICATION", @@ -35,7 +35,7 @@ }, { "name": "A", - "SPDXID": "SPDXRef-Package-761ce79b41d8f121", + "SPDXID": "SPDXRef-Package-3aea0b160c3af98d", "versionInfo": "1.9.0", "supplier": "NOASSERTION", "downloadLocation": "NONE", @@ -68,7 +68,7 @@ }, { "name": "B", - "SPDXID": "SPDXRef-Package-28f04edc422602a", + "SPDXID": "SPDXRef-Package-2264d5c424c073e7", "versionInfo": "1.9.0", "supplier": "NOASSERTION", "downloadLocation": "NONE", @@ -101,7 +101,7 @@ }, { "name": "B", - "SPDXID": "SPDXRef-Package-6e0b0d1825d8c02c", + "SPDXID": "SPDXRef-Package-e29bcba688483642", "versionInfo": "1.9.0", "supplier": "NOASSERTION", "downloadLocation": "NONE", @@ -150,13 +150,13 @@ ], "relationships": [ { - "spdxElementId": "SPDXRef-Application-18fc3597717a3e56", - "relatedSpdxElement": "SPDXRef-Package-6e0b0d1825d8c02c", + "spdxElementId": "SPDXRef-Application-c39d15beb6bdf085", + "relatedSpdxElement": "SPDXRef-Package-3aea0b160c3af98d", "relationshipType": "CONTAINS" }, { - "spdxElementId": "SPDXRef-Application-18fc3597717a3e56", - "relatedSpdxElement": "SPDXRef-Package-761ce79b41d8f121", + "spdxElementId": "SPDXRef-Application-c39d15beb6bdf085", + "relatedSpdxElement": "SPDXRef-Package-e29bcba688483642", "relationshipType": "CONTAINS" }, { @@ -166,12 +166,12 @@ }, { "spdxElementId": "SPDXRef-Filesystem-1be792dd0077c431", - "relatedSpdxElement": "SPDXRef-Application-18fc3597717a3e56", + "relatedSpdxElement": "SPDXRef-Application-c39d15beb6bdf085", "relationshipType": "CONTAINS" }, { - "spdxElementId": "SPDXRef-Package-761ce79b41d8f121", - "relatedSpdxElement": "SPDXRef-Package-28f04edc422602a", + "spdxElementId": "SPDXRef-Package-3aea0b160c3af98d", + "relatedSpdxElement": "SPDXRef-Package-2264d5c424c073e7", "relationshipType": "DEPENDS_ON" } ] diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go index a18ab636a8fe..8cbb6fd9d2ea 100644 --- a/pkg/commands/artifact/run.go +++ b/pkg/commands/artifact/run.go @@ -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 { 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) { + // Perform validation checks + checkOptions(ctx, opts, targetKind) + r, err := NewRunner(ctx, opts, targetKind) if err != nil { if errors.Is(err, SkipScan) { @@ -466,6 +460,27 @@ func run(ctx context.Context, opts flag.Options, targetKind TargetKind) (types.R 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 { // Specified analyzers to be disabled depending on scanning modes // e.g. The 'image' subcommand should disable the lock file scanning. diff --git a/pkg/fanal/artifact/sbom/sbom_test.go b/pkg/fanal/artifact/sbom/sbom_test.go index 53cadf905123..327e8ba7800a 100644 --- a/pkg/fanal/artifact/sbom/sbom_test.go +++ b/pkg/fanal/artifact/sbom/sbom_test.go @@ -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", filePath: filepath.Join("testdata", "unknown.json"), diff --git a/pkg/fanal/artifact/sbom/testdata/bom-missing-refs.json b/pkg/fanal/artifact/sbom/testdata/bom-missing-refs.json new file mode 100644 index 000000000000..949c89644971 --- /dev/null +++ b/pkg/fanal/artifact/sbom/testdata/bom-missing-refs.json @@ -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": [] +} \ No newline at end of file diff --git a/pkg/sbom/core/bom.go b/pkg/sbom/core/bom.go index 4f8cec545e02..49760361fad3 100644 --- a/pkg/sbom/core/bom.go +++ b/pkg/sbom/core/bom.go @@ -1,8 +1,11 @@ package core import ( + "slices" "sort" + "github.com/samber/lo" + dtypes "github.com/aquasecurity/trivy-db/pkg/types" "github.com/aquasecurity/trivy/pkg/digest" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" @@ -164,6 +167,18 @@ func (c *Component) ID() uuid.UUID { 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 { // Path is a path of the file. // CycloneDX: N/A @@ -181,6 +196,9 @@ type Property struct { Name string Value 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 @@ -352,3 +370,43 @@ func (b *BOM) bomRef(c *Component) string { } 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, + } +} diff --git a/pkg/sbom/cyclonedx/marshal.go b/pkg/sbom/cyclonedx/marshal.go index 394fd7b956c6..75dd2db93b3d 100644 --- a/pkg/sbom/cyclonedx/marshal.go +++ b/pkg/sbom/cyclonedx/marshal.go @@ -1,6 +1,7 @@ package cyclonedx import ( + "cmp" "context" "fmt" "net/url" @@ -358,13 +359,13 @@ func (m *Marshaler) normalizeLicense(license string) expression.Expression { func (*Marshaler) Properties(properties []core.Property) *[]cdx.Property { cdxProps := make([]cdx.Property, 0, len(properties)) for _, property := range properties { - namespace := Namespace - if property.Namespace != "" { - namespace = property.Namespace - } + namespace := cmp.Or(property.Namespace, 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{ - Name: namespace + property.Name, + Name: name, Value: property.Value, }) } diff --git a/pkg/sbom/cyclonedx/marshal_test.go b/pkg/sbom/cyclonedx/marshal_test.go index 7fd1bc52bc56..0d518b3dadd3 100644 --- a/pkg/sbom/cyclonedx/marshal_test.go +++ b/pkg/sbom/cyclonedx/marshal_test.go @@ -64,7 +64,9 @@ var ( func TestMarshaler_MarshalReport(t *testing.T) { testSBOM := core.NewBOM(core.Options{GenerateBOMRef: true}) - testSBOM.AddComponent(&core.Component{ + + // Add root component + rootComponent := &core.Component{ Root: true, Type: core.TypeApplication, Name: "jackson-databind-2.13.4.1.jar", @@ -77,7 +79,40 @@ func TestMarshaler_MarshalReport(t *testing.T) { 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 { name string @@ -1533,7 +1568,7 @@ func TestMarshaler_MarshalReport(t *testing.T) { BOMFormat: "CycloneDX", SpecVersion: cdx.SpecVersion1_6, 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, Metadata: &cdx.Metadata{ Timestamp: "2021-08-25T12:20:30+00:00", diff --git a/pkg/sbom/cyclonedx/unmarshal.go b/pkg/sbom/cyclonedx/unmarshal.go index f1538efba881..ff3e1e1f5025 100644 --- a/pkg/sbom/cyclonedx/unmarshal.go +++ b/pkg/sbom/cyclonedx/unmarshal.go @@ -80,12 +80,20 @@ func (b *BOM) parseBOM(bom *cdx.BOM) error { if !ok { continue } - for _, depRef := range lo.FromPtr(dep.Dependencies) { - dependency, ok := components[depRef] - if !ok { - continue + + dependencies := lo.FromPtr(dep.Dependencies) + if len(dependencies) == 0 { + // 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 { var props []core.Property for _, p := range lo.FromPtr(properties) { - props = append(props, core.Property{ - Name: strings.TrimPrefix(p.Name, Namespace), + prop := core.Property{ 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 } diff --git a/pkg/sbom/io/encode.go b/pkg/sbom/io/encode.go index 9e2f5f8ac769..dfaca17d067d 100644 --- a/pkg/sbom/io/encode.go +++ b/pkg/sbom/io/encode.go @@ -12,6 +12,7 @@ import ( "github.com/aquasecurity/trivy/pkg/digest" 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/sbom/core" "github.com/aquasecurity/trivy/pkg/scan/utils" @@ -19,9 +20,8 @@ import ( ) type Encoder struct { - bom *core.BOM - opts core.Options - components map[uuid.UUID]*core.Component + bom *core.BOM + opts core.Options } 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) { + // 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 { - e.components = report.BOM.Components() + return e.reuseExistingBOM(report) } // Metadata component root, err := e.rootComponent(report) @@ -101,11 +105,6 @@ func (e *Encoder) rootComponent(r types.Report) (*core.Component, error) { case ftypes.TypeRepository: root.Type = core.TypeRepository 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. // e.g. for use in `convert` mode. // 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 -func (e *Encoder) existedPkgIdentifier(name string, componentType core.ComponentType) ftypes.PkgIdentifier { - for _, c := range e.components { - if c.Name == name && c.Type == componentType { - return c.PkgIdentifier +// reuseExistingBOM preserves the original SBOM structure and only updates the vulnerabilities section +// with newly detected vulnerabilities. This method handles two use cases: +// 1. SBOM scanning (CycloneDX): When scanning an existing SBOM file to refresh vulnerability data while +// preserving the original structure, components, and relationships +// 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 { @@ -285,10 +320,8 @@ func (e *Encoder) resultComponent(root *core.Component, r types.Result, osFound component.Version = osFound.Name } component.Type = core.TypeOS - component.PkgIdentifier = e.existedPkgIdentifier(component.Name, component.Type) case types.ClassLangPkg: component.Type = core.TypeApplication - component.PkgIdentifier = e.existedPkgIdentifier(component.Name, component.Type) } e.bom.AddRelationship(root, component, core.RelationshipContains) diff --git a/pkg/sbom/io/encode_test.go b/pkg/sbom/io/encode_test.go index c89ead10e22f..2d67f29c4c1b 100644 --- a/pkg/sbom/io/encode_test.go +++ b/pkg/sbom/io/encode_test.go @@ -1000,44 +1000,20 @@ func TestEncoder_Encode(t *testing.T) { SchemaVersion: 2, ArtifactName: "report.cdx.json", ArtifactType: ftypes.TypeCycloneDX, - Results: []types.Result{ - { - 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), + BOM: newTestBOM(t), }, wantComponents: map[uuid.UUID]*core.Component{ 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{ 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, }, }, - uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): nil, + uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000002"): nil, }, wantVulns: make(map[uuid.UUID][]core.Vulnerability), }, @@ -1047,44 +1023,13 @@ func TestEncoder_Encode(t *testing.T) { SchemaVersion: 2, ArtifactName: "report.cdx.json", ArtifactType: ftypes.TypeCycloneDX, - Results: []types.Result{ - { - 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), + BOM: newTestBOM2(t), }, wantComponents: map[uuid.UUID]*core.Component{ - uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): fsComponent, - uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000002"): libComponent, + uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): libComponent, }, wantRels: map[uuid.UUID][]core.Relationship{ - uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): { - { - Dependency: uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000002"), - Type: core.RelationshipContains, - }, - }, - uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000002"): nil, + uuid.MustParse("2ff14136-e09f-4df9-80ea-000000000001"): nil, }, wantVulns: make(map[uuid.UUID][]core.Vulnerability), }, @@ -1600,7 +1545,17 @@ var ( func newTestBOM(t *testing.T) *core.BOM { uuid.SetFakeUUID(t, "2ff14136-e09f-4df9-80ea-%012d") 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 } @@ -1608,6 +1563,12 @@ func newTestBOM(t *testing.T) *core.BOM { func newTestBOM2(t *testing.T) *core.BOM { uuid.SetFakeUUID(t, "2ff14136-e09f-4df9-80ea-%012d") 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 } diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go index 6a943e3f2822..65f2e7d3f083 100644 --- a/pkg/sbom/sbom.go +++ b/pkg/sbom/sbom.go @@ -184,18 +184,20 @@ func decodeAttestCycloneDXJSONFormat(r io.ReadSeeker) (Format, bool) { func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error) { var ( v any - bom = core.NewBOM(core.Options{}) + bom *core.BOM decoder interface{ Decode(any) error } ) switch format { case FormatCycloneDXJSON: + bom = core.NewBOM(core.Options{GenerateBOMRef: true}) v = &cyclonedx.BOM{BOM: bom} decoder = json.NewDecoder(f) case FormatAttestCycloneDXJSON: // dsse envelope // => in-toto attestation // => CycloneDX JSON + bom = core.NewBOM(core.Options{GenerateBOMRef: true}) v = &attestation.Statement{ 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 // => cosign predicate // => CycloneDX JSON + bom = core.NewBOM(core.Options{GenerateBOMRef: true}) v = &attestation.Statement{ Predicate: &attestation.CosignPredicate{ 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) case FormatSPDXJSON: + bom = core.NewBOM(core.Options{}) v = &spdx.SPDX{BOM: bom} decoder = json.NewDecoder(f) case FormatSPDXTV: + bom = core.NewBOM(core.Options{}) v = &spdx.SPDX{BOM: bom} decoder = spdx.NewTVDecoder(f) default: diff --git a/pkg/vex/vex_test.go b/pkg/vex/vex_test.go index 6d9f25e92cd0..fe7cd2d80590 100644 --- a/pkg/vex/vex_test.go +++ b/pkg/vex/vex_test.go @@ -17,6 +17,7 @@ import ( "github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/sbom/core" "github.com/aquasecurity/trivy/pkg/types" + "github.com/aquasecurity/trivy/pkg/uuid" "github.com/aquasecurity/trivy/pkg/vex" ) @@ -156,6 +157,9 @@ func TestFilter(t *testing.T) { // Set up the OCI registry tr, d := setUpRegistry(t) + uuid.SetFakeUUID(t, "3ff14136-e09f-4df9-80ea-%012d") + testCycloneDXSBOM := createCycloneDXBOMWithSpringComponent() + type args struct { report *types.Report opts vex.Options @@ -329,10 +333,7 @@ func TestFilter(t *testing.T) { args: args{ report: &types.Report{ ArtifactType: ftypes.TypeCycloneDX, - BOM: &core.BOM{ - SerialNumber: "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", - Version: 1, - }, + BOM: testCycloneDXSBOM, Results: []types.Result{ springResult(types.Result{ Vulnerabilities: []types.DetectedVulnerability{vuln1}, @@ -350,10 +351,7 @@ func TestFilter(t *testing.T) { }, want: &types.Report{ ArtifactType: ftypes.TypeCycloneDX, - BOM: &core.BOM{ - SerialNumber: "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", - Version: 1, - }, + BOM: testCycloneDXSBOM, Results: []types.Result{ springResult(types.Result{ Vulnerabilities: []types.DetectedVulnerability{}, @@ -617,6 +615,22 @@ func ociPURLString(ts *httptest.Server, d v1.Hash) 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 { return &types.Report{ ArtifactName: ".",