From fc5d02fdc143f6276f48baf4571b33717076f963 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Thu, 14 May 2026 14:31:05 +0530 Subject: [PATCH] feat: add support for --exclude and --scope flags in SBOM generation command Co-authored-by: Copilot --- command/sbom_generate.go | 60 ++++++++++++++++++++-- command/sbom_generate_test.go | 89 +++++++++++++++++++++++++++++++++ internal/sbom/generator.go | 20 ++++++++ internal/sbom/generator_syft.go | 20 ++++++-- 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 command/sbom_generate_test.go diff --git a/command/sbom_generate.go b/command/sbom_generate.go index 136597a9a..6aee66855 100644 --- a/command/sbom_generate.go +++ b/command/sbom_generate.go @@ -34,6 +34,7 @@ func (cmd *SBOMGenerateCommand) ParseArgs(args []string) (*sbom.Config, int) { ScanPath: "/", Format: sbom.FormatCycloneDX, // default format Parallelism: 4, // default parallelism + Scope: sbom.ScopeSquashed, // default scope } //Parse Syft Style args @@ -59,11 +60,60 @@ func (cmd *SBOMGenerateCommand) ParseArgs(args []string) (*sbom.Config, int) { } cfg.Format = format + case "--exclude": + if i+1 >= len(args) { + cmd.Ui.Error("Missing value for --exclude flag") + return cfg, 1 + } + i++ + cfg.Exclude = append(cfg.Exclude, args[i]) + + case "--scope": + if i+1 >= len(args) { + cmd.Ui.Error("Missing value for --scope flag") + return cfg, 1 + } + i++ + scope, err := sbom.ParseScopeFromArgs(args[i]) + if err != nil { + cmd.Ui.Error(err.Error()) + return cfg, 1 + } + cfg.Scope = scope + default: - // Assume it's the scan path (positional argument) - if !strings.HasPrefix(arg, "-") { - cfg.ScanPath = arg + if strings.HasPrefix(arg, "--exclude=") { + value := strings.TrimPrefix(arg, "--exclude=") + if value == "" { + cmd.Ui.Error("Missing value for --exclude flag") + return cfg, 1 + } + cfg.Exclude = append(cfg.Exclude, value) + continue + } + + if strings.HasPrefix(arg, "--scope=") { + value := strings.TrimPrefix(arg, "--scope=") + if value == "" { + cmd.Ui.Error("Missing value for --scope flag") + return cfg, 1 + } + scope, err := sbom.ParseScopeFromArgs(value) + if err != nil { + cmd.Ui.Error(err.Error()) + return cfg, 1 + } + cfg.Scope = scope + continue + } + + if strings.HasPrefix(arg, "-") { + cmd.Ui.Say(fmt.Sprintf("Warning: unsupported sbom-generate argument ignored: %s", arg)) + continue } + + // Assume it's the scan path (positional argument) + cfg.ScanPath = arg } } return cfg, 0 @@ -104,6 +154,8 @@ Usage: packer sbom-generate [options] This command is typically invoked internally by the hcp-sbom provisioner. Options: -o Output format: cyclonedx-json, spdx-json (default: cyclonedx-json) + --exclude Optional: exclude path glob from scanning (repeatable) + --scope Optional: scan scope: squashed, all-layers (default: squashed) Arguments: Path to scan (default: /) Examples: @@ -113,6 +165,8 @@ Examples: packer sbom-generate -o spdx-json / > sbom.json # Scan specific directory packer sbom-generate -o cyclonedx-json /opt/app > app-sbom.json + # Scan all image layers and exclude temporary paths + packer sbom-generate --scope all-layers --exclude "/tmp/**" -o cyclonedx-json / > sbom.json Note: Output is written to stdout. Use shell redirection (>) to save to file. ` return strings.TrimSpace(helpText) diff --git a/command/sbom_generate_test.go b/command/sbom_generate_test.go new file mode 100644 index 000000000..03a5807bd --- /dev/null +++ b/command/sbom_generate_test.go @@ -0,0 +1,89 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "bytes" + "strings" + "testing" + + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/hashicorp/packer/internal/sbom" +) + +func TestSBOMGenerateCommand_ParseArgs_ExcludeAndScope(t *testing.T) { + var out, errOut bytes.Buffer + cmd := &SBOMGenerateCommand{ + Meta: Meta{ + Ui: &packersdk.BasicUi{ + Writer: &out, + ErrorWriter: &errOut, + }, + }, + } + + cfg, ret := cmd.ParseArgs([]string{ + "--exclude", "/tmp/**", + "--exclude=/var/cache/**", + "--scope", "all-layers", + "-o", "cyclonedx-json", + "/", + }) + if ret != 0 { + t.Fatalf("expected parse success, got ret=%d err=%q", ret, errOut.String()) + } + + if cfg.Scope != sbom.ScopeAllLayers { + t.Fatalf("expected scope %q, got %q", sbom.ScopeAllLayers, cfg.Scope) + } + + if len(cfg.Exclude) != 2 { + t.Fatalf("expected 2 exclude entries, got %d", len(cfg.Exclude)) + } + if cfg.Exclude[0] != "/tmp/**" || cfg.Exclude[1] != "/var/cache/**" { + t.Fatalf("unexpected exclude values: %#v", cfg.Exclude) + } +} + +func TestSBOMGenerateCommand_ParseArgs_InvalidScope(t *testing.T) { + var out, errOut bytes.Buffer + cmd := &SBOMGenerateCommand{ + Meta: Meta{ + Ui: &packersdk.BasicUi{ + Writer: &out, + ErrorWriter: &errOut, + }, + }, + } + + _, ret := cmd.ParseArgs([]string{"--scope", "bad-scope"}) + if ret == 0 { + t.Fatalf("expected parse failure for invalid scope") + } +} + +func TestSBOMGenerateCommand_ParseArgs_UnsupportedFlagWarns(t *testing.T) { + var out, errOut bytes.Buffer + cmd := &SBOMGenerateCommand{ + Meta: Meta{ + Ui: &packersdk.BasicUi{ + Writer: &out, + ErrorWriter: &errOut, + }, + }, + } + + cfg, ret := cmd.ParseArgs([]string{"-q", "/opt/app"}) + if ret != 0 { + t.Fatalf("expected parse success, got ret=%d err=%q", ret, errOut.String()) + } + + if cfg.ScanPath != "/opt/app" { + t.Fatalf("expected scan path /opt/app, got %q", cfg.ScanPath) + } + + if !strings.Contains(out.String(), "unsupported sbom-generate argument ignored: -q") { + t.Fatalf("expected warning for unsupported arg, got output %q", out.String()) + } +} diff --git a/internal/sbom/generator.go b/internal/sbom/generator.go index 1b7f30e7c..6122eff29 100644 --- a/internal/sbom/generator.go +++ b/internal/sbom/generator.go @@ -11,6 +11,8 @@ type Format string const ( FormatCycloneDX Format = "cyclonedx" FormatSPDX Format = "spdx" + ScopeSquashed string = "squashed" + ScopeAllLayers string = "all-layers" ) // Config holds configuration for SBOM generation @@ -18,6 +20,8 @@ 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) + Scope string // Scan scope (squashed or all-layers) + Exclude []string } // Generator generates SBOMs using embedded Syft SDK @@ -37,6 +41,9 @@ func NewGenerator(cfg Config) *Generator { if cfg.Parallelism == 0 { cfg.Parallelism = 4 } + if cfg.Scope == "" { + cfg.Scope = ScopeSquashed + } return &Generator{config: cfg} } @@ -55,3 +62,16 @@ func ParseFormatFromArgs(formatArg string) (Format, error) { return "", fmt.Errorf("unsupported format: %s", formatArg) } + +func ParseScopeFromArgs(scopeArg string) (string, error) { + scopeArg = strings.ToLower(strings.TrimSpace(scopeArg)) + + switch scopeArg { + case ScopeSquashed: + return ScopeSquashed, nil + case ScopeAllLayers, "all", "alllayers": + return ScopeAllLayers, nil + default: + return "", fmt.Errorf("unsupported scope: %s (supported: squashed, all-layers)", scopeArg) + } +} diff --git a/internal/sbom/generator_syft.go b/internal/sbom/generator_syft.go index b029c41b4..f12d5719c 100644 --- a/internal/sbom/generator_syft.go +++ b/internal/sbom/generator_syft.go @@ -22,17 +22,31 @@ import ( // 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) + getSourceCfg := syft.DefaultGetSourceConfig() + if len(g.config.Exclude) > 0 { + getSourceCfg = getSourceCfg.WithExcludeConfig(source.ExcludeConfig{Paths: g.config.Exclude}) + } + + src, err := syft.GetSource(ctx, sourceInput, getSourceCfg) if err != nil { return nil, fmt.Errorf("failed to get source: %w", err) } defer src.Close() + var scope source.Scope + switch g.config.Scope { + case ScopeAllLayers: + scope = source.AllLayersScope + case "", ScopeSquashed: + scope = source.SquashedScope + default: + return nil, fmt.Errorf("unsupported scope: %s", g.config.Scope) + } + sbomCfg := syft.DefaultCreateSBOMConfig(). WithSearchConfig(cataloging.SearchConfig{ - Scope: source.SquashedScope, + Scope: scope, }). WithParallelism(g.config.Parallelism)