diff --git a/command/sbom_generate.go b/command/sbom_generate.go new file mode 100644 index 000000000..136597a9a --- /dev/null +++ b/command/sbom_generate.go @@ -0,0 +1,119 @@ +package command + +//Types to define SBOM generation command +// - Command struct (e.g., SBOMGenerateCommand) +// - Flags for the command (e.g., --format, --output) +// - Method to execute the command (e.g., Run) + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/hashicorp/packer/internal/sbom" +) + +type SBOMGenerateCommand struct { + Meta +} + +func (cmd *SBOMGenerateCommand) Run(args []string) int { + ctx, cleanup := handleTermInterrupt(cmd.Ui) + defer cleanup() + + cfg, ret := cmd.ParseArgs(args) + if ret != 0 { + return ret + } + return cmd.RunContext(ctx, cfg) +} + +func (cmd *SBOMGenerateCommand) ParseArgs(args []string) (*sbom.Config, int) { + cfg := &sbom.Config{ + ScanPath: "/", + Format: sbom.FormatCycloneDX, // default format + Parallelism: 4, // default parallelism + } + + //Parse Syft Style args + // Parse Syft-style arguments + for i := 0; i < len(args); i++ { + arg := args[i] + + switch arg { + case "-o", "--output": + // Next arg is format + if i+1 >= len(args) { + cmd.Ui.Error("Missing value for -o flag") + return cfg, 1 + } + i++ + formatStr := args[i] + + // Parse format string + format, err := sbom.ParseFormatFromArgs(formatStr) + if err != nil { + cmd.Ui.Error(err.Error()) + return cfg, 1 + } + cfg.Format = format + + default: + // Assume it's the scan path (positional argument) + if !strings.HasPrefix(arg, "-") { + cfg.ScanPath = arg + } + } + } + return cfg, 0 +} + +func (cmd *SBOMGenerateCommand) RunContext(ctx context.Context, cfg *sbom.Config) int { + fmt.Fprintf(os.Stderr, "Generating %s SBOM for %s...\n", cfg.Format, cfg.ScanPath) + + // Create generator + generator := sbom.NewGenerator(*cfg) + + // Generate SBOM + sbomData, err := generator.Generate(ctx) + if err != nil { + cmd.Ui.Error(fmt.Sprintf("SBOM generation failed: %s", err)) + return 1 + } + + // Write to stdout (will be redirected to file via > operator) + _, err = os.Stdout.Write(sbomData) + if err != nil { + cmd.Ui.Error(fmt.Sprintf("Failed to write SBOM: %s", err)) + return 1 + } + + fmt.Fprintln(os.Stderr, "✓ SBOM generation completed") + + return 0 + +} +func (c *SBOMGenerateCommand) Synopsis() string { + return "Generate SBOM for the local system (internal use)" +} +func (c *SBOMGenerateCommand) Help() string { + helpText := ` +Usage: packer sbom-generate [options] + Generate a Software Bill of Materials (SBOM) for the local filesystem. + This command is typically invoked internally by the hcp-sbom provisioner. +Options: + -o Output format: cyclonedx-json, spdx-json (default: cyclonedx-json) +Arguments: + Path to scan (default: /) +Examples: + # Generate CycloneDX SBOM for root filesystem + packer sbom-generate -o cyclonedx-json / > sbom.json + # Generate SPDX SBOM + packer sbom-generate -o spdx-json / > sbom.json + # Scan specific directory + packer sbom-generate -o cyclonedx-json /opt/app > app-sbom.json +Note: Output is written to stdout. Use shell redirection (>) to save to file. +` + return strings.TrimSpace(helpText) +} diff --git a/commands.go b/commands.go index 4159218d3..18b1e4133 100644 --- a/commands.go +++ b/commands.go @@ -108,6 +108,10 @@ func init() { }, nil }, + "sbom-generate": func() (cli.Command, error) { + return &command.SBOMGenerateCommand{Meta: *CommandMeta}, nil + }, + // plugin is essentially an alias to the plugins command // // It is not meant to be documented or used outside of simple diff --git a/go.mod b/go.mod index 1174782f5..4fec58987 100644 --- a/go.mod +++ b/go.mod @@ -61,8 +61,10 @@ require ( github.com/oklog/ulid v1.3.1 github.com/pierrec/lz4/v4 v4.1.22 github.com/shirou/gopsutil/v3 v3.23.4 + github.com/sirupsen/logrus v1.9.4 github.com/spdx/tools-golang v0.5.7 google.golang.org/grpc v1.79.3 + modernc.org/sqlite v1.46.1 ) require ( @@ -303,6 +305,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect @@ -325,6 +328,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect github.com/ryanuber/go-glob v1.0.0 // indirect @@ -335,7 +339,6 @@ require ( github.com/sergi/go-diff v1.4.0 // indirect github.com/shoenig/go-m1cpu v0.1.5 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d // indirect @@ -395,6 +398,9 @@ require ( gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) go 1.25.9 diff --git a/go.sum b/go.sum index bc0da1d2a..aa68c35e2 100644 --- a/go.sum +++ b/go.sum @@ -1762,14 +1762,34 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/internal/sbom/generator.go b/internal/sbom/generator.go new file mode 100644 index 000000000..1b7f30e7c --- /dev/null +++ b/internal/sbom/generator.go @@ -0,0 +1,57 @@ +package sbom + +import ( + "fmt" + "strings" +) + +// Format represents the SBOM output format +type Format string + +const ( + FormatCycloneDX Format = "cyclonedx" + FormatSPDX Format = "spdx" +) + +// Config holds configuration for SBOM generation +type Config struct { + ScanPath string // Path to scan (e.g., "/", "/opt/app") + Format Format // Output format (cyclonedx or spdx) + Parallelism int // Number of parallel catalogers (0 = auto-detect) +} + +// Generator generates SBOMs using embedded Syft SDK +type Generator struct { + config Config +} + +// NewGenerator creates a new SBOM generator with the given configuration +func NewGenerator(cfg Config) *Generator { + // Set defaults + if cfg.ScanPath == "" { + cfg.ScanPath = "/" + } + if cfg.Format == "" { + cfg.Format = FormatCycloneDX + } + if cfg.Parallelism == 0 { + cfg.Parallelism = 4 + } + + return &Generator{config: cfg} +} + +// ParseFormatFromArgs parses Syft-style format argument +// This is a PACKAGE-LEVEL EXPORTED FUNCTION +func ParseFormatFromArgs(formatArg string) (Format, error) { + formatArg = strings.ToLower(formatArg) + + if strings.Contains(formatArg, "cyclonedx") { + return FormatCycloneDX, nil + } + if strings.Contains(formatArg, "spdx") { + return FormatSPDX, nil + } + + return "", fmt.Errorf("unsupported format: %s", formatArg) +} diff --git a/internal/sbom/generator_syft.go b/internal/sbom/generator_syft.go new file mode 100644 index 000000000..f6f130209 --- /dev/null +++ b/internal/sbom/generator_syft.go @@ -0,0 +1,77 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !netbsd && !openbsd && !solaris + +package sbom + +import ( + "context" + "fmt" + + _ "modernc.org/sqlite" + + "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/cataloging" + "github.com/anchore/syft/syft/format" + "github.com/anchore/syft/syft/format/cyclonedxjson" + "github.com/anchore/syft/syft/format/spdxjson" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" +) + +// Generate creates an SBOM for the configured scan path and returns the encoded result. +func (g *Generator) Generate(ctx context.Context) ([]byte, error) { + + sourceInput := g.config.ScanPath + src, err := syft.GetSource(ctx, sourceInput, nil) + if err != nil { + return nil, fmt.Errorf("failed to get source: %w", err) + } + defer src.Close() + + sbomCfg := syft.DefaultCreateSBOMConfig(). + WithSearchConfig(cataloging.SearchConfig{ + Scope: source.SquashedScope, + }). + WithParallelism(g.config.Parallelism) + + sbomResult, err := syft.CreateSBOM(ctx, src, sbomCfg) + if err != nil { + return nil, fmt.Errorf("failed to create SBOM: %w", err) + } + + return g.encodeToFormat(sbomResult) +} + +// encodeToFormat encodes the SBOM to the requested format. +func (g *Generator) encodeToFormat(sbomData *sbom.SBOM) ([]byte, error) { + switch g.config.Format { + case FormatCycloneDX: + encoder, err := cyclonedxjson.NewFormatEncoderWithConfig( + cyclonedxjson.EncoderConfig{ + Version: "1.5", + Pretty: true, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to create CycloneDX encoder: %w", err) + } + return format.Encode(*sbomData, encoder) + + case FormatSPDX: + encoder, err := spdxjson.NewFormatEncoderWithConfig( + spdxjson.EncoderConfig{ + Version: "2.3", + Pretty: true, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to create SPDX encoder: %w", err) + } + return format.Encode(*sbomData, encoder) + + default: + return nil, fmt.Errorf("unsupported format: %s (supported: cyclonedx, spdx)", g.config.Format) + } +} diff --git a/internal/sbom/generator_unsupported.go b/internal/sbom/generator_unsupported.go new file mode 100644 index 000000000..ce46022ca --- /dev/null +++ b/internal/sbom/generator_unsupported.go @@ -0,0 +1,18 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +//go:build netbsd || openbsd || solaris + +package sbom + +import ( + "context" + "fmt" + "runtime" +) + +// Generate returns an error on platforms where the Syft SDK cannot be built. +func (g *Generator) Generate(ctx context.Context) ([]byte, error) { + _ = ctx + return nil, fmt.Errorf("sbom generation is not supported on %s builds", runtime.GOOS) +} diff --git a/main.go b/main.go index f2374fba6..d7e1393d0 100644 --- a/main.go +++ b/main.go @@ -265,7 +265,7 @@ func wrappedMain() int { Args: args, Autocomplete: true, Commands: Commands, - HelpFunc: excludeHelpFunc(Commands, []string{"execute", "plugin"}), + HelpFunc: excludeHelpFunc(Commands, []string{"execute", "plugin", "sbom-generate"}), HelpWriter: os.Stdout, Name: "packer", Version: version.Version, diff --git a/version/VERSION b/version/VERSION index f2380cc7a..749afc16b 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -1.15.3 +1.15.3-dev