diff --git a/cmd/generate.go b/cmd/generate.go index 3200df1..fd5185a 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -18,6 +18,7 @@ var ( attestationTypes []string catalog string projectDir string + summaryFlag 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, 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 { @@ -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{ @@ -93,7 +95,8 @@ 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) } @@ -101,5 +104,8 @@ func runGenerate(attestationFile string) error { fmt.Fprintf(os.Stderr, "SBOM written to %s\n", outputPath) } + summary := generator.GenerateSummary(doc) + generator.PrintSummary(summary, summaryFlag) + return nil } diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 6b80cf9..eeac0cb 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -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) @@ -93,7 +93,7 @@ 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 @@ -101,7 +101,7 @@ func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAtt 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) } } @@ -109,12 +109,7 @@ func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAtt 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: } @@ -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. @@ -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, diff --git a/pkg/generator/summary.go b/pkg/generator/summary.go new file mode 100644 index 0000000..ccc340e --- /dev/null +++ b/pkg/generator/summary.go @@ -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") + } +} diff --git a/pkg/generator/summary_test.go b/pkg/generator/summary_test.go new file mode 100644 index 0000000..488a94b --- /dev/null +++ b/pkg/generator/summary_test.go @@ -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) + } + } +}