Skip to content

Commit 2c919ab

Browse files
authored
Merge pull request #317 from rpkelly/sbom-merge
fix: sbomtool merge losing packages and properties
2 parents 4427ca0 + 78a7bac commit 2c919ab

File tree

9 files changed

+985
-205
lines changed

9 files changed

+985
-205
lines changed

sbomtool/cmd/sbomtool/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/anchore/syft/syft/pkg"
1010
"github.com/anchore/syft/syft/sbom"
1111
"github.com/spf13/cobra"
12+
_ "modernc.org/sqlite"
1213

1314
"github.com/bottlerocket-os/bottlerocket-sdk/sbomtool/go/internal/commands/filter"
1415
"github.com/bottlerocket-os/bottlerocket-sdk/sbomtool/go/internal/commands/generate"

sbomtool/go.mod

Lines changed: 127 additions & 52 deletions
Large diffs are not rendered by default.

sbomtool/go.sum

Lines changed: 278 additions & 122 deletions
Large diffs are not rendered by default.

sbomtool/go.work.sum

Lines changed: 375 additions & 17 deletions
Large diffs are not rendered by default.

sbomtool/internal/commands/generate/generate.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ func Generate(name string, spdx bool, cyclonedx bool, buildDir string, outDir st
109109
return false, fmt.Errorf("failed to create SBOM: %w", err)
110110
}
111111

