Skip to content
Open
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
9 changes: 6 additions & 3 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
attestationTypes []string
catalog string
projectDir string
showEnrichment bool
)

var generateCmd = &cobra.Command{
Expand Down Expand Up @@ -57,8 +58,9 @@ func init() {
generateCmd.Flags().StringVarP(&documentVersion, "version", "v", "0.0.1", "Version for the SBOM document")
generateCmd.Flags().StringSliceVar(&authors, "author", []string{}, "Document authors (can be specified multiple times)")
generateCmd.Flags().StringSliceVar(&attestationTypes, "types", []string{"material", "command-run", "product", "network-trace"}, "Attestation types to parse (comma-separated).")
generateCmd.Flags().StringVarP(&catalog, "catalog", "c", "", "Cataloger to run before processing attestations (supported: syft)")
generateCmd.Flags().StringVarP(&catalog, "catalog", "c", "", "Cataloger to run before processing attestations (supported: syft, trivy)")
generateCmd.Flags().StringVar(&projectDir, "project-dir", "", "Project directory to scan with the cataloger (default: current directory)")
generateCmd.Flags().BoolVar(&showEnrichment, "show-enrichment", false, "Output a human-readable summary displaying the attestation-based enrichment")
}

func runGenerate(attestationFile string) error {
Expand All @@ -75,10 +77,10 @@ func runGenerate(attestationFile string) error {
}

validCatalogs := map[string]bool{
"": true, "syft": true,
"": true, "syft": true, "trivy": true,
}
if !validCatalogs[strings.ToLower(catalog)] {
return fmt.Errorf("invalid catalog: %s (supported: syft)", catalog)
return fmt.Errorf("invalid catalog: %s (supported: syft, trivy)", catalog)
}

opts := &generator.Options{
Expand All @@ -90,6 +92,7 @@ func runGenerate(attestationFile string) error {
OutputPath: outputPath,
Catalog: catalog,
ProjectDir: projectDir,
ShowEnrichment: showEnrichment,
}

gen := generator.New(opts)
Expand Down
109 changes: 109 additions & 0 deletions pkg/diff/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package diff

import (
"fmt"
"os"
"strings"

"github.com/protobom/protobom/pkg/sbom"
)

// normalizePURL removes qualifiers (everything after '?') for comparison
func normalizePURL(purl string) string {
if purl == "" {
return ""
}
if idx := strings.Index(purl, "?"); idx != -1 {
return purl[:idx]
}
return purl
}

// CompareSBOM compares the base SBOM against the attestation-derived SBOM
// to identify what was added and what properties were updated.
func CompareSBOM(base, enriched *sbom.Document) EnrichmentSummary {
if base == nil || base.NodeList == nil || enriched == nil || enriched.NodeList == nil {
return EnrichmentSummary{}
}

summary := EnrichmentSummary{}

// Index base nodes by normalized PURL
baseIndexByPURL := make(map[string]*sbom.Node)
totalBasePackages := 0

for _, node := range base.NodeList.Nodes {
if node != nil && node.Type == sbom.Node_PACKAGE {
totalBasePackages++

purl := string(node.Purl())
if purl == "" {
continue
}

basePurl := normalizePURL(purl)
baseIndexByPURL[strings.ToLower(basePurl)] = node
}
}

summary.TotalBase = totalBasePackages

// Compare enriched nodes against base
for _, enrichedNode := range enriched.NodeList.Nodes {
if enrichedNode == nil || enrichedNode.Type != sbom.Node_PACKAGE {
continue
}

purl := string(enrichedNode.Purl())
if purl == "" {
continue
}

basePurl := normalizePURL(purl)

baseNode, exists := baseIndexByPURL[strings.ToLower(basePurl)]
if !exists {
// New package not found in base
summary.Added++
} else {
// Existing package → check enrichment
if hasNewEnrichment(baseNode, enrichedNode) {
summary.Updated++
}
}
}

return summary
}

// hasNewEnrichment checks if enriched node has additional data compared to base
func hasNewEnrichment(base, enriched *sbom.Node) bool {
// 1️ Hash comparison
for algo, hash := range enriched.Hashes {
if baseHash, exists := base.Hashes[algo]; !exists || baseHash != hash {
return true
}
}

// PURL qualifier comparison
baseP := string(base.Purl())
enrichedP := string(enriched.Purl())

baseNorm := normalizePURL(baseP)
enrichedNorm := normalizePURL(enrichedP)

// Same base package but enriched has extra qualifiers
if baseNorm == enrichedNorm && baseP != enrichedP {
return true
}

return false
}

// PrintEnrichmentSummary outputs a formatted summary
func PrintEnrichmentSummary(summary EnrichmentSummary) {
fmt.Fprintf(os.Stderr, "\nSBOMit Enrichment Summary:\n")
fmt.Fprintf(os.Stderr, " • Base Packages: %d\n", summary.TotalBase)
fmt.Fprintf(os.Stderr, " • Added by Attestation: %d\n", summary.Added)
fmt.Fprintf(os.Stderr, " • Enriched Packages (Hashes, URLs, etc): %d\n\n", summary.Updated)
}
97 changes: 97 additions & 0 deletions pkg/diff/diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package diff

import (
"testing"

"github.com/protobom/protobom/pkg/sbom"
)

func TestCompareSBOM(t *testing.T) {
// Base document with two packages
baseDoc := &sbom.Document{
NodeList: &sbom.NodeList{
Nodes: []*sbom.Node{
{
Id: "pkg:npm/base-pkg@1.0.0",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:npm/base-pkg@1.0.0",
},
Hashes: make(map[int32]string),
},
{
Id: "pkg:npm/network-pkg@1.0.0",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:npm/network-pkg@1.0.0",
},
Hashes: make(map[int32]string),
},
},
},
}

// Calculate enriched document with an additional package and two enriched packages
enrichedDoc := &sbom.Document{
NodeList: &sbom.NodeList{
Nodes: []*sbom.Node{
// Enriched node (added a hash)
{
Id: "pkg:npm/base-pkg@1.0.0",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:npm/base-pkg@1.0.0",
},
Hashes: map[int32]string{
int32(sbom.HashAlgorithm_SHA256): "deadbeef",
},
},
// Enriched node (added PURL qualifiers from network trace)
{
Id: "pkg:npm/network-pkg@1.0.0",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:npm/network-pkg@1.0.0?url=https://registry.npmjs.org",
},
Hashes: make(map[int32]string),
},
// Completely unlisted node
{
Id: "pkg:npm/added-pkg@2.0.0",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:npm/added-pkg@2.0.0",
},
Hashes: make(map[int32]string),
},
},
},
}

