From b90187ae091dce137a3971bd583bdee1f49ca589 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Wed, 20 May 2026 12:42:52 +0530 Subject: [PATCH] feat(hcp-sbom): add scanner_args field and update execute_command format - Introduced `scanner_args` field in FlatConfig for additional scanner arguments. - Updated `execute_command` to use `sbom-generate` for SBOM generation. - Enhanced tests to cover new configurations and command formats. - Added a new file for downloading the latest Packer release and verifying checksums. - Removed deprecated `scanner_url` and `scanner_checksum` fields, updating documentation accordingly. - Bumped version to 1.15.4. --- command/sbom_generate.go | 167 +++ command/sbom_generate_test.go | 88 ++ commands.go | 4 + go.mod | 6 + go.sum | 20 + internal/sbom/generator.go | 77 ++ internal/sbom/generator_syft.go | 91 ++ internal/sbom/generator_unsupported.go | 18 + main.go | 2 +- provisioner/hcp-sbom/provisioner.go | 1110 +++++------------ provisioner/hcp-sbom/provisioner.hcl2spec.go | 4 +- provisioner/hcp-sbom/provisioner_test.go | 126 +- provisioner/hcp-sbom/release_download.go | 307 +++++ provisioner/hcp-sbom/syft_dependency.go | 18 - version/VERSION | 2 +- .../content/docs/provisioners/hcp-sbom.mdx | 7 + 16 files changed, 1194 insertions(+), 853 deletions(-) create mode 100644 command/sbom_generate.go create mode 100644 command/sbom_generate_test.go create mode 100644 internal/sbom/generator.go create mode 100644 internal/sbom/generator_syft.go create mode 100644 internal/sbom/generator_unsupported.go create mode 100644 provisioner/hcp-sbom/release_download.go delete mode 100644 provisioner/hcp-sbom/syft_dependency.go diff --git a/command/sbom_generate.go b/command/sbom_generate.go new file mode 100644 index 000000000..155fddb58 --- /dev/null +++ b/command/sbom_generate.go @@ -0,0 +1,167 @@ +package command + +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 + Scope: sbom.ScopeSquashed, // default scope + } + + //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 + + 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: + 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, "-") { + continue + } + + // Assume it's the scan path (positional argument) + 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) + --exclude Optional: exclude path glob from scanning (repeatable) + --scope Optional: scan scope: squashed, all-layers (default: squashed) +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 + # 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..996d083cd --- /dev/null +++ b/command/sbom_generate_test.go @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "bytes" + "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_UnsupportedFlagIgnored(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 out.Len() != 0 { + t.Fatalf("expected no stdout output for ignored arg, got %q", out.String()) + } +} 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..3ae486685 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/shirou/gopsutil/v3 v3.23.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 +304,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 +327,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 @@ -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..6122eff29 --- /dev/null +++ b/internal/sbom/generator.go @@ -0,0 +1,77 @@ +package sbom + +import ( + "fmt" + "strings" +) + +// Format represents the SBOM output format +type Format string + +const ( + FormatCycloneDX Format = "cyclonedx" + FormatSPDX Format = "spdx" + ScopeSquashed string = "squashed" + ScopeAllLayers string = "all-layers" +) + +// 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) + Scope string // Scan scope (squashed or all-layers) + Exclude []string +} + +// 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 + } + if cfg.Scope == "" { + cfg.Scope = ScopeSquashed + } + + 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) +} + +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 new file mode 100644 index 000000000..60018c848 --- /dev/null +++ b/internal/sbom/generator_syft.go @@ -0,0 +1,91 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !netbsd && !openbsd && !solaris && !mips && !mipsle && !mips64 && !(freebsd && 386) + +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 + 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 func() { _ = 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: scope, + }). + 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..e6d3a12d7 --- /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 || mips || mipsle || mips64 || (freebsd && 386) + +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/provisioner/hcp-sbom/provisioner.go b/provisioner/hcp-sbom/provisioner.go index 57a483d20..373e0324c 100644 --- a/provisioner/hcp-sbom/provisioner.go +++ b/provisioner/hcp-sbom/provisioner.go @@ -7,32 +7,24 @@ package hcp_sbom import ( - "archive/tar" "archive/zip" "bytes" - "compress/gzip" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" - "net/http" "os" "path/filepath" "regexp" "strings" - "time" - "github.com/hashicorp/go-getter/v2" "github.com/hashicorp/hcl/v2/hcldec" hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" "github.com/hashicorp/packer-plugin-sdk/common" "github.com/hashicorp/packer-plugin-sdk/guestexec" packersdk "github.com/hashicorp/packer-plugin-sdk/packer" - "github.com/hashicorp/packer-plugin-sdk/retry" "github.com/hashicorp/packer-plugin-sdk/template/config" "github.com/hashicorp/packer-plugin-sdk/template/interpolate" ) @@ -59,30 +51,27 @@ type Config struct { // an artifact version. SbomName string `mapstructure:"sbom_name" required:"false"` - // Enable automatic SBOM generation by downloading and running a scanner - // tool on the remote host. When enabled, the provisioner will detect the - // remote OS and architecture, download an appropriate scanner (Syft by - // default), and execute it to generate an SBOM. - // Mutually exclusive with `source`. + // Enable automatic SBOM generation by running `packer sbom-generate` on + // the remote host. When enabled, the provisioner uploads the running Packer + // binary (which embeds the Syft SDK) to the remote VM and executes it there + // to generate an SBOM. Mutually exclusive with `source`. AutoGenerate bool `mapstructure:"auto_generate" required:"true"` - // URL to scanner tool. Supports go-getter syntax including HTTP, local - // files, Git, S3, etc. If empty and `auto_generate` is true, Syft will be - // automatically downloaded based on detected OS and architecture. When - // specified, only direct binary URLs are supported (archives are not - // extracted). + // Arguments to pass to `packer sbom-generate`. Default: + // `["-o", "cyclonedx-json"]`. + ScannerArgs []string `mapstructure:"scanner_args" required:"false"` + + // DEPRECATED: Custom scanner URL is no longer supported. The hcp-sbom + // provisioner now uses the Packer binary with embedded Syft SDK for + // automatic SBOM generation. This field is ignored and will be removed + // in a future major version. For custom SBOM tools, use manual generation + // with the `source` field instead of `auto_generate`. ScannerURL string `mapstructure:"scanner_url" required:"false"` - // Expected SHA256 checksum of scanner binary for verification. Provide as - // a hex string without prefix, for example: `abc123def456...`. If - // provided, `scanner_url` must also be specified. The checksum is verified - // after download and before upload to the remote host. + // DEPRECATED: Scanner checksum verification is no longer supported. + // This field is ignored and will be removed in a future major version. ScannerChecksum string `mapstructure:"scanner_checksum" required:"false"` - // Arguments to pass to the scanner tool. Default for Syft: - // `["-o", "cyclonedx-json", "-q"]`. - ScannerArgs []string `mapstructure:"scanner_args" required:"false"` - // Path to scan on remote host. Defaults to `/` (root directory). ScanPath string `mapstructure:"scan_path" required:"false"` @@ -94,41 +83,28 @@ type Config struct { // - `{{.ScanPath}}` - Path to scan (from `scan_path`) // - `{{.Output}}` - Output file path for the SBOM // - // Default for Unix: `chmod +x {{.Path}} && sudo {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}` + // Default for Unix: `chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}` // - // Default for Windows: `{{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}` + // Default for Windows: `{{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}` // // Examples: // // Without sudo: // // ``` hcl - // execute_command = "chmod +x {{.Path}} && {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}" + // execute_command = "chmod +x {{.Path}} && {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}" // ``` // // With sudo password: // // ``` hcl - // execute_command = "chmod +x {{.Path}} && echo 'password' | sudo -S {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}" - // ``` - // - // With sudo -n (no password): - // - // ``` hcl - // execute_command = "chmod +x {{.Path}} && sudo -n {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}" - // ``` - // - // With specific user: - // - // ``` hcl - // execute_command = "chmod +x {{.Path}} && sudo -u myuser {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}" + // execute_command = "chmod +x {{.Path}} && echo 'password' | sudo -S {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}" // ``` ExecuteCommand string `mapstructure:"execute_command" required:"false"` - // A username to use for elevated permissions when running the scanner on - // Windows. This is only used for Windows hosts when the scanner needs - // administrative privileges. For Unix-like systems, use `execute_command` - // with sudo instead. + // A username to use for elevated permissions when running Packer on + // Windows. This is only used for Windows hosts when elevated privileges + // are required. For Unix-like systems, use `execute_command` with sudo instead. ElevatedUser string `mapstructure:"elevated_user" required:"false"` // The password for the `elevated_user`. Required if `elevated_user` is @@ -144,6 +120,22 @@ type Provisioner struct { generatedData map[string]interface{} } +func formatUIWarning(message string) string { + if os.Getenv("PACKER_NO_COLOR") != "" { + return "WARNING: " + message + } + return "\033[33mWARNING:\033[0m " + message +} + +func (p *Provisioner) warnDeprecatedConfigInUI(ui packersdk.Ui) { + if p.config.ScannerURL != "" { + ui.Say(formatUIWarning("'scanner_url' is deprecated and ignored. This field will be removed in a future version.")) + } + if p.config.ScannerChecksum != "" { + ui.Say(formatUIWarning("'scanner_checksum' is deprecated and ignored. This field will be removed in a future version.")) + } +} + func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() } @@ -154,6 +146,38 @@ func (p *Provisioner) FlatConfig() interface{} { var sbomFormatRegexp = regexp.MustCompile("^[0-9A-Za-z-]{3,36}$") +// scannerPathTokenRegexp matches the raw execute_command template token used +// for the uploaded binary path, including optional whitespace inside the +// template braces. +// +// Examples that match: +// +// {{.Path}} +// {{ .Path }} +// +// Examples that do not match: +// +// {{.Args}} +// /tmp/packer-sbom-runner +var scannerPathTokenRegexp = regexp.MustCompile(`\{\{\s*\.Path\s*\}\}`) + +// scannerArgsOrScanPathTokenPrefixRegexp matches only when the next +// non-whitespace token after {{.Path}} is either {{.Args}} or {{.ScanPath}}. +// This is the backward-compatible shape of older scanner commands where the +// path was executed directly without an explicit sbom-generate subcommand. +// +// Examples that match after trimming leading whitespace: +// +// {{.Args}} {{.ScanPath}} > {{.Output}} +// {{ .ScanPath }} > {{.Output}} +// +// Examples that do not match: +// +// sbom-generate {{.Args}} {{.ScanPath}} +// version +// && chmod +x {{.Path}} +var scannerArgsOrScanPathTokenPrefixRegexp = regexp.MustCompile(`^\{\{\s*\.(Args|ScanPath)\s*\}\}`) + func (p *Provisioner) Prepare(raws ...interface{}) error { err := config.Decode(&p.config, &config.DecodeOpts{ PluginType: "hcp-sbom", @@ -184,22 +208,21 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { p.config.ScanPath = "/" } if len(p.config.ScannerArgs) == 0 { - // Default to CycloneDX JSON format with quiet output + // Default to CycloneDX JSON format p.config.ScannerArgs = []string{ "-o", "cyclonedx-json", - "-q", // Quiet mode - suppress non-essential output } } // Set default execute_command if not provided // Note: This will be further customized based on OS at runtime if p.config.ExecuteCommand == "" { - p.config.ExecuteCommand = "chmod +x {{.Path}} && sudo {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}" + p.config.ExecuteCommand = "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}" } - // Validate: if checksum is provided, URL must also be provided + // Keep legacy validation for clarity while fields remain accepted. if p.config.ScannerChecksum != "" && p.config.ScannerURL == "" { - errs = packersdk.MultiErrorAppend(errs, errors.New("scanner_checksum requires scanner_url to be specified")) + errs = packersdk.MultiErrorAppend(errs, errors.New("scanner_checksum requires scanner_url to be specified (note: both fields are deprecated and ignored)")) } // Validate elevated user configuration (Windows only) @@ -278,24 +301,15 @@ func (p *Provisioner) Provision( // Native generation enabled ui.Say("Automatic SBOM generation enabled") + p.warnDeprecatedConfigInUI(ui) - var osType, osArch string - var err error - - // Only detect OS if scanner_url is NOT provided - if p.config.ScannerURL == "" { - osType, osArch, err = p.detectRemoteOS(ctx, ui, comm, generatedData) - if err != nil { - ui.Error(fmt.Sprintf("Failed to detect remote OS: %s", err)) - ui.Error("SBOM generation will be skipped, but build will continue") - return nil - } - ui.Say(fmt.Sprintf("Detected: OS=%s, Arch=%s", osType, osArch)) - } else { - // User provided scanner URL, assume they know their platform - osType = "unknown" - osArch = "unknown" + osType, osArch, err := p.detectRemoteOS(ctx, ui, comm, generatedData) + if err != nil { + ui.Error(fmt.Sprintf("Failed to detect remote OS: %s", err)) + ui.Error("SBOM generation will be skipped, but build will continue") + return nil } + ui.Say(fmt.Sprintf("Detected: OS=%s, Arch=%s", osType, osArch)) err = p.provisionWithNativeGeneration(ctx, ui, comm, generatedData, osType, osArch) if err != nil { @@ -452,754 +466,213 @@ func (p *Provisioner) getUserDestination() (string, error) { return dst, nil } -// downloadAndVerifyScanner downloads and verifies the scanner binary. -// It performs atomic download + checksum verification to ensure consistency. -func (p *Provisioner) downloadAndVerifyScanner( - ctx context.Context, ui packersdk.Ui, osType, osArch string, scannerPath *string, -) error { - // Download the scanner - path, err := p.downloadScanner(ctx, ui, osType, osArch) - if err != nil { - log.Printf("[WARN] Scanner download failed, will retry: %s", err) - return err - } - - // Verify checksum if provided (part of atomic operation) - if p.config.ScannerChecksum != "" { - if err := p.verifyChecksum(path); err != nil { - log.Printf("[WARN] Checksum verification failed, will retry: %s", err) - _ = os.Remove(path) // Clean up failed download, ignore error - return err - } - log.Printf("[INFO] Checksum verification passed") - } - - *scannerPath = path - return nil -} - -// provisionWithNativeGeneration handles the new native SBOM generation flow -func (p *Provisioner) provisionWithNativeGeneration( - ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, - generatedData map[string]interface{}, osType, osArch string, -) error { - ui.Say("Starting Automatic SBOM generation workflow...") - - // Step 1: Download scanner binary with retry logic (max 3 attempts) - // Make the entire download + checksum verification atomic - var scannerLocalPath string - log.Println("Downloading scanner binary...") - err := retry.Config{ - Tries: 3, - RetryDelay: func() time.Duration { return 10 * time.Second }, - }.Run(ctx, func(ctx context.Context) error { - return p.downloadAndVerifyScanner(ctx, ui, osType, osArch, &scannerLocalPath) - }) - - if err != nil { - return fmt.Errorf("failed to download and verify scanner after retries: %s", err) - } - defer func() { - _ = os.Remove(scannerLocalPath) // Cleanup, ignore error - }() - - log.Printf("Scanner ready at: %s", scannerLocalPath) - - // Step 2: Upload scanner to remote - log.Println("Uploading scanner to remote host...") - remoteScannerPath, err := p.uploadScanner(ctx, ui, comm, scannerLocalPath, osType) - if err != nil { - return fmt.Errorf("failed to upload scanner: %s", err) - } - defer p.cleanupRemoteFile(ctx, ui, comm, remoteScannerPath) - - // Step 3: Run scanner on remote - ui.Say(fmt.Sprintf("Running scanner on remote host (scanning %s)...", p.config.ScanPath)) - remoteSBOMPath, err := p.runScanner(ctx, ui, comm, remoteScannerPath, osType) - if err != nil { - return fmt.Errorf("failed to run scanner: %s", err) - } - defer p.cleanupRemoteFile(ctx, ui, comm, remoteSBOMPath) - - // Step 4: Download SBOM from remote - log.Println("Downloading SBOM from remote host...") - sbomData, err := p.downloadSBOM(ctx, ui, comm, remoteSBOMPath) - if err != nil { - return fmt.Errorf("failed to download SBOM: %s", err) - } - - // Step 5: Process SBOM for HCP (validate, compress, store) - log.Println("Processing SBOM for HCP Packer...") - if err := p.processSBOMForHCP(generatedData, sbomData); err != nil { - return fmt.Errorf("failed to process SBOM: %s", err) - } - - ui.Say("Automatic SBOM generation completed successfully") - return nil -} - -// downloadScanner downloads the scanner binary using go-getter -// If scanner_url is provided: downloads binary directly (no archive extraction) -// If scanner_url is empty: auto-downloads Syft archive and extracts it -// For Windows auto-download: returns zip file path for remote extraction (optimization) -// For Unix auto-download: extracts and returns the binary path -func (p *Provisioner) downloadScanner(ctx context.Context, ui packersdk.Ui, - osType, osArch string) (string, error) { - var downloadURL string - isCustomURL := p.config.ScannerURL != "" - - // If user provided a URL, use it (expect direct binary, not archive) - if isCustomURL { - downloadURL = p.config.ScannerURL - log.Printf("Using custom scanner URL: %s", downloadURL) - } else { - // Default to Syft from GitHub releases (archive format) - if osType == "unknown" || osArch == "unknown" { - return "", fmt.Errorf("cannot auto-download scanner: OS/Arch unknown (provide scanner_url)") - } - log.Printf("Fetching latest Syft version for %s/%s...", osType, osArch) - downloadURL = p.buildDefaultSyftURL(osType, osArch) - log.Printf("Download URL: %s", downloadURL) - } +// uploadScanner uploads the scanner binary to the remote host. +// For Windows: uploads zip file and extracts remotely (optimization for slow WinRM uploads). +// For Unix: uploads binary directly. +// +// Unix path: +// 1. Extract local zip entry `packer` +// 2. Upload binary → /tmp/packer-sbom-runner +// 3. chmod +x → /tmp/packer-sbom-runner +// +// Windows path: +// 1. Upload zip → C:\Windows\Temp\packer-sbom-runner.zip +// 2-5. Single PowerShell command (Expand-Archive + Move-Item + Remove-Item) +func (p *Provisioner) uploadScanner(ctx context.Context, ui packersdk.Ui, + comm packersdk.Communicator, localZipPath, osType string) (string, error) { + _ = ui isWindows := strings.Contains(strings.ToLower(osType), "windows") - // Create temporary directory for download - tmpDir, err := os.MkdirTemp("", "packer-scanner-*") - if err != nil { - return "", fmt.Errorf("failed to create temp directory: %s", err) - } - defer func() { - _ = os.RemoveAll(tmpDir) // Cleanup, ignore error - }() - - // Use go-getter to download - client := &getter.Client{} - - // For Windows auto-download (Syft): disable decompression to keep the zip file intact - // This optimization allows us to upload the zip and extract remotely (faster than uploading extracted binary) - if isWindows && !isCustomURL { - client.Decompressors = map[string]getter.Decompressor{} - } - - req := &getter.Request{ - Src: downloadURL, - Dst: tmpDir, - } - - log.Println("Downloading scanner...") - if _, err := client.Get(ctx, req); err != nil { - return "", fmt.Errorf("failed to download scanner: %s", err) - } - - // If custom URL provided, expect a direct binary (no extraction needed) - if isCustomURL { - // Find the downloaded binary - var binaryPath string - _ = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - binaryPath = path - return filepath.SkipDir - } - return nil - }) - - if binaryPath == "" { - return "", fmt.Errorf("no file found in download") - } - - // Copy to permanent temp location - return p.copyScannerToTemp(binaryPath) + var remotePath, binaryName string + if isWindows { + binaryName = "packer.exe" + remotePath = "C:\\Windows\\Temp\\packer-sbom-runner.exe" + } else { + binaryName = "packer" + remotePath = "/tmp/packer-sbom-runner" } - // Auto-download (Syft): handle archive extraction - // For Windows: return the zip file path for remote extraction (faster upload) if isWindows { - // Find the zip file - var zipPath string - _ = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(path), ".zip") { - zipPath = path - return filepath.SkipDir - } - return nil - }) + remoteDir := "C:\\Windows\\Temp" + remoteZipPath := remoteDir + "\\packer-sbom-runner.zip" - if zipPath == "" { - return "", fmt.Errorf("no zip file found in downloaded files") - } - - // Copy zip to a permanent temp location using existing helper - finalPath, err := p.copyScannerToTemp(zipPath) + // Step 1: upload zip to remote. + zipFile, err := os.Open(localZipPath) if err != nil { - return "", fmt.Errorf("failed to copy zip file: %s", err) + return "", fmt.Errorf("failed to open Packer release zip: %s", err) } + defer func() { _ = zipFile.Close() }() - log.Printf("Scanner zip ready: %s (will extract on remote)", finalPath) - return finalPath, nil - } - - // For Unix: extract and return binary path (existing behavior) - scannerPath, err := p.findScannerBinary(tmpDir, osType) - if err != nil { - return "", fmt.Errorf("failed to locate scanner binary: %s", err) - } - - // Copy to a permanent temp location - finalPath, err := p.copyScannerToTemp(scannerPath) - if err != nil { - return "", fmt.Errorf("failed to copy scanner: %s", err) - } - - log.Printf("Scanner downloaded to: %s", finalPath) - return finalPath, nil -} - -// buildDefaultSyftURL constructs the default Syft download URL -func (p *Provisioner) buildDefaultSyftURL(osType, osArch string) string { - // Map to Syft platform naming - syftOS, syftArch := p.mapToSyftPlatform(osType, osArch) - - // Fetch latest version from GitHub API - version := p.getLatestSyftVersion() - if version == "" { - // Fallback to a known stable version if API call fails - log.Printf("[WARN] Failed to fetch latest Syft version, using fallback v1.42.2") - version = "v1.42.2" - } - - // Determine file extension based on OS - // Windows uses .zip, Unix-like systems use .tar.gz - fileExt := ".tar.gz" - if strings.Contains(strings.ToLower(osType), "windows") { - fileExt = ".zip" - } - - // Construct GitHub release URL - // Example: https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz - // Example: https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_windows_amd64.zip - versionNum := strings.TrimPrefix(version, "v") - fileName := fmt.Sprintf("syft_%s_%s_%s%s", versionNum, syftOS, syftArch, fileExt) - - return fmt.Sprintf("https://github.com/anchore/syft/releases/download/%s/%s", - version, fileName) -} - -// getLatestSyftVersion fetches the latest Syft v1.x release version from GitHub API -// It paginates through releases to find the latest v1 version for compatibility -func (p *Provisioner) getLatestSyftVersion() string { - // Create HTTP client with timeout - client := &http.Client{ - Timeout: 30 * time.Second, - } - - // Compile regex to match v1.x.x format (e.g., v1.0.0, v1.42.2, v1.100.25) - v1Pattern := regexp.MustCompile(`^v1\.\d+\.\d+$`) - - // Paginate through releases to find latest v1.x.x - page := 1 - perPage := 100 - - for page <= 10 { // Limit to 10 pages (1000 releases) to avoid infinite loops - // GitHub API endpoint for releases with pagination - apiURL := fmt.Sprintf("https://api.github.com/repos/anchore/syft/releases?per_page=%d&page=%d", perPage, page) - - // Create request - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - log.Printf("[WARN] Failed to create request for Syft version: %s", err) - return "" + log.Printf("[INFO] Uploading Packer release zip to %s...", remoteZipPath) + if err := comm.Upload(remoteZipPath, zipFile, nil); err != nil { + return "", fmt.Errorf("failed to upload Packer release zip: %s", err) + } + + // Single PowerShell command: extract, move binary, remove zip. + psCmd := fmt.Sprintf( + `powershell -NoProfile -ExecutionPolicy Bypass -Command `+ + `"$ErrorActionPreference='Stop'; `+ + `Expand-Archive -Path '%s' -DestinationPath '%s' -Force; `+ + `if (!(Test-Path '%s\%s')) { throw 'packer.exe not found after extraction' }; `+ + `Move-Item -Force '%s\%s' '%s'; `+ + `Remove-Item -Force '%s'"`, + remoteZipPath, remoteDir, + remoteDir, binaryName, + remoteDir, binaryName, remotePath, + remoteZipPath, + ) + if err := p.runRemoteCmd(ctx, comm, psCmd, "extract scanner (Windows)"); err != nil { + return "", err } - - // Set User-Agent header (GitHub API requires it) - req.Header.Set("User-Agent", "Packer-HCP-SBOM-Provisioner") - req.Header.Set("Accept", "application/vnd.github.v3+json") - - // Make request - resp, err := client.Do(req) + } else { + // Step 1: extract the binary locally from the release zip. + binaryData, err := extractBinaryFromZip(localZipPath, binaryName) if err != nil { - log.Printf("[WARN] Failed to fetch Syft releases (page %d): %s", page, err) - return "" + return "", fmt.Errorf("failed to extract %s from Packer release zip: %w", binaryName, err) } - // Check status code - if resp.StatusCode == http.StatusNotFound { - // 404 means no more pages available - log.Printf("[INFO] No more Syft releases available (page %d returned 404)", page) - _ = resp.Body.Close() - break - } - if resp.StatusCode != http.StatusOK { - log.Printf("[WARN] GitHub API returned status %d for Syft releases (page %d)", resp.StatusCode, page) - _ = resp.Body.Close() - return "" + // Step 2: upload binary directly to remote. + localFile := bytes.NewReader(binaryData) + log.Printf("[INFO] Uploading Packer binary to %s...", remotePath) + if err := comm.Upload(remotePath, localFile, nil); err != nil { + return "", fmt.Errorf("failed to upload Packer binary: %s", err) } - // Parse response - var releases []struct { - TagName string `json:"tag_name"` - } - - if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { - log.Printf("[WARN] Failed to parse Syft releases response (page %d): %s", page, err) - _ = resp.Body.Close() - return "" + // Step 3: make it executable. + chmodCmd := fmt.Sprintf(`chmod +x "%s"`, remotePath) + if err := p.runRemoteCmd(ctx, comm, chmodCmd, "chmod scanner binary"); err != nil { + return "", err } - _ = resp.Body.Close() - // Look for latest v1.x.x release using regex - for _, release := range releases { - if v1Pattern.MatchString(release.TagName) { - log.Printf("[INFO] Found latest Syft v1.x.x version: %s", release.TagName) - return release.TagName - } + // Final verify: confirm binary is executable. + verifyCmd := fmt.Sprintf(`test -x "%s"`, remotePath) + if err := p.runRemoteCmd(ctx, comm, verifyCmd, "verify scanner is executable"); err != nil { + return "", fmt.Errorf("scanner binary is not executable at %s after chmod; "+ + "check that /tmp is not mounted noexec on the remote host", remotePath) } - - // If we got fewer releases than per_page, we've reached the end - if len(releases) < perPage { - break - } - - page++ } - log.Printf("[WARN] No Syft v1.x.x release found in paginated results") - return "" -} - -// mapToSyftPlatform maps detected OS/Arch to Syft naming conventions -func (p *Provisioner) mapToSyftPlatform(osType, osArch string) (string, string) { - osType = strings.ToLower(osType) - osArch = strings.ToLower(osArch) - - // Map OS - syftOS := "linux" - if strings.Contains(osType, "darwin") || strings.Contains(osType, "macos") { - syftOS = "darwin" - } else if strings.Contains(osType, "windows") { - syftOS = "windows" - } else if strings.Contains(osType, "freebsd") { - syftOS = "freebsd" - } - - // Map Architecture - syftArch := osArch - switch osArch { - case "x86_64", "amd64": - syftArch = "amd64" - case "aarch64", "arm64": - syftArch = "arm64" - case "i386", "i686": - syftArch = "386" - case "armv7l", "armv7": - syftArch = "arm" - } - - return syftOS, syftArch + return remotePath, nil } -// findScannerBinary locates the scanner executable in the downloaded directory. -// It handles three cases: -// 1. Standalone binary (no archive) - used directly -// 2. .tar.gz archive - extracted to find binary -// 3. .zip archive - extracted to find binary -func (p *Provisioner) findScannerBinary(dir, osType string) (string, error) { - osType = strings.ToLower(osType) - - var binaryName string - if strings.Contains(osType, "windows") { - binaryName = "syft.exe" - } else { - binaryName = "syft" +func extractBinaryFromZip(zipPath, binaryName string) ([]byte, error) { + zr, err := zip.OpenReader(zipPath) + if err != nil { + return nil, fmt.Errorf("failed to open zip: %w", err) } + defer func() { _ = zr.Close() }() - // First, try to find the binary directly (handles standalone binaries) - var foundPath string - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + for _, f := range zr.File { + if f.Name != binaryName { + continue } - if !info.IsDir() { - fileName := filepath.Base(path) - // Match exact name or name as part of the file - if fileName == binaryName || strings.Contains(fileName, binaryName) { - // For archives, we want the actual binary, not the archive - if !strings.HasSuffix(fileName, ".tar.gz") && - !strings.HasSuffix(fileName, ".zip") && - !strings.HasSuffix(fileName, ".tar") { - foundPath = path - return filepath.SkipDir - } - } - } - return nil - }) - if err != nil { - return "", err - } - - if foundPath == "" { - // Binary not found directly, try to extract from archive (.tar.gz or .zip) - foundPath, err = p.extractScannerFromArchive(dir, binaryName) + rc, err := f.Open() if err != nil { - return "", fmt.Errorf("scanner binary '%s' not found in downloaded files", binaryName) + return nil, fmt.Errorf("failed to open %s from zip: %w", binaryName, err) } - } - return foundPath, nil -} - -// extractScannerFromArchive extracts the scanner binary from a tar.gz or zip archive -func (p *Provisioner) extractScannerFromArchive(dir, binaryName string) (string, error) { - // Find archive file (.tar.gz or .zip) - var archivePath string - var isZip bool - _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + data, readErr := io.ReadAll(rc) + closeErr := rc.Close() + if readErr != nil { + return nil, fmt.Errorf("failed to read %s from zip: %w", binaryName, readErr) } - if !info.IsDir() { - if strings.HasSuffix(path, ".tar.gz") { - archivePath = path - isZip = false - return filepath.SkipDir - } else if strings.HasSuffix(path, ".zip") { - archivePath = path - isZip = true - return filepath.SkipDir - } + if closeErr != nil { + return nil, fmt.Errorf("failed to close %s stream from zip: %w", binaryName, closeErr) } - return nil - }) - - if archivePath == "" { - return "", fmt.Errorf("no tar.gz or zip archive found") + return data, nil } - if isZip { - return p.extractFromZip(archivePath, dir, binaryName) - } - return p.extractFromTarGz(archivePath, dir, binaryName) + return nil, fmt.Errorf("%s not found in zip %s", binaryName, zipPath) } -// extractFromTarGz extracts a binary from a tar.gz archive -func (p *Provisioner) extractFromTarGz(archivePath, dir, binaryName string) (string, error) { - // Open and extract - file, err := os.Open(archivePath) - if err != nil { - return "", err +// runRemoteCmd runs a single shell command on the remote host and returns a +// descriptive error if it fails. +func (p *Provisioner) runRemoteCmd(ctx context.Context, comm packersdk.Communicator, cmdStr, step string) error { + var stderr bytes.Buffer + cmd := &packersdk.RemoteCmd{ + Command: cmdStr, + Stderr: &stderr, } - defer func() { - _ = file.Close() // Cleanup, ignore error - }() - - gzr, err := gzip.NewReader(file) - if err != nil { - return "", err + if err := comm.Start(ctx, cmd); err != nil { + return fmt.Errorf("failed to start remote step %q: %s", step, err) } - defer func() { - _ = gzr.Close() // Cleanup, ignore error - }() - - tr := tar.NewReader(gzr) - - // Find and extract the binary - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return "", err - } - - // Look for the binary - if filepath.Base(header.Name) == binaryName { - // Create temporary file for the binary - tmpBinary, err := os.CreateTemp(dir, "syft-binary-*") - if err != nil { - return "", err - } - defer func() { - _ = tmpBinary.Close() // Cleanup, ignore error - }() - - // Copy binary content - if _, err := io.Copy(tmpBinary, tr); err != nil { - return "", err - } - - // Make executable - if err := os.Chmod(tmpBinary.Name(), 0755); err != nil { - return "", err - } - - return tmpBinary.Name(), nil - } + cmd.Wait() + if cmd.ExitStatus() != 0 { + return fmt.Errorf("remote step %q failed (exit %d): %s", + step, cmd.ExitStatus(), strings.TrimSpace(stderr.String())) } - - return "", fmt.Errorf("binary '%s' not found in tar.gz archive", binaryName) + return nil } -// extractFromZip extracts a binary from a zip archive -func (p *Provisioner) extractFromZip(archivePath, dir, binaryName string) (string, error) { - // Open zip file - zipReader, err := zip.OpenReader(archivePath) - if err != nil { - return "", err - } - defer func() { - _ = zipReader.Close() // Cleanup, ignore error - }() - - // Find and extract the binary - for _, file := range zipReader.File { - if filepath.Base(file.Name) == binaryName { - // Open file in zip - rc, err := file.Open() - if err != nil { - return "", err - } - defer func() { - _ = rc.Close() // Cleanup, ignore error - }() - - // Create temporary file for the binary - tmpBinary, err := os.CreateTemp(dir, "syft-binary-*") - if err != nil { - return "", err - } - defer func() { - _ = tmpBinary.Close() // Cleanup, ignore error - }() - - // Copy binary content - if _, err := io.Copy(tmpBinary, rc); err != nil { - return "", err - } - - // Make executable (important for Unix, no-op on Windows) - if err := os.Chmod(tmpBinary.Name(), 0755); err != nil { - return "", err - } +// provisionWithNativeGeneration handles the native SBOM generation flow. +// The Packer release binary is downloaded on the host (works in air-gapped +// environments where the VM has no internet access), verified, then uploaded +// to the remote host via the communicator before running `packer sbom-generate`. +func (p *Provisioner) provisionWithNativeGeneration( + ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, + generatedData map[string]interface{}, osType, osArch string, +) error { + ui.Say("Starting Automatic SBOM generation workflow...") - return tmpBinary.Name(), nil - } + // Step 1: Download Packer release binary on the host. + targetGOOS := strings.ToLower(osType) + archMap := map[string]string{ + "x86_64": "amd64", "aarch64": "arm64", "i386": "386", "i686": "386", "armv7l": "arm", "armv7": "arm", } - - return "", fmt.Errorf("binary '%s' not found in zip archive", binaryName) -} - -// copyScannerToTemp copies the scanner binary to a permanent temp location -func (p *Provisioner) copyScannerToTemp(srcPath string) (string, error) { - // Create temp file - tmpFile, err := os.CreateTemp("", "packer-scanner-*") - if err != nil { - return "", err + targetGOARCH := strings.ToLower(osArch) + if mapped, ok := archMap[targetGOARCH]; ok { + targetGOARCH = mapped } - defer func() { - _ = tmpFile.Close() // Cleanup, ignore error - }() - - // Open source - src, err := os.Open(srcPath) + ui.Say(fmt.Sprintf("Downloading latest Packer release for %s/%s from %s...", targetGOOS, targetGOARCH, getReleaseBaseURL())) + scannerZipPath, err := downloadPackerRelease(ctx, targetGOOS, targetGOARCH) if err != nil { - return "", err + return fmt.Errorf("failed to download Packer release for %s/%s: %w", targetGOOS, targetGOARCH, err) } defer func() { - _ = src.Close() // Cleanup, ignore error + if err := os.Remove(scannerZipPath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("[WARN] failed to remove temporary Packer release zip %s: %v", scannerZipPath, err) + } }() - // Copy - if _, err := io.Copy(tmpFile, src); err != nil { - return "", err - } - - // Make executable - if err := os.Chmod(tmpFile.Name(), 0755); err != nil { - return "", err - } - - return tmpFile.Name(), nil -} - -// verifyChecksum verifies the SHA256 checksum of the scanner binary -func (p *Provisioner) verifyChecksum(filePath string) error { - // Open file - file, err := os.Open(filePath) + // Step 2: Upload scanner to remote. + log.Println("Uploading scanner to remote host...") + remoteScannerPath, err := p.uploadScanner(ctx, ui, comm, scannerZipPath, osType) if err != nil { - return err - } - defer func() { - _ = file.Close() // Cleanup, ignore error - }() - - // Calculate SHA256 - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return err - } - - // Get hex string - actualChecksum := hex.EncodeToString(hash.Sum(nil)) - - // Compare with expected - expectedChecksum := strings.ToLower(strings.TrimSpace(p.config.ScannerChecksum)) - if actualChecksum != expectedChecksum { - return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum) - } - - return nil -} - -// uploadScanner uploads the scanner binary to the remote host -// For Windows: uploads zip file and extracts remotely (optimization for slow WinRM uploads) -// For Unix: uploads binary directly -func (p *Provisioner) uploadScanner(ctx context.Context, ui packersdk.Ui, - comm packersdk.Communicator, localPath, osType string) (string, error) { - - isWindows := strings.Contains(strings.ToLower(osType), "windows") - isZipFile := strings.HasSuffix(strings.ToLower(localPath), ".zip") - - // Windows optimization: upload zip and extract remotely - if isWindows && isZipFile { - log.Println("Uploading scanner zip file...") - - // Upload zip to remote - remoteZipPath := "C:\\Windows\\Temp\\packer-sbom-scanner.zip" - zipFile, err := os.Open(localPath) - if err != nil { - return "", fmt.Errorf("failed to open scanner zip: %s", err) - } - defer func() { - _ = zipFile.Close() // Cleanup, ignore error - }() - - log.Printf("Uploading zip to %s...", remoteZipPath) - if err := comm.Upload(remoteZipPath, zipFile, nil); err != nil { - return "", fmt.Errorf("failed to upload scanner zip: %s", err) - } - - // Extract zip on remote using PowerShell - extractDir := "C:\\Windows\\Temp\\packer-sbom-scanner" - - // Simplified extraction command - extractCmd := fmt.Sprintf( - "powershell -Command \"Expand-Archive -Path '%s' -DestinationPath '%s' -Force\"", - remoteZipPath, extractDir, - ) - - log.Println("Extracting scanner on remote host...") - cmd := &packersdk.RemoteCmd{Command: extractCmd} - if err := comm.Start(ctx, cmd); err != nil { - return "", fmt.Errorf("failed to extract scanner: %s", err) - } - cmd.Wait() - - if cmd.ExitStatus() != 0 { - return "", fmt.Errorf("extraction failed with exit status %d", cmd.ExitStatus()) - } - - // Find the executable in extracted files - // Look for any .exe file (could be syft.exe, grype.exe, or custom scanner) - findCmd := fmt.Sprintf( - "powershell -Command \"Get-ChildItem -Path '%s' -Recurse -Filter '*.exe' | Select-Object -First 1 -ExpandProperty FullName\"", - extractDir, - ) - - log.Println("Locating scanner executable in extracted files...") - var stdout bytes.Buffer - findCmdExec := &packersdk.RemoteCmd{ - Command: findCmd, - Stdout: &stdout, - } - if err := comm.Start(ctx, findCmdExec); err != nil { - return "", fmt.Errorf("failed to locate scanner executable: %s", err) - } - findCmdExec.Wait() - - if findCmdExec.ExitStatus() != 0 { - return "", fmt.Errorf("failed to find scanner executable in extracted files (exit status %d)", findCmdExec.ExitStatus()) - } - - remoteBinaryPath := strings.TrimSpace(stdout.String()) - if remoteBinaryPath == "" { - return "", fmt.Errorf("no executable (.exe) found in extracted files") - } - - // Clean up zip file - cleanupCmd := &packersdk.RemoteCmd{ - Command: fmt.Sprintf("del \"%s\"", remoteZipPath), - } - _ = comm.Start(ctx, cleanupCmd) // Best effort cleanup, ignore error - cleanupCmd.Wait() - - log.Printf("Scanner ready at: %s", remoteBinaryPath) - // Return format: "DIR:extractDir|EXE:actualPath" so cleanup knows to remove the directory - return fmt.Sprintf("DIR:%s|EXE:%s", extractDir, remoteBinaryPath), nil - } - - // Standard upload for Unix or non-zip Windows files - var remotePath string - if isWindows { - remotePath = "C:\\Windows\\Temp\\packer-sbom-scanner.exe" - } else { - remotePath = "/tmp/packer-sbom-scanner" + return fmt.Errorf("failed to upload scanner: %s", err) } + defer p.cleanupRemoteFile(ctx, ui, comm, remoteScannerPath) - // Open local file - localFile, err := os.Open(localPath) + // Step 3: Run scanner on remote + ui.Say(fmt.Sprintf("Running scanner on remote host (scanning %s)...", p.config.ScanPath)) + remoteSBOMPath, err := p.runScanner(ctx, ui, comm, remoteScannerPath, osType) if err != nil { - return "", fmt.Errorf("failed to open local scanner: %s", err) + return fmt.Errorf("failed to run scanner: %s", err) } - defer func() { - _ = localFile.Close() // Cleanup, ignore error - }() + defer p.cleanupRemoteFile(ctx, ui, comm, remoteSBOMPath) - // Upload to remote - log.Printf("Uploading scanner to %s...", remotePath) - if err := comm.Upload(remotePath, localFile, nil); err != nil { - return "", fmt.Errorf("failed to upload scanner: %s", err) + // Step 4: Download SBOM from remote + log.Println("Downloading SBOM from remote host...") + sbomData, err := p.downloadSBOM(ctx, ui, comm, remoteSBOMPath) + if err != nil { + return fmt.Errorf("failed to download SBOM: %s", err) } - // Make executable on Unix-like systems - if !isWindows { - cmd := &packersdk.RemoteCmd{ - Command: fmt.Sprintf("chmod +x %s", remotePath), - } - if err := comm.Start(ctx, cmd); err != nil { - return "", fmt.Errorf("failed to make scanner executable: %s", err) - } - cmd.Wait() - if cmd.ExitStatus() != 0 { - return "", fmt.Errorf("chmod command failed with exit status %d", cmd.ExitStatus()) - } + // Step 5: Process SBOM for HCP (validate, compress, store) + log.Println("Processing SBOM for HCP Packer...") + if err := p.processSBOMForHCP(generatedData, sbomData); err != nil { + return fmt.Errorf("failed to process SBOM: %s", err) } - return remotePath, nil + ui.Say("Automatic SBOM generation completed successfully") + return nil } -// runScanner executes the scanner on the remote host +// runScanner executes `packer sbom-generate` on the remote host. func (p *Provisioner) runScanner(ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, scannerPath, osType string) (string, error) { - // Parse scanner path if it's in special format (Windows zip extraction) - // Format: "DIR:extractDir|EXE:actualPath" - actualScannerPath := scannerPath - if strings.Contains(scannerPath, "DIR:") && strings.Contains(scannerPath, "|EXE:") { - parts := strings.Split(scannerPath, "|EXE:") - if len(parts) == 2 { - actualScannerPath = parts[1] - } - } - // Determine output path based on OS var outputPath string isWindows := strings.Contains(strings.ToLower(osType), "windows") @@ -1209,29 +682,34 @@ func (p *Provisioner) runScanner(ctx context.Context, ui packersdk.Ui, outputPath = "/tmp/packer-sbom.json" } - // Prepare template data - templateData := make(map[string]interface{}) - // Copy generatedData - for k, v := range p.generatedData { - templateData[k] = v + // Restrict execute_command interpolation data to explicit scanner keys. + // This avoids exposing arbitrary generatedData values to shell template rendering. + templateData := map[string]string{ + "Path": scannerPath, + "Args": strings.Join(p.config.ScannerArgs, " "), + "ScanPath": p.config.ScanPath, + "Output": outputPath, } - // Add scanner-specific data - templateData["Path"] = actualScannerPath - templateData["Args"] = strings.Join(p.config.ScannerArgs, " ") - templateData["ScanPath"] = p.config.ScanPath - templateData["Output"] = outputPath - - p.config.ctx.Data = templateData + renderCtx := p.config.ctx + renderCtx.Data = templateData // Use Windows-specific default if on Windows and user hasn't customized executeCommand := p.config.ExecuteCommand - if isWindows && executeCommand == "chmod +x {{.Path}} && sudo {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}" { - // User didn't customize, use Windows default (no sudo on Windows) - executeCommand = "{{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}" + if isWindows && executeCommand == "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}" { + // User didn't customize, use Windows default (no sudo, uses sbom-generate subcommand) + executeCommand = "{{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}" + } + + // Backward compatibility: older execute_command templates omitted the + // sbom-generate subcommand and invoked the scanner binary directly. + normalizedExecuteCommand := normalizeScannerExecuteCommand(executeCommand) + if normalizedExecuteCommand != executeCommand { + log.Printf("[INFO] execute_command compatibility: injected 'sbom-generate' subcommand") + executeCommand = normalizedExecuteCommand } // Render the execute command template - cmdStr, err := interpolate.Render(executeCommand, &p.config.ctx) + cmdStr, err := interpolate.Render(executeCommand, &renderCtx) if err != nil { return "", fmt.Errorf("failed to render execute_command: %s", err) } @@ -1277,6 +755,72 @@ func (p *Provisioner) runScanner(ctx context.Context, ui packersdk.Ui, return outputPath, nil } +func normalizeScannerExecuteCommand(executeCommand string) string { + // Walk each {{.Path}} token and only inject "sbom-generate" when that + // token is being used as the scanner executable invocation. + // + // Example rewritten: + // chmod +x {{.Path}} && {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}} + // becomes: + // chmod +x {{.Path}} && {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}} + // + // Example left unchanged: + // chmod +x {{.Path}} && {{.Path}} version + // because the token after {{.Path}} is not {{.Args}} or {{.ScanPath}}. + var out strings.Builder + cursor := 0 + + for { + loc := scannerPathTokenRegexp.FindStringIndex(executeCommand[cursor:]) + if loc == nil { + break + } + + end := cursor + loc[1] + out.WriteString(executeCommand[cursor:end]) + + after := executeCommand[end:] + trimmedAfter := strings.TrimLeft(after, " \t") + + if !hasSBOMGenerateSubcommandPrefix(trimmedAfter) && scannerArgsOrScanPathTokenPrefixRegexp.MatchString(trimmedAfter) { + out.WriteString(" sbom-generate") + } + + cursor = end + } + + out.WriteString(executeCommand[cursor:]) + return out.String() +} + +func hasSBOMGenerateSubcommandPrefix(s string) bool { + // Treat sbom-generate as already present only when it is a complete shell + // token prefix, not when it is part of a longer word. + // + // Matches: + // sbom-generate {{.Args}} + // sbom-generate; echo done + // + // Does not match: + // sbom-generate-custom + const subcommand = "sbom-generate" + if !strings.HasPrefix(s, subcommand) { + return false + } + + if len(s) == len(subcommand) { + return true + } + + next := s[len(subcommand)] + switch next { + case ' ', '\t', '\n', '\r', ';', '|', '&', '>', '<': + return true + default: + return false + } +} + // downloadSBOM downloads the SBOM file from the remote host func (p *Provisioner) downloadSBOM(ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, remotePath string) ([]byte, error) { @@ -1296,7 +840,7 @@ func (p *Provisioner) downloadSBOM(ctx context.Context, ui packersdk.Ui, return buf.Bytes(), nil } -// cleanupRemoteFile removes a file or directory from the remote host +// cleanupRemoteFile removes a file from the remote host. func (p *Provisioner) cleanupRemoteFile(ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, remotePath string) { @@ -1304,34 +848,14 @@ func (p *Provisioner) cleanupRemoteFile(ctx context.Context, ui packersdk.Ui, return } - // Check if this is a special format path (Windows zip extraction) - // Format: "DIR:extractDir|EXE:actualPath" - var cleanupPath string - var isDirectory bool - if strings.Contains(remotePath, "DIR:") && strings.Contains(remotePath, "|EXE:") { - // Extract the directory path - parts := strings.Split(remotePath, "|EXE:") - dirPart := strings.TrimPrefix(parts[0], "DIR:") - cleanupPath = dirPart - isDirectory = true - log.Printf("Cleaning up extraction directory: %s", cleanupPath) - } else { - cleanupPath = remotePath - isDirectory = false - log.Printf("Cleaning up remote file: %s", cleanupPath) - } + log.Printf("Cleaning up remote file: %s", remotePath) - // Determine delete command based on type and path + // Determine delete command based on path (Windows vs Unix) var cmdStr string - if isDirectory { - // Windows directory removal - cmdStr = fmt.Sprintf("powershell -Command \"Remove-Item -Path '%s' -Recurse -Force\"", cleanupPath) - } else if strings.Contains(cleanupPath, "C:\\") || strings.Contains(cleanupPath, "c:\\") { - // Quote path for Windows to handle spaces - cmdStr = fmt.Sprintf("del /F /Q \"%s\"", cleanupPath) + if strings.Contains(remotePath, "C:\\") || strings.Contains(remotePath, "c:\\") { + cmdStr = fmt.Sprintf("del /F /Q \"%s\"", remotePath) } else { - // Quote path for Unix to handle spaces - cmdStr = fmt.Sprintf("rm -f \"%s\"", cleanupPath) + cmdStr = fmt.Sprintf("rm -f \"%s\"", remotePath) } cmd := &packersdk.RemoteCmd{ diff --git a/provisioner/hcp-sbom/provisioner.hcl2spec.go b/provisioner/hcp-sbom/provisioner.hcl2spec.go index 5dc98aff0..544af77a3 100644 --- a/provisioner/hcp-sbom/provisioner.hcl2spec.go +++ b/provisioner/hcp-sbom/provisioner.hcl2spec.go @@ -22,9 +22,9 @@ type FlatConfig struct { Destination *string `mapstructure:"destination" required:"false" cty:"destination" hcl:"destination"` SbomName *string `mapstructure:"sbom_name" required:"false" cty:"sbom_name" hcl:"sbom_name"` AutoGenerate *bool `mapstructure:"auto_generate" required:"true" cty:"auto_generate" hcl:"auto_generate"` + ScannerArgs []string `mapstructure:"scanner_args" required:"false" cty:"scanner_args" hcl:"scanner_args"` ScannerURL *string `mapstructure:"scanner_url" required:"false" cty:"scanner_url" hcl:"scanner_url"` ScannerChecksum *string `mapstructure:"scanner_checksum" required:"false" cty:"scanner_checksum" hcl:"scanner_checksum"` - ScannerArgs []string `mapstructure:"scanner_args" required:"false" cty:"scanner_args" hcl:"scanner_args"` ScanPath *string `mapstructure:"scan_path" required:"false" cty:"scan_path" hcl:"scan_path"` ExecuteCommand *string `mapstructure:"execute_command" required:"false" cty:"execute_command" hcl:"execute_command"` ElevatedUser *string `mapstructure:"elevated_user" required:"false" cty:"elevated_user" hcl:"elevated_user"` @@ -55,9 +55,9 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "destination": &hcldec.AttrSpec{Name: "destination", Type: cty.String, Required: false}, "sbom_name": &hcldec.AttrSpec{Name: "sbom_name", Type: cty.String, Required: false}, "auto_generate": &hcldec.AttrSpec{Name: "auto_generate", Type: cty.Bool, Required: false}, + "scanner_args": &hcldec.AttrSpec{Name: "scanner_args", Type: cty.List(cty.String), Required: false}, "scanner_url": &hcldec.AttrSpec{Name: "scanner_url", Type: cty.String, Required: false}, "scanner_checksum": &hcldec.AttrSpec{Name: "scanner_checksum", Type: cty.String, Required: false}, - "scanner_args": &hcldec.AttrSpec{Name: "scanner_args", Type: cty.List(cty.String), Required: false}, "scan_path": &hcldec.AttrSpec{Name: "scan_path", Type: cty.String, Required: false}, "execute_command": &hcldec.AttrSpec{Name: "execute_command", Type: cty.String, Required: false}, "elevated_user": &hcldec.AttrSpec{Name: "elevated_user", Type: cty.String, Required: false}, diff --git a/provisioner/hcp-sbom/provisioner_test.go b/provisioner/hcp-sbom/provisioner_test.go index 8cc4eae4d..070b916e5 100644 --- a/provisioner/hcp-sbom/provisioner_test.go +++ b/provisioner/hcp-sbom/provisioner_test.go @@ -71,14 +71,46 @@ func TestConfigPrepare(t *testing.T) { &Config{ AutoGenerate: true, ScanPath: "/", - ScannerArgs: []string{"-o", "cyclonedx-json", "-q"}, - ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}", + ScannerArgs: []string{"-o", "cyclonedx-json"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", }, false, "", }, { - "auto_generate with custom scanner URL", + "auto_generate with custom scan path", + map[string]interface{}{ + "auto_generate": true, + "scan_path": "/opt/app", + }, + interpolate.Context{}, + &Config{ + AutoGenerate: true, + ScanPath: "/opt/app", + ScannerArgs: []string{"-o", "cyclonedx-json"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + false, + "", + }, + { + "auto_generate with custom execute_command", + map[string]interface{}{ + "auto_generate": true, + "execute_command": "{{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + interpolate.Context{}, + &Config{ + AutoGenerate: true, + ScanPath: "/", + ScannerArgs: []string{"-o", "cyclonedx-json"}, + ExecuteCommand: "{{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + false, + "", + }, + { + "auto_generate with deprecated scanner_url (should warn but not fail)", map[string]interface{}{ "auto_generate": true, "scanner_url": "https://example.com/scanner", @@ -89,14 +121,14 @@ func TestConfigPrepare(t *testing.T) { AutoGenerate: true, ScannerURL: "https://example.com/scanner", ScanPath: "/opt/app", - ScannerArgs: []string{"-o", "cyclonedx-json", "-q"}, - ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}", + ScannerArgs: []string{"-o", "cyclonedx-json"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", }, false, "", }, { - "auto_generate with scanner checksum and URL", + "deprecated scanner_checksum with scanner_url (should warn but not fail)", map[string]interface{}{ "auto_generate": true, "scanner_url": "https://example.com/scanner", @@ -108,27 +140,22 @@ func TestConfigPrepare(t *testing.T) { ScannerURL: "https://example.com/scanner", ScannerChecksum: "abc123def456", ScanPath: "/", - ScannerArgs: []string{"-o", "cyclonedx-json", "-q"}, - ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}", + ScannerArgs: []string{"-o", "cyclonedx-json"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", }, false, "", }, { - "auto_generate with custom execute_command", + "deprecated scanner_checksum without scanner_url - should still error for clarity", map[string]interface{}{ - "auto_generate": true, - "execute_command": "{{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}", + "auto_generate": true, + "scanner_checksum": "abc123", }, interpolate.Context{}, - &Config{ - AutoGenerate: true, - ScanPath: "/", - ScannerArgs: []string{"-o", "cyclonedx-json", "-q"}, - ExecuteCommand: "{{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}", - }, - false, - "", + nil, + true, + "scanner_checksum requires scanner_url", }, { "auto_generate with elevated user and password", @@ -143,8 +170,8 @@ func TestConfigPrepare(t *testing.T) { ElevatedUser: "admin", ElevatedPassword: "password123", ScanPath: "/", - ScannerArgs: []string{"-o", "cyclonedx-json", "-q"}, - ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}", + ScannerArgs: []string{"-o", "cyclonedx-json"}, + ExecuteCommand: "chmod +x {{.Path}} && sudo {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", }, false, "", @@ -160,17 +187,6 @@ func TestConfigPrepare(t *testing.T) { true, "source and auto_generate are mutually exclusive", }, - { - "scanner_checksum without scanner_url - should error", - map[string]interface{}{ - "auto_generate": true, - "scanner_checksum": "abc123", - }, - interpolate.Context{}, - nil, - true, - "scanner_checksum requires scanner_url", - }, { "elevated_password without elevated_user - should error", map[string]interface{}{ @@ -186,22 +202,18 @@ func TestConfigPrepare(t *testing.T) { "source mode with scanner fields - should succeed (allows toggling auto_generate)", map[string]interface{}{ "source": "sbom.json", - "scanner_url": "https://example.com/scanner", - "scanner_checksum": "abc123", "scanner_args": []string{"-o", "json"}, "scan_path": "/opt/app", - "execute_command": "{{.Path}} {{.Args}}", + "execute_command": "{{.Path}} sbom-generate {{.Args}}", "elevated_user": "admin", "elevated_password": "password123", }, interpolate.Context{}, &Config{ Source: "sbom.json", - ScannerURL: "https://example.com/scanner", - ScannerChecksum: "abc123", ScannerArgs: []string{"-o", "json"}, ScanPath: "/opt/app", - ExecuteCommand: "{{.Path}} {{.Args}}", + ExecuteCommand: "{{.Path}} sbom-generate {{.Args}}", ElevatedUser: "admin", ElevatedPassword: "password123", }, @@ -238,3 +250,41 @@ func TestConfigPrepare(t *testing.T) { }) } } + +func TestNormalizeScannerExecuteCommand(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "injects when path directly followed by args", + in: "chmod +x {{.Path}} && {{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}", + want: "chmod +x {{.Path}} && {{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + { + name: "injects when path directly followed by scan path", + in: "sudo {{.Path}} {{.ScanPath}} > {{.Output}}", + want: "sudo {{.Path}} sbom-generate {{.ScanPath}} > {{.Output}}", + }, + { + name: "keeps command when sbom-generate already present", + in: "{{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + want: "{{.Path}} sbom-generate {{.Args}} {{.ScanPath}} > {{.Output}}", + }, + { + name: "does not modify non-scan invocation", + in: "chmod +x {{.Path}} && {{.Path}} version", + want: "chmod +x {{.Path}} && {{.Path}} version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeScannerExecuteCommand(tt.in) + if got != tt.want { + t.Fatalf("unexpected normalized command:\nwant: %q\n got: %q", tt.want, got) + } + }) + } +} diff --git a/provisioner/hcp-sbom/release_download.go b/provisioner/hcp-sbom/release_download.go new file mode 100644 index 000000000..77ae095ce --- /dev/null +++ b/provisioner/hcp-sbom/release_download.go @@ -0,0 +1,307 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package hcp_sbom + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strings" + "time" + + semver "github.com/Masterminds/semver/v3" + "github.com/hashicorp/packer-plugin-sdk/retry" +) + +// releaseBaseURL is the base URL for downloading Packer release artifacts. +// Override this to point at a local release server (e.g. for air-gapped testing): +// +// PACKER_RELEASE_SERVER=http://127.0.0.1:3231 +const defaultReleaseBaseURL = "https://releases.hashicorp.com" + +func getReleaseBaseURL() string { + if v := os.Getenv("PACKER_RELEASE_SERVER"); v != "" { + return strings.TrimRight(v, "/") + } + return defaultReleaseBaseURL +} + +// releaseIndex is the top-level structure of https://releases.hashicorp.com/packer/index.json. +type releaseIndex struct { + Versions map[string]releaseVersion `json:"versions"` +} + +// releaseVersion represents one version entry in the release index. +type releaseVersion struct { + Version string `json:"version"` + Shasums string `json:"shasums"` + Builds []releaseBuild `json:"builds"` +} + +// releaseBuild represents one platform build inside a release version. +type releaseBuild struct { + OS string `json:"os"` + Arch string `json:"arch"` + Filename string `json:"filename"` + URL string `json:"url"` +} + +// fetchLatestPackerVersion queries the HashiCorp releases index, sorts all +// stable (non-prerelease) versions with semver, and returns the highest one. +func fetchLatestPackerVersion(ctx context.Context, client *http.Client) (string, error) { + indexURL := defaultReleaseBaseURL + "/packer/index.json" + var indexData releaseIndex + + err := retry.Config{ + Tries: 3, + RetryDelay: func() time.Duration { return 5 * time.Second }, + }.Run(ctx, func(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) + if err != nil { + return fmt.Errorf("failed to build index request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch release index: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d for %s", resp.StatusCode, indexURL) + } + return json.NewDecoder(resp.Body).Decode(&indexData) + }) + if err != nil { + return "", fmt.Errorf("failed to retrieve packer release index from %s: %w", indexURL, err) + } + + var semverList []*semver.Version + for vStr := range indexData.Versions { + v, parseErr := semver.NewVersion(vStr) + if parseErr != nil { + continue + } + if v.Prerelease() != "" { + continue // skip alpha/beta/rc + } + semverList = append(semverList, v) + } + + if len(semverList) == 0 { + return "", fmt.Errorf("no stable Packer releases found in index at %s", indexURL) + } + + sort.Sort(semver.Collection(semverList)) + latest := semverList[len(semverList)-1] + log.Printf("[INFO] Latest stable Packer version from releases index: %s", latest.Original()) + return latest.Original(), nil +} + +// downloadURLToTempFile downloads url into a new temp file and returns its path. +// On any error the temp file is removed. The caller owns the returned file on success. +func downloadURLToTempFile(ctx context.Context, client *http.Client, url, suffix string) (string, error) { + f, err := os.CreateTemp("", "packer-dl-*"+suffix) + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := f.Name() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + _ = f.Close() + _ = os.Remove(tmpPath) + return "", err + } + + resp, err := client.Do(req) + if err != nil { + _ = f.Close() + _ = os.Remove(tmpPath) + return "", fmt.Errorf("HTTP request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + _ = f.Close() + _ = os.Remove(tmpPath) + return "", fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + + _, copyErr := io.Copy(f, resp.Body) + closeErr := f.Close() + if copyErr != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("failed to write download: %w", copyErr) + } + if closeErr != nil { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("failed to close temp file: %w", closeErr) + } + + return tmpPath, nil +} + +// downloadChecksumFile fetches the SHA256SUMS text file at url. +func downloadChecksumFile(ctx context.Context, client *http.Client, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to build request for %s: %w", url, err) + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to download %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed: HTTP %d for %s", resp.StatusCode, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed reading response body for %s: %w", url, err) + } + if len(strings.TrimSpace(string(body))) == 0 { + return "", fmt.Errorf("empty response body for %s", url) + } + + return string(body), nil +} + +func isValidSHA256Hex(s string) bool { + if len(s) != 64 { + return false + } + _, err := hex.DecodeString(s) + return err == nil +} + +func expectedZipSHA256FromSums(sumsContent, fileName string) (string, error) { + for _, line := range strings.Split(sumsContent, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 { + continue + } + candidateFileName := strings.TrimPrefix(fields[len(fields)-1], "*") + if candidateFileName == fileName { + hash := strings.ToLower(fields[0]) + if !isValidSHA256Hex(hash) { + return "", fmt.Errorf("invalid SHA256 checksum format for %s in SHA256SUMS", fileName) + } + return hash, nil + } + } + return "", fmt.Errorf("checksum for %s not found in SHA256SUMS", fileName) +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("failed to open %s for hashing: %w", path, err) + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("failed hashing %s: %w", path, err) + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// downloadPackerRelease fetches the latest stable Packer version from the +// HashiCorp releases index (releases.hashicorp.com/packer/index.json), then +// downloads and checksum-verifies the zip for the given GOOS/GOARCH. +// All HTTP operations are retried up to three times. +// Set PACKER_RELEASE_SERVER=http://127.0.0.1:3231 to use a local release +// server instead of releases.hashicorp.com. +func downloadPackerRelease(ctx context.Context, goos, goarch string) (string, error) { + base := getReleaseBaseURL() + client := &http.Client{Timeout: 5 * time.Minute} + + // Resolve the latest stable version from the releases index. + version, err := fetchLatestPackerVersion(ctx, client) + if err != nil { + return "", fmt.Errorf("failed to determine latest Packer version: %w", err) + } + + fileName := fmt.Sprintf("packer_%s_%s_%s.zip", version, goos, goarch) + zipURL := fmt.Sprintf("%s/packer/%s/%s", base, version, fileName) + shaSumsURL := fmt.Sprintf("%s/packer/%s/packer_%s_SHA256SUMS", base, version, version) + + log.Printf("[INFO] Downloading Packer %s for %s/%s...", version, goos, goarch) + + // Download the release zip. + zipPath, err := downloadURLToTempFile(ctx, client, zipURL, ".zip") + if err != nil { + return "", fmt.Errorf("failed to download Packer release zip: %w", err) + } + + // Download the SHA256SUMS file with retry. + var sumsContent string + err = retry.Config{ + Tries: 3, + RetryDelay: func() time.Duration { return 5 * time.Second }, + }.Run(ctx, func(ctx context.Context) error { + var e error + sumsContent, e = downloadChecksumFile(ctx, client, shaSumsURL) + return e + }) + if err != nil { + _ = os.Remove(zipPath) + return "", fmt.Errorf("failed to download release checksums: %w", err) + } + + // Verify checksum. + expectedSHA, err := expectedZipSHA256FromSums(sumsContent, fileName) + if err != nil { + _ = os.Remove(zipPath) + return "", fmt.Errorf("failed to resolve expected checksum: %w", err) + } + actualSHA, err := fileSHA256(zipPath) + if err != nil { + _ = os.Remove(zipPath) + return "", err + } + if !strings.EqualFold(expectedSHA, actualSHA) { + _ = os.Remove(zipPath) + return "", fmt.Errorf("checksum mismatch for %s: expected %s, got %s", fileName, expectedSHA, actualSHA) + } + + // Validate the expected binary exists inside the archive. + binaryName := "packer" + if goos == "windows" { + binaryName = "packer.exe" + } + + zr, err := zip.OpenReader(zipPath) + if err != nil { + _ = os.Remove(zipPath) + return "", fmt.Errorf("failed to open downloaded zip: %w", err) + } + defer func() { _ = zr.Close() }() + + foundBinary := false + for _, f := range zr.File { + if f.Name == binaryName { + foundBinary = true + break + } + } + if !foundBinary { + _ = os.Remove(zipPath) + return "", fmt.Errorf("packer binary %q not found in release zip %s", binaryName, zipURL) + } + + log.Printf("[INFO] Downloaded and verified Packer release zip: %s", zipPath) + return zipPath, nil +} diff --git a/provisioner/hcp-sbom/syft_dependency.go b/provisioner/hcp-sbom/syft_dependency.go deleted file mode 100644 index 90185eade..000000000 --- a/provisioner/hcp-sbom/syft_dependency.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright IBM Corp. 2013, 2025 -// SPDX-License-Identifier: BUSL-1.1 - -// Exclude platforms where containerd (a transitive dependency of syft) doesn't compile. -// Containerd has platform-specific code that lacks support for NetBSD, OpenBSD, and Solaris. -// This exclusion only affects dependency tracking; the hcp-sbom provisioner downloads -// pre-built syft binaries at runtime and works on all platforms where those binaries exist. -//go:build !netbsd && !openbsd && !solaris - -package hcp_sbom - -import ( - // Blank import to register Syft as a dependency - // This file exists to declare Syft as a dependency for license and security scanning purposes. - // While Packer downloads and executes Syft binaries at runtime, this import ensures - // the Syft project appears in dependency analysis tools and SBOMs generated for Packer itself. - _ "github.com/anchore/syft/syft" -) diff --git a/version/VERSION b/version/VERSION index f2380cc7a..e34208c93 100644 --- a/version/VERSION +++ b/version/VERSION @@ -1 +1 @@ -1.15.3 +1.15.4 diff --git a/website/content/docs/provisioners/hcp-sbom.mdx b/website/content/docs/provisioners/hcp-sbom.mdx index fa2478fc5..4269274da 100644 --- a/website/content/docs/provisioners/hcp-sbom.mdx +++ b/website/content/docs/provisioners/hcp-sbom.mdx @@ -17,6 +17,9 @@ page_title: hcp-sbom provisioner reference The `hcp-sbom` provisioner uploads software bill of materials (SBOM) files from artifacts built by Packer to HCP Packer. You must format SBOM files you want to upload as JSON and follow either the [SPDX](https://spdx.github.io/spdx-spec/latest) or [CycloneDX](https://cyclonedx.org/) specification. HCP Packer ties these SBOM files to the version of the artifact that Packer builds. +## Deprecation Notice + +~> **Deprecated Configuration Options:** The `scanner_url` and `scanner_checksum` configuration options are deprecated as of Packer v1.15.3 and will be removed in a future major version. The provisioner now uses a Packer binary with the embedded Syft SDK for the remote OS/arch for automatic SBOM generation; it downloads that binary from `releases.hashicorp.com` and uploads it to the target. For custom SBOM generation tools, use manual generation with the `source` field instead of `auto_generate`. ## Example The following example uploads an SBOM from the local `/tmp` directory and stores a copy at `./sbom/sbom_cyclonedx.json` on the local machine. @@ -47,6 +50,10 @@ provisioner "hcp-sbom" { +## Migrating from Custom Scanner URLs + +Prior to v1.15.4, the `hcp-sbom` provisioner supported downloading custom scanner binaries via `scanner_url`. This is no longer supported as the provisioner now uses the Packer binary with embedded Syft SDK. + ## Configuration reference You can specify the following configuration options.