Skip to content

Commit 193d66a

Browse files
committed
fix: add root node to the merged sbom
SBOM data was previously missing data from the root node of the dependency graph when merged. Fixed merge to include this node as well Signed-off-by: Richard Kelly <rpkelly@amazon.com>
1 parent e219a58 commit 193d66a

File tree

7 files changed

+947
-198
lines changed

7 files changed

+947
-198
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+
}

0 commit comments

Comments
 (0)