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.
HRP-2522-Remove_syft_bin
Hari Om 1 day ago
parent 342fc1c554
commit b90187ae09

@ -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] <path>
Generate a Software Bill of Materials (SBOM) for the local filesystem.
This command is typically invoked internally by the hcp-sbom provisioner.
Options:
-o <format> Output format: cyclonedx-json, spdx-json (default: cyclonedx-json)
--exclude <glob> Optional: exclude path glob from scanning (repeatable)
--scope <scope> Optional: scan scope: squashed, all-layers (default: squashed)
Arguments:
<path> 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)
}

@ -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())
}
}

@ -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

@ -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

@ -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=

@ -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)
}
}

@ -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)
}
}

@ -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)
}

@ -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,

File diff suppressed because it is too large Load Diff

@ -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},

@ -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)
}
})
}
}

@ -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
}

@ -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"
)

@ -1 +1 @@
1.15.3
1.15.4

@ -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" {
</Tab>
</Tabs>
## 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.

Loading…
Cancel
Save