Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sbomtool/cmd/sbomtool/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/spf13/cobra"
_ "modernc.org/sqlite"

"github.com/bottlerocket-os/bottlerocket-sdk/sbomtool/go/internal/commands/filter"
"github.com/bottlerocket-os/bottlerocket-sdk/sbomtool/go/internal/commands/generate"
Expand Down
179 changes: 127 additions & 52 deletions sbomtool/go.mod

Large diffs are not rendered by default.

400 changes: 278 additions & 122 deletions sbomtool/go.sum

Large diffs are not rendered by default.

392 changes: 375 additions & 17 deletions sbomtool/go.work.sum

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions sbomtool/internal/commands/generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ func Generate(name string, spdx bool, cyclonedx bool, buildDir string, outDir st
return false, fmt.Errorf("failed to create SBOM: %w", err)
}

// Set the source name from the --name parameter so it appears in metadata.component
sbomData.Source.Name = name

if spdx {
slog.Info("Generating SPDX SBOM", "name", name)
if err := createSpdxSbom(*sbomData, name, outDir); err != nil {
Expand Down
45 changes: 40 additions & 5 deletions sbomtool/internal/commands/merge/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"

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

// sourceToPackage converts an SBOM's Source (metadata.component) to a package.
// This ensures the subject of each input SBOM is preserved in the merged output.
func sourceToPackage(src source.Description) *pkg.Package {
if src.Name == "" {
return nil
}

p := pkg.Package{
Name: src.Name,
Version: src.Version,
Type: pkg.UnknownPkg,
}
p.SetID()

slog.Debug("Converted source to package", "name", src.Name, "version", src.Version)
return &p
}

// mergeSBOMs combines SBOM metadata and extracts all packages and relationships.
// Each input SBOM's Source (metadata.component) is converted to a package to preserve
// the subject of each SBOM in the merged output. Contains relationships are created
// from the source package to all packages in that SBOM.
func mergeSBOMs(sboms []*sbom.SBOM) (*sbom.SBOM, []pkg.Package, []artifact.Relationship) {
if len(sboms) == 0 {
return &sbom.SBOM{}, nil, nil
}

// Use first SBOM as base
// Use first SBOM as base for descriptor
merged := &sbom.SBOM{
Descriptor: sboms[0].Descriptor,
Source: sboms[0].Source,
}

var allPackages []pkg.Package
var allRelationships []artifact.Relationship

// Collect all packages and relationships
// Collect all packages and relationships, including Source as a package
for _, s := range sboms {
allPackages = append(allPackages, s.Artifacts.Packages.Sorted()...)
sbomPackages := s.Artifacts.Packages.Sorted()
allPackages = append(allPackages, sbomPackages...)
allRelationships = append(allRelationships, s.Relationships...)

// Convert Source (metadata.component) to a package and create contains relationships
if srcPkg := sourceToPackage(s.Source); srcPkg != nil {
allPackages = append(allPackages, *srcPkg)

// Create "contains" relationships from source to each package in this SBOM
for _, p := range sbomPackages {
allRelationships = append(allRelationships, artifact.Relationship{
From: *srcPkg,
To: p,
Type: artifact.ContainsRelationship,
})
}
}
}

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

return &sbom.SBOM{
Descriptor: base.Descriptor,
Source: base.Source,
Artifacts: sbom.Artifacts{
Packages: collection,
},
Expand Down
125 changes: 123 additions & 2 deletions sbomtool/internal/commands/merge/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,10 @@ func TestMergeSBOMs(t *testing.T) {
createTestPackage("pkg3", "3.0.0", ""),
}),
},
expectedPackageCount: 4,
expectedRelationshipCount: 2,
// 4 original packages + 2 source packages (test1, test2) = 6
// 2 original relationships + 4 contains relationships (2 pkgs × 2 sources) = 6
expectedPackageCount: 6,
expectedRelationshipCount: 6,
},
{
name: "empty SBOM slice",
Expand Down Expand Up @@ -259,3 +261,122 @@ func createTestPackagePtr(name, version, cpeStr string) *pkg.Package {
p := createTestPackage(name, version, cpeStr)
return &p
}

func TestSourceToPackage(t *testing.T) {
// GIVEN: Various source descriptions
// WHEN: sourceToPackage is called
// THEN: Appropriate packages should be created

tests := []struct {
name string
src source.Description
expectNil bool
expectName string
expectVer string
}{
{
name: "valid source with name and version",
src: source.Description{Name: "libelf", Version: "0.194"},
expectNil: false,
expectName: "libelf",
expectVer: "0.194",
},
{
name: "valid source with name only",
src: source.Description{Name: "mypackage"},
expectNil: false,
expectName: "mypackage",
expectVer: "",
},
{
name: "empty source",
src: source.Description{},
expectNil: true,
},
{
name: "source with empty name",
src: source.Description{Name: "", Version: "1.0"},
expectNil: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sourceToPackage(tt.src)

if tt.expectNil {
if result != nil {
t.Errorf("expected nil, got package with name %s", result.Name)
}
return
}

if result == nil {
t.Fatal("expected package, got nil")
}

if result.Name != tt.expectName {
t.Errorf("expected name %s, got %s", tt.expectName, result.Name)
}

if result.Version != tt.expectVer {
t.Errorf("expected version %s, got %s", tt.expectVer, result.Version)
}
})
}
}

func TestMergeSBOMsPreservesSource(t *testing.T) {
// GIVEN: Multiple SBOMs with Source (metadata.component) set
// WHEN: mergeSBOMs is called
// THEN: Source from each SBOM should be converted to a package with contains relationships

sbom1 := createTestSBOM("sbom1", []pkg.Package{
createTestPackage("pkg1", "1.0.0", ""),
})
sbom1.Source = source.Description{Name: "libelf", Version: "0.194"}

sbom2 := createTestSBOM("sbom2", []pkg.Package{
createTestPackage("pkg2", "2.0.0", ""),
})
sbom2.Source = source.Description{Name: "glibc", Version: "2.38"}

_, packages, relationships := mergeSBOMs([]*sbom.SBOM{sbom1, sbom2})

// Should have: pkg1, pkg2, libelf (from source), glibc (from source) = 4 packages
if len(packages) != 4 {
t.Errorf("expected 4 packages (2 original + 2 from source), got %d", len(packages))
}

// Verify libelf and glibc are in the packages
foundLibelf := false
foundGlibc := false
for _, p := range packages {
if p.Name == "libelf" && p.Version == "0.194" {
foundLibelf = true
}
if p.Name == "glibc" && p.Version == "2.38" {
foundGlibc = true
}
}

if !foundLibelf {
t.Error("expected libelf package from source, not found")
}
if !foundGlibc {
t.Error("expected glibc package from source, not found")
}

// Verify contains relationships were created
// Each SBOM has 1 package + 1 existing relationship from createTestSBOM
// Plus 1 contains relationship from source to package = 2 contains relationships total
containsCount := 0
for _, rel := range relationships {
if rel.Type == artifact.ContainsRelationship {
containsCount++
}
}
if containsCount != 2 {
t.Errorf("expected 2 contains relationships (one per source), got %d", containsCount)
}
}
22 changes: 20 additions & 2 deletions sbomtool/internal/deduplication/deduplicator.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
)

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

