From d5d9acd3dec0fed2e96cea6d77f4b16963d72808 Mon Sep 17 00:00:00 2001 From: corepacket Date: Tue, 7 Apr 2026 00:13:12 +0530 Subject: [PATCH] feat: highlight exact attestation value with --show-enrichment diff flag Adds a generic SBOM diff layer via pkg/diff and pipes the logic to generate commands Signed-off-by: corepacket --- cmd/generate.go | 9 ++- pkg/diff/diff.go | 109 +++++++++++++++++++++++++++++++++++++ pkg/diff/diff_test.go | 97 +++++++++++++++++++++++++++++++++ pkg/diff/types.go | 8 +++ pkg/generator/generator.go | 37 ++++++++++++- 5 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 pkg/diff/diff.go create mode 100644 pkg/diff/diff_test.go create mode 100644 pkg/diff/types.go diff --git a/cmd/generate.go b/cmd/generate.go index c4af136..6e86321 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -18,6 +18,7 @@ var ( attestationTypes []string catalog string projectDir string + showEnrichment bool ) var generateCmd = &cobra.Command{ @@ -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 { @@ -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{ @@ -90,6 +92,7 @@ func runGenerate(attestationFile string) error { OutputPath: outputPath, Catalog: catalog, ProjectDir: projectDir, + ShowEnrichment: showEnrichment, } gen := generator.New(opts) diff --git a/pkg/diff/diff.go b/pkg/diff/diff.go new file mode 100644 index 0000000..05c1f5a --- /dev/null +++ b/pkg/diff/diff.go @@ -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) +} diff --git a/pkg/diff/diff_test.go b/pkg/diff/diff_test.go new file mode 100644 index 0000000..5efec7a --- /dev/null +++ b/pkg/diff/diff_test.go @@ -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) + } +} diff --git a/pkg/diff/types.go b/pkg/diff/types.go new file mode 100644 index 0000000..2df3c87 --- /dev/null +++ b/pkg/diff/types.go @@ -0,0 +1,8 @@ +package diff + +// EnrichmentSummary holds the quantifiable metrics of SBOM enrichment. +type EnrichmentSummary struct { + TotalBase int + Added int + Updated int +} diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 00b6add..b6e3499 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -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" ) @@ -29,6 +30,7 @@ type Options struct { OutputPath string Catalog string ProjectDir string + ShowEnrichment bool } // DefaultOptions returns default generator options @@ -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: } @@ -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) } @@ -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 @@ -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 != "" { @@ -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")