summary := CompareSBOM(baseDoc, enrichedDoc)

if summary.TotalBase != 2 {
t.Errorf("Expected TotalBase to be 2, got %d", summary.TotalBase)
}

if summary.Added != 1 {
t.Errorf("Expected Added to be 1, got %d", summary.Added)
}

if summary.Updated != 2 {
t.Errorf("Expected Updated to be 2, got %d", summary.Updated)
}
}

func TestCompareSBOM_Empty(t *testing.T) {
summary := CompareSBOM(nil, nil)
if summary.TotalBase != 0 || summary.Added != 0 || summary.Updated != 0 {
t.Errorf("Expected zeros for nil inputs, got %+v", summary)
}

emptyDoc := &sbom.Document{NodeList: &sbom.NodeList{}}
summary = CompareSBOM(emptyDoc, emptyDoc)
if summary.TotalBase != 0 || summary.Added != 0 || summary.Updated != 0 {
t.Errorf("Expected zeros for empty inputs, got %+v", summary)
}
}
8 changes: 8 additions & 0 deletions pkg/diff/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package diff

// EnrichmentSummary holds the quantifiable metrics of SBOM enrichment.
type EnrichmentSummary struct {
TotalBase int
Added int
Updated int
}
37 changes: 35 additions & 2 deletions pkg/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"

"github.com/sbomit/sbomit/pkg/attestation"
"github.com/sbomit/sbomit/pkg/diff"
"github.com/sbomit/sbomit/pkg/resolver"
"github.com/sbomit/sbomit/pkg/resolver/network"
)
Expand All @@ -29,6 +30,7 @@ type Options struct {
OutputPath string
Catalog string
ProjectDir string
ShowEnrichment bool
}

// DefaultOptions returns default generator options
Expand Down Expand Up @@ -111,6 +113,11 @@ func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAtt
if err != nil {
return fmt.Errorf("failed to run syft: %w", err)
}
case "trivy":
baseDoc, err = g.runTrivy(projectDir)
if err != nil {
return fmt.Errorf("failed to run trivy: %w", err)
}
default:
}

Expand All @@ -137,6 +144,10 @@ func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAtt

if baseDoc != nil {
g.applyMetadata(baseDoc)
if g.opts.ShowEnrichment {
summary := diff.CompareSBOM(baseDoc, attDoc)
diff.PrintEnrichmentSummary(summary)
}
g.mergePreferAttestation(baseDoc, attDoc)
return g.writeOutput(baseDoc)
}
Expand Down Expand Up @@ -319,7 +330,6 @@ func (g *Generator) mergePreferAttestation(baseDoc *sbom.Document, attDoc *sbom.
}
}

// Fallback to ID match if no PURL match
if targetNode == nil {
if baseNode, ok := baseIndexByID[attNode.Id]; ok {
targetNode = baseNode
Expand All @@ -330,7 +340,7 @@ func (g *Generator) mergePreferAttestation(baseDoc *sbom.Document, attDoc *sbom.
// Merge: prefer attestation values over syft
targetNode.Update(attNode)
} else {
// New node from attestation

baseDoc.NodeList.AddNode(attNode)
baseIndexByID[attNode.Id] = attNode
if attPurl != "" {
Expand All @@ -346,6 +356,29 @@ func (g *Generator) mergePreferAttestation(baseDoc *sbom.Document, attDoc *sbom.
baseDoc.NodeList.Add(mergeList)
}

func (g *Generator) runTrivy(projectDir string) (*sbom.Document, error) {
if _, err := exec.LookPath("trivy"); err != nil {
return nil, fmt.Errorf("trivy not found in PATH. Install options:\n - macOS (brew): brew install trivy\n - curl: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin\n - other platforms: https://aquasecurity.github.io/trivy/latest/getting-started/installation/")
}

tmpFile, err := os.CreateTemp("", "sbomit-trivy-*.json")
if err != nil {
return nil, fmt.Errorf("create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())

var stderr bytes.Buffer
cmd := exec.Command("trivy", "fs", projectDir, "--format", "spdx-json", "--output", tmpFile.Name())
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("trivy failed: %w: %s", err, strings.TrimSpace(stderr.String()))
}

r := reader.New()
return r.ParseFile(tmpFile.Name())
}

func (g *Generator) runSyft(projectDir string) (*sbom.Document, error) {
if _, err := exec.LookPath("syft"); err != nil {
return nil, fmt.Errorf("syft not found in PATH. Install options:\n - macOS (brew): brew install syft\n - go install: go install github.com/anchore/syft/cmd/syft@latest\n - other platforms: https://github.com/anchore/syft#installation")
Expand Down