// Filter out document root packages and other metadata packages
// Filter out packages with empty names
var realPackages []pkg.Package
for _, p := range packages {
if p.Name != "" && !strings.Contains(string(p.ID()), "DocumentRoot") {
if p.Name != "" {
realPackages = append(realPackages, p)
}
}
Expand Down Expand Up @@ -236,6 +237,23 @@ func mergePackages(packages []*pkg.Package) *pkg.Package {
}
}

// Merge Locations (union of all file locations)
allLocations := make([]file.Location, 0)
for _, p := range packages {
allLocations = append(allLocations, p.Locations.ToSlice()...)
}
canonical.Locations = file.NewLocationSet(allLocations...)

// Preserve Metadata from first package with non-nil metadata
if canonical.Metadata == nil {
for _, p := range packages {
if p.Metadata != nil {
canonical.Metadata = p.Metadata
break
}
}
}

return &canonical
}

Expand Down
23 changes: 18 additions & 5 deletions sbomtool/internal/processor/processor.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Package processor provides Syft-based SBOM processing capabilities optimized for Bottlerocket builds.
// It configures Syft's catalogers for comprehensive package detection including Go and Rust binary analysis.

package processor

import (
Expand All @@ -8,6 +9,7 @@ import (
"os"

"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
)
Expand Down Expand Up @@ -41,12 +43,23 @@ func LoadSBOM(path string) (*sbom.SBOM, string, error) {
// SaveSBOM saves an SBOM using Syft's format encoders.
// It supports all formats that Syft can encode.
func SaveSBOM(s *sbom.SBOM, path, formatName string) error {
// Find the encoder for the specified format
var encoder sbom.FormatEncoder
for _, enc := range format.Encoders() {
if string(enc.ID()) == formatName {
encoder = enc
break
var err error

if formatName == "cyclonedx-json" {
encoder, err = cyclonedxjson.NewFormatEncoderWithConfig(cyclonedxjson.EncoderConfig{
Version: "1.6",
Pretty: true,
})
if err != nil {
return fmt.Errorf("failed to create CycloneDX encoder: %w", err)
}
} else {
for _, enc := range format.Encoders() {
if string(enc.ID()) == formatName {
encoder = enc
break
}
}
}

Expand Down