112+
// Set the source name from the --name parameter so it appears in metadata.component
113+
sbomData.Source.Name = name
114+
112115
if spdx {
113116
slog.Info("Generating SPDX SBOM", "name", name)
114117
if err := createSpdxSbom(*sbomData, name, outDir); err != nil {

sbomtool/internal/commands/merge/merge.go

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/anchore/syft/syft/artifact"
1313
"github.com/anchore/syft/syft/pkg"
1414
"github.com/anchore/syft/syft/sbom"
15+
"github.com/anchore/syft/syft/source"
1516

1617
"github.com/bottlerocket-os/bottlerocket-sdk/sbomtool/go/internal/deduplication"
1718
"github.com/bottlerocket-os/bottlerocket-sdk/sbomtool/go/internal/processor"
@@ -136,25 +137,60 @@ func loadSBOMs(inputFiles []string) ([]*sbom.SBOM, string, error) {
136137
return sboms, commonFormat, nil
137138
}
138139

140+
// sourceToPackage converts an SBOM's Source (metadata.component) to a package.
141+
// This ensures the subject of each input SBOM is preserved in the merged output.
142+
func sourceToPackage(src source.Description) *pkg.Package {
143+
if src.Name == "" {
144+
return nil
145+
}
146+
147+
p := pkg.Package{
148+
Name: src.Name,
149+
Version: src.Version,
150+
Type: pkg.UnknownPkg,
151+
}
152+
p.SetID()
153+
154+
slog.Debug("Converted source to package", "name", src.Name, "version", src.Version)
155+
return &p
156+
}
157+
139158
// mergeSBOMs combines SBOM metadata and extracts all packages and relationships.
159+
// Each input SBOM's Source (metadata.component) is converted to a package to preserve
160+
// the subject of each SBOM in the merged output. Contains relationships are created
161+
// from the source package to all packages in that SBOM.
140162
func mergeSBOMs(sboms []*sbom.SBOM) (*sbom.SBOM, []pkg.Package, []artifact.Relationship) {
141163
if len(sboms) == 0 {
142164
return &sbom.SBOM{}, nil, nil
143165
}
144166

145-
// Use first SBOM as base
167+
// Use first SBOM as base for descriptor
146168
merged := &sbom.SBOM{
147169
Descriptor: sboms[0].Descriptor,
148-
Source: sboms[0].Source,
149170
}
150171

151172
var allPackages []pkg.Package
152173
var allRelationships []artifact.Relationship
153174

154-
// Collect all packages and relationships
175+
// Collect all packages and relationships, including Source as a package
155176
for _, s := range sboms {
156-
allPackages = append(allPackages, s.Artifacts.Packages.Sorted()...)
177+
sbomPackages := s.Artifacts.Packages.Sorted()
178+
allPackages = append(allPackages, sbomPackages...)
157179
allRelationships = append(allRelationships, s.Relationships...)
180+
181+
// Convert Source (metadata.component) to a package and create contains relationships
182+
if srcPkg := sourceToPackage(s.Source); srcPkg != nil {
183+
allPackages = append(allPackages, *srcPkg)
184+
185+
// Create "contains" relationships from source to each package in this SBOM
186+
for _, p := range sbomPackages {
187+
allRelationships = append(allRelationships, artifact.Relationship{
188+
From: *srcPkg,
189+
To: p,
190+
Type: artifact.ContainsRelationship,
191+
})
192+
}
193+
}
158194
}
159195

160196
slog.Debug("SBOM merge phase completed",
@@ -174,7 +210,6 @@ func createFinalSBOM(base *sbom.SBOM, canonicalPackages map[string]*pkg.Package,
174210

175211
return &sbom.SBOM{
176212
Descriptor: base.Descriptor,
177-
Source: base.Source,
178213
Artifacts: sbom.Artifacts{
179214
Packages: collection,
180215
},

sbomtool/internal/commands/merge/merge_test.go

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ func TestMergeSBOMs(t *testing.T) {
143143
createTestPackage("pkg3", "3.0.0", ""),
144144
}),
145145
},
146-
expectedPackageCount: 4,
147-
expectedRelationshipCount: 2,
146+
// 4 original packages + 2 source packages (test1, test2) = 6
147+
// 2 original relationships + 4 contains relationships (2 pkgs × 2 sources) = 6
148+
expectedPackageCount: 6,
149+
expectedRelationshipCount: 6,
148150
},
149151
{
150152
name: "empty SBOM slice",
@@ -259,3 +261,122 @@ func createTestPackagePtr(name, version, cpeStr string) *pkg.Package {
259261
p := createTestPackage(name, version, cpeStr)
260262
return &p
261263
}
264+
265+
func TestSourceToPackage(t *testing.T) {
266+
// GIVEN: Various source descriptions
267+
// WHEN: sourceToPackage is called
268+
// THEN: Appropriate packages should be created
269+
270+
tests := []struct {
271+
name string
272+
src source.Description
273+
expectNil bool
274+
expectName string
275+
expectVer string
276+
}{
277+
{
278+
name: "valid source with name and version",
279+
src: source.Description{Name: "libelf", Version: "0.194"},
280+
expectNil: false,
281+
expectName: "libelf",
282+
expectVer: "0.194",
283+
},
284+
{
285+
name: "valid source with name only",
286+
src: source.Description{Name: "mypackage"},
287+
expectNil: false,
288+
expectName: "mypackage",
289+
expectVer: "",
290+
},
291+
{
292+
name: "empty source",
293+
src: source.Description{},
294+
expectNil: true,
295+
},
296+
{
297+
name: "source with empty name",
298+
src: source.Description{Name: "", Version: "1.0"},
299+
expectNil: true,
300+
},
301+
}
302+
303+
for _, tt := range tests {
304+
t.Run(tt.name, func(t *testing.T) {
305+
result := sourceToPackage(tt.src)
306+
307+
if tt.expectNil {
308+
if result != nil {
309+
t.Errorf("expected nil, got package with name %s", result.Name)
310+
}
311+
return
312+
}
313+
314+
if result == nil {
315+
t.Fatal("expected package, got nil")
316+
}
317+
318+
if result.Name != tt.expectName {
319+
t.Errorf("expected name %s, got %s", tt.expectName, result.Name)
320+
}
321+
322+
if result.Version != tt.expectVer {
323+
t.Errorf("expected version %s, got %s", tt.expectVer, result.Version)
324+
}
325+
})
326+
}
327+
}
328+
329+
func TestMergeSBOMsPreservesSource(t *testing.T) {
330+
// GIVEN: Multiple SBOMs with Source (metadata.component) set
331+
// WHEN: mergeSBOMs is called
332+
// THEN: Source from each SBOM should be converted to a package with contains relationships
333+
334+
sbom1 := createTestSBOM("sbom1", []pkg.Package{
335+
createTestPackage("pkg1", "1.0.0", ""),
336+
})
337+
sbom1.Source = source.Description{Name: "libelf", Version: "0.194"}
338+
339+
sbom2 := createTestSBOM("sbom2", []pkg.Package{
340+
createTestPackage("pkg2", "2.0.0", ""),
341+
})
342+
sbom2.Source = source.Description{Name: "glibc", Version: "2.38"}
343+
344+
_, packages, relationships := mergeSBOMs([]*sbom.SBOM{sbom1, sbom2})
345+
346+
// Should have: pkg1, pkg2, libelf (from source), glibc (from source) = 4 packages
347+
if len(packages) != 4 {
348+
t.Errorf("expected 4 packages (2 original + 2 from source), got %d", len(packages))
349+
}
350+
351+
// Verify libelf and glibc are in the packages
352+
foundLibelf := false
353+
foundGlibc := false
354+
for _, p := range packages {
355+
if p.Name == "libelf" && p.Version == "0.194" {
356+
foundLibelf = true
357+
}
358+
if p.Name == "glibc" && p.Version == "2.38" {
359+
foundGlibc = true
360+
}
361+
}
362+
363+
if !foundLibelf {
364+
t.Error("expected libelf package from source, not found")
365+
}
366+
if !foundGlibc {
367+
t.Error("expected glibc package from source, not found")
368+
}
369+
370+
// Verify contains relationships were created
371+
// Each SBOM has 1 package + 1 existing relationship from createTestSBOM
372+
// Plus 1 contains relationship from source to package = 2 contains relationships total
373+
containsCount := 0
374+
for _, rel := range relationships {
375+
if rel.Type == artifact.ContainsRelationship {
376+
containsCount++
377+
}
378+
}
379+
if containsCount != 2 {
380+
t.Errorf("expected 2 contains relationships (one per source), got %d", containsCount)
381+
}
382+
}

sbomtool/internal/deduplication/deduplicator.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/anchore/syft/syft/artifact"
1515
"github.com/anchore/syft/syft/cpe"
16+
"github.com/anchore/syft/syft/file"
1617
"github.com/anchore/syft/syft/pkg"
1718
)
1819

@@ -39,10 +40,10 @@ type DeduplicationStats struct {
3940
func DeduplicatePackages(packages []pkg.Package) *DeduplicationResult {
4041
startTime := time.Now()
4142

42-
// Filter out document root packages and other metadata packages
43+
// Filter out packages with empty names
4344
var realPackages []pkg.Package
4445
for _, p := range packages {
45-
if p.Name != "" && !strings.Contains(string(p.ID()), "DocumentRoot") {
46+
if p.Name != "" {
4647
realPackages = append(realPackages, p)
4748
}
4849
}
@@ -236,6 +237,23 @@ func mergePackages(packages []*pkg.Package) *pkg.Package {
236237
}
237238
}
238239

240+
// Merge Locations (union of all file locations)
241+
allLocations := make([]file.Location, 0)
242+
for _, p := range packages {
243+
allLocations = append(allLocations, p.Locations.ToSlice()...)
244+
}
245+
canonical.Locations = file.NewLocationSet(allLocations...)
246+
247+
// Preserve Metadata from first package with non-nil metadata
248+
if canonical.Metadata == nil {
249+
for _, p := range packages {
250+
if p.Metadata != nil {
251+
canonical.Metadata = p.Metadata
252+
break
253+
}
254+
}
255+
}
256+
239257
return &canonical
240258
}
241259

sbomtool/internal/processor/processor.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Package processor provides Syft-based SBOM processing capabilities optimized for Bottlerocket builds.
22
// It configures Syft's catalogers for comprehensive package detection including Go and Rust binary analysis.
3+
34
package processor
45

56
import (
@@ -8,6 +9,7 @@ import (
89
"os"
910

1011
"github.com/anchore/syft/syft/format"
12+
"github.com/anchore/syft/syft/format/cyclonedxjson"
1113
"github.com/anchore/syft/syft/pkg"
1214
"github.com/anchore/syft/syft/sbom"
1315
)
@@ -41,12 +43,23 @@ func LoadSBOM(path string) (*sbom.SBOM, string, error) {
4143
// SaveSBOM saves an SBOM using Syft's format encoders.
4244
// It supports all formats that Syft can encode.
4345
func SaveSBOM(s *sbom.SBOM, path, formatName string) error {
44-
// Find the encoder for the specified format
4546
var encoder sbom.FormatEncoder
46-
for _, enc := range format.Encoders() {
47-
if string(enc.ID()) == formatName {
48-
encoder = enc
49-
break
47+
var err error
48+
49+
if formatName == "cyclonedx-json" {
50+
encoder, err = cyclonedxjson.NewFormatEncoderWithConfig(cyclonedxjson.EncoderConfig{
51+
Version: "1.6",
52+
Pretty: true,
53+
})
54+
if err != nil {
55+
return fmt.Errorf("failed to create CycloneDX encoder: %w", err)
56+
}
57+
} else {
58+
for _, enc := range format.Encoders() {
59+
if string(enc.ID()) == formatName {
60+
encoder = enc
61+
break
62+
}
5063
}
5164
}
5265

0 commit comments

Comments
 (0)