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
14 changes: 10 additions & 4 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
summaryFlag 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, trivy)")
generateCmd.Flags().StringVarP(&catalog, "catalog", "c", "", "Cataloger to run before processing attestations (supported: syft)")
generateCmd.Flags().StringVar(&projectDir, "project-dir", "", "Project directory to scan with the cataloger (default: current directory)")
generateCmd.Flags().BoolVar(&summaryFlag, "summary", false, "Print a detailed package listing alongside the human-readable summary")
}

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

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

opts := &generator.Options{
Expand All @@ -93,13 +95,17 @@ func runGenerate(attestationFile string) error {
}

gen := generator.New(opts)
if err := gen.GenerateFromFile(attestationFile); err != nil {
doc, err := gen.GenerateFromFile(attestationFile)
if err != nil {
return fmt.Errorf("failed to generate SBOM: %w", err)
}

if outputPath != "" {
fmt.Fprintf(os.Stderr, "SBOM written to %s\n", outputPath)
}

summary := generator.GenerateSummary(doc)
generator.PrintSummary(summary, summaryFlag)

return nil
}
46 changes: 9 additions & 37 deletions pkg/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ func New(opts *Options) *Generator {
}
}

func (g *Generator) GenerateFromFile(attestationPath string) error {
func (g *Generator) GenerateFromFile(attestationPath string) (*sbom.Document, error) {
attestations, err := attestation.ParseWitnessFile(attestationPath, g.opts.AttestationTypes)
if err != nil {
return fmt.Errorf("failed to parse attestation file: %w", err)
return nil, fmt.Errorf("failed to parse attestation file: %w", err)
}
g.printParsedAttestationSummary(attestations)
return g.GenerateFromAttestations(attestations)
Expand Down Expand Up @@ -93,28 +93,23 @@ func (g *Generator) printParsedAttestationSummary(attestations []attestation.Typ
fmt.Fprintf(os.Stderr, "Parsed attestations (%d total): %s\n", len(attestations), strings.Join(parts, ", "))
}

func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAttestation) error {
func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAttestation) (*sbom.Document, error) {
var baseDoc *sbom.Document
var err error

projectDir := strings.TrimSpace(g.opts.ProjectDir)
if projectDir == "" {
projectDir, err = os.Getwd()
if err != nil {
return fmt.Errorf("failed to determine project directory: %w", err)
return nil, fmt.Errorf("failed to determine project directory: %w", err)
}
}

switch g.opts.Catalog {
case "syft":
baseDoc, err = g.runSyft(projectDir)
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)
return nil, fmt.Errorf("failed to run syft: %w", err)
}
default:
}
Expand Down Expand Up @@ -143,10 +138,12 @@ func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAtt
if baseDoc != nil {
g.applyMetadata(baseDoc)
g.mergePreferAttestation(baseDoc, attDoc)
return g.writeOutput(baseDoc)
err = g.writeOutput(baseDoc)
return baseDoc, err
}

return g.writeOutput(attDoc)
err = g.writeOutput(attDoc)
return attDoc, err
}

// mergeNetworkPackages merges network-resolved packages into the file-resolved result.
Expand Down Expand Up @@ -383,31 +380,6 @@ func (g *Generator) runSyft(projectDir string) (*sbom.Document, error) {
return r.ParseFile(tmpFile.Name())
}

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 - other platforms: https://trivy.dev/docs/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
// Tell Trivy to scan the directory and output an SPDX JSON file
cmd := exec.Command("trivy", "fs", "--format", "spdx-json", "--output", tmpFile.Name(), projectDir)
cmd.Stderr = &stderr

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

// Parse the SBOM using protobom (the same way we do with Syft)
r := reader.New()
return r.ParseFile(tmpFile.Name())
}

func (g *Generator) createPackageNode(pkg resolver.PackageInfo) *sbom.Node {
node := &sbom.Node{
Id: pkg.PURL,
Expand Down
106 changes: 106 additions & 0 deletions pkg/generator/summary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package generator

import (
"fmt"
"sort"
"strings"

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

type Summary struct {
TotalPackages int
TotalFiles int
EcosystemCounts map[string]int
PackagesByEcosystem map[string][]string
}

func GenerateSummary(doc *sbom.Document) Summary {
summary := Summary{
TotalPackages: 0,
TotalFiles: 0,
EcosystemCounts: make(map[string]int),
PackagesByEcosystem: make(map[string][]string),
}

if doc == nil || doc.NodeList == nil {
return summary
}

for _, node := range doc.NodeList.Nodes {
if node.Type == sbom.Node_PACKAGE {
summary.TotalPackages++

purl := string(node.Purl())
if purl != "" {
ecosystem := extractEcosystemFromPURL(purl)
if ecosystem != "" {
summary.EcosystemCounts[ecosystem]++
summary.PackagesByEcosystem[ecosystem] = append(summary.PackagesByEcosystem[ecosystem], purl)
} else {
summary.EcosystemCounts["unclassified"]++
summary.PackagesByEcosystem["unclassified"] = append(summary.PackagesByEcosystem["unclassified"], node.Id)
}
} else {
summary.EcosystemCounts["unclassified"]++
summary.PackagesByEcosystem["unclassified"] = append(summary.PackagesByEcosystem["unclassified"], node.Id)
}
} else if node.Type == sbom.Node_FILE {
summary.TotalFiles++
summary.EcosystemCounts["file"]++
summary.PackagesByEcosystem["file"] = append(summary.PackagesByEcosystem["file"], node.Id)
}
}

return summary
}

func extractEcosystemFromPURL(purl string) string {
// PURL format: pkg:type/namespace/name@version?qualifiers#subpath
if !strings.HasPrefix(purl, "pkg:") {
return ""
}

// Remove "pkg:"
remainder := strings.TrimPrefix(purl, "pkg:")

// Ecosystem (type) is the part before the first '/'
parts := strings.SplitN(remainder, "/", 2)
if len(parts) > 0 && parts[0] != "" {
return parts[0]
}

return ""
}

func PrintSummary(summary Summary, detailed bool) {
fmt.Println("\nSBOM Summary")
fmt.Println("------------")
fmt.Printf("Total Packages: %d\n", summary.TotalPackages)
if summary.TotalFiles > 0 {
fmt.Printf("Total Files: %d\n", summary.TotalFiles)
}

if len(summary.EcosystemCounts) > 0 {
fmt.Println("\nEcosystem Breakdown:")

var ecosystems []string
for eco := range summary.EcosystemCounts {
ecosystems = append(ecosystems, eco)
}
sort.Strings(ecosystems)

for _, eco := range ecosystems {
fmt.Printf(" %s: %d\n", eco, summary.EcosystemCounts[eco])
if detailed {
items := append([]string{}, summary.PackagesByEcosystem[eco]...)
sort.Strings(items)
for _, item := range items {
fmt.Printf(" - %s\n", item)
}
}
}
} else {
fmt.Println("\nEcosystem Breakdown: None")
}
}
113 changes: 113 additions & 0 deletions pkg/generator/summary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package generator

import (
"testing"

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

func TestGenerateSummary(t *testing.T) {
doc := &sbom.Document{
NodeList: &sbom.NodeList{
Nodes: []*sbom.Node{
{
Id: "node1",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:npm/react@18.2.0",
},
},
{
Id: "node2",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:golang/github.com/gin-gonic/gin@v1.9.0",
},
},
{
Id: "node3",
Type: sbom.Node_PACKAGE,
Identifiers: map[int32]string{
int32(sbom.SoftwareIdentifierType_PURL): "pkg:golang/github.com/spf13/cobra@v1.7.0",
},
},
{
Id: "node4",
Type: sbom.Node_PACKAGE,
// No PURL, should fall to unclassified
},
{
Id: "node5",
Type: sbom.Node_FILE,
// Should not be counted in summary.TotalPackages, but in TotalFiles
},
},
},
}

summary := GenerateSummary(doc)

if summary.TotalPackages != 4 {
t.Errorf("expected 4 total packages, got %d", summary.TotalPackages)
}

if summary.TotalFiles != 1 {
t.Errorf("expected 1 total file, got %d", summary.TotalFiles)
}

if summary.EcosystemCounts["npm"] != 1 {
t.Errorf("expected 1 npm package, got %d", summary.EcosystemCounts["npm"])
}

if summary.EcosystemCounts["golang"] != 2 {
t.Errorf("expected 2 golang packages, got %d", summary.EcosystemCounts["golang"])
}

if summary.EcosystemCounts["unclassified"] != 1 {
t.Errorf("expected 1 unclassified package, got %d", summary.EcosystemCounts["unclassified"])
}

if summary.EcosystemCounts["file"] != 1 {
t.Errorf("expected 1 file ecosystem, got %d", summary.EcosystemCounts["file"])
}

if len(summary.PackagesByEcosystem["npm"]) != 1 || summary.PackagesByEcosystem["npm"][0] != "pkg:npm/react@18.2.0" {
t.Errorf("expected correct PackagesByEcosystem for npm")
}

if len(summary.PackagesByEcosystem["unclassified"]) != 1 || summary.PackagesByEcosystem["unclassified"][0] != "node4" {
t.Errorf("expected correct PackagesByEcosystem for unclassified")
}

if len(summary.PackagesByEcosystem["file"]) != 1 || summary.PackagesByEcosystem["file"][0] != "node5" {
t.Errorf("expected correct PackagesByEcosystem for file")
}
}

func TestGenerateSummaryNilDoc(t *testing.T) {
summary := GenerateSummary(nil)
if summary.TotalPackages != 0 {
t.Errorf("expected 0 total packages for nil doc, got %d", summary.TotalPackages)
}
}

func TestExtractEcosystemFromPURL(t *testing.T) {
tests := []struct {
purl string
expected string
}{
{"pkg:npm/react@18.2.0", "npm"},
{"pkg:golang/github.com/foo/bar@1.0.0", "golang"},
{"pkg:rust/crate@1.0", "rust"},
{"pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1", "maven"},
{"invalid-purl", ""},
{"pkg:invalid", "invalid"},
}

for _, tt := range tests {
actual := extractEcosystemFromPURL(tt.purl)
if actual != tt.expected {
t.Errorf("expected %s for %s, got %s", tt.expected, tt.purl, actual)
}
}
}