You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
packer/provisioner/hcp-sbom/provisioner.go

1415 lines
43 KiB

// Copyright IBM Corp. 2013, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:generate packer-sdc mapstructure-to-hcl2 -type Config
//go:generate packer-sdc struct-markdown
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"
)
type Config struct {
common.PackerConfig `mapstructure:",squash"`
// The file path or URL to the SBOM file in the Packer artifact.
// This file must either be in the SPDX or CycloneDX format.
// Mutually exclusive with `auto_generate`.
Source string `mapstructure:"source" required:"true"`
// The path on the local machine to store a copy of the SBOM file.
// You can specify an absolute or a path relative to the working directory
// when you execute the Packer build. If the file already exists on the
// local machine, Packer overwrites the file. If the destination is a
// directory, the directory must already exist.
Destination string `mapstructure:"destination" required:"false"`
// The name of the SBOM file stored in HCP Packer.
// If omitted, HCP Packer uses the build fingerprint as the file name.
// This value must be between three and 36 characters from the following
// set: `[A-Za-z0-9_-]`. You must specify a unique name for each build in
// 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`.
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).
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.
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"`
// The command template used to execute the scanner on the remote host.
// Available template variables:
//
// - `{{.Path}}` - Path to the scanner binary on the remote host
// - `{{.Args}}` - Scanner arguments (from `scanner_args`)
// - `{{.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 Windows: `{{.Path}} {{.Args}} {{.ScanPath}} > {{.Output}}`
//
// Examples:
//
// Without sudo:
//
// ``` hcl
// execute_command = "chmod +x {{.Path}} && {{.Path}} {{.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}}"
// ```
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.
ElevatedUser string `mapstructure:"elevated_user" required:"false"`
// The password for the `elevated_user`. Required if `elevated_user` is
// specified. Only applicable for Windows hosts.
ElevatedPassword string `mapstructure:"elevated_password" required:"false"`
ctx interpolate.Context
}
type Provisioner struct {
config Config
communicator packersdk.Communicator
generatedData map[string]interface{}
}
func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec {
return p.config.FlatMapstructure().HCL2Spec()
}
func (p *Provisioner) FlatConfig() interface{} {
return p.config.FlatMapstructure()
}
var sbomFormatRegexp = regexp.MustCompile("^[0-9A-Za-z-]{3,36}$")
func (p *Provisioner) Prepare(raws ...interface{}) error {
err := config.Decode(&p.config, &config.DecodeOpts{
PluginType: "hcp-sbom",
Interpolate: true,
InterpolateContext: &p.config.ctx,
InterpolateFilter: &interpolate.RenderFilter{
Exclude: []string{
"execute_command",
},
},
}, raws...)
if err != nil {
return err
}
var errs error
// Validate that source and auto_generate are mutually exclusive
if p.config.Source != "" && p.config.AutoGenerate {
errs = packersdk.MultiErrorAppend(errs, errors.New("source and auto_generate are mutually exclusive; use either source for pre-generated SBOMs or auto_generate to create them"))
}
// Validate based on mode
if p.config.AutoGenerate {
// Native generation mode: source must not be set
// Set defaults
if p.config.ScanPath == "" {
p.config.ScanPath = "/"
}
if len(p.config.ScannerArgs) == 0 {
// Default to CycloneDX JSON format with quiet output
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}}"
}
// Validate: if checksum is provided, URL must also be provided
if p.config.ScannerChecksum != "" && p.config.ScannerURL == "" {
errs = packersdk.MultiErrorAppend(errs, errors.New("scanner_checksum requires scanner_url to be specified"))
}
// Validate elevated user configuration (Windows only)
if p.config.ElevatedUser == "" && p.config.ElevatedPassword != "" {
errs = packersdk.MultiErrorAppend(errs, errors.New("elevated_user must be specified if elevated_password is provided"))
}
} else {
// Traditional mode: source is required
if p.config.Source == "" {
errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified when auto_generate is not enabled"))
}
// Note: Scanner-related fields are allowed in source mode to support
// toggling auto_generate without clearing configuration fields
}
if p.config.SbomName != "" && !sbomFormatRegexp.MatchString(p.config.SbomName) {
// Ugly but a bit of a problem with interpolation since Provisioners
// are prepared twice in HCL2.
//
// If the information used for interpolating is populated in-between the
// first call to Prepare (at the start of the build), and when the
// Provisioner is actually called, the first call will fail, as
// the value won't contain the actual interpolated value, but a
// placeholder which doesn't match the regex.
//
// Since we don't have a way to discriminate between the calls
// in the context of the provisioner, we ignore them, and later the
// HCP Packer call will fail because of the broken regex.
if strings.Contains(p.config.SbomName, "<no value>") {
log.Printf("[WARN] interpolation incomplete for `sbom_name`, will possibly retry later with data populated into context, otherwise will fail when uploading to HCP Packer.")
} else {
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("`sbom_name` %q doesn't match the expected format, it must "+
"contain between 3 and 36 characters, all from the following set: [A-Za-z0-9_-]", p.config.SbomName))
}
}
return errs
}
// PackerSBOM is the type we write to the temporary JSON dump of the SBOM to
// be consumed by Packer core
type PackerSBOM struct {
// RawSBOM is the raw data from the SBOM downloaded from the guest
RawSBOM []byte `json:"raw_sbom"`
// Format is the format detected by the provisioner
//
// Supported values: `SPDX` or `CYCLONEDX`
Format hcpPackerModels.HashicorpCloudPacker20230101SbomFormat `json:"format"`
// Name is the name of the SBOM to be set on HCP Packer
//
// If unset, HCP Packer will generate one
Name string `json:"name,omitempty"`
}
func (p *Provisioner) Provision(
ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator,
generatedData map[string]interface{},
) error {
// Store communicator and generatedData for elevated execution
p.communicator = comm
p.generatedData = generatedData
log.Println("Starting to provision with `hcp-sbom` provisioner")
if generatedData == nil {
generatedData = make(map[string]interface{})
}
p.config.ctx.Data = generatedData
// Check if native generation is enabled
if !p.config.AutoGenerate {
// Original behavior: user provides SBOM file
log.Println("Using existing SBOM provisioner behavior (user-provided SBOM)")
return p.provisionWithExistingSBOM(ctx, ui, comm, generatedData)
}
// Native generation enabled
ui.Say("Automatic SBOM generation enabled")
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"
}
err = p.provisionWithNativeGeneration(ctx, ui, comm, generatedData, osType, osArch)
if err != nil {
ui.Error(fmt.Sprintf("SBOM generation failed: %s", err))
ui.Error("Build will continue without SBOM")
return nil
}
return nil
}
// provisionWithExistingSBOM handles the original flow where user provides an SBOM file
func (p *Provisioner) provisionWithExistingSBOM(
ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator,
generatedData map[string]interface{},
) error {
src := p.config.Source
// Download SBOM from remote
var buf bytes.Buffer
if err := comm.Download(src, &buf); err != nil {
ui.Error(fmt.Sprintf("Failed to download SBOM file: %s", err))
ui.Error("Build will continue without SBOM")
return nil
}
// Process and write SBOM (reuses common logic)
err := p.processSBOMForHCP(generatedData, buf.Bytes())
if err != nil {
ui.Error(fmt.Sprintf("Failed to process SBOM: %s", err))
ui.Error("Build will continue without SBOM")
return nil
}
return nil
}
// detectRemoteOS performs OS detection on the remote host
func (p *Provisioner) detectRemoteOS(ctx context.Context, ui packersdk.Ui,
comm packersdk.Communicator,
generatedData map[string]interface{}) (string, string, error) {
// First check if already detected (from generatedData)
if osType, ok := generatedData["OSType"].(string); ok {
if osArch, ok := generatedData["OSArch"].(string); ok {
return osType, osArch, nil
}
}
// Not in generatedData, detect now
log.Println("Running OS detection commands on remote host...")
// Get communicator type
connType := "ssh" // default
if ct, ok := generatedData["ConnType"].(string); ok {
connType = ct
}
// Run detection command based on communicator
var cmd *packersdk.RemoteCmd
if connType == "winrm" {
cmd = &packersdk.RemoteCmd{
Command: "echo %PROCESSOR_ARCHITECTURE%",
}
} else {
cmd = &packersdk.RemoteCmd{
Command: "uname -s -m",
}
}
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := comm.Start(ctx, cmd); err != nil {
return "", "", fmt.Errorf("failed to run OS detection command: %s", err)
}
cmd.Wait()
if cmd.ExitStatus() != 0 {
return "", "", fmt.Errorf("OS detection command exited with status %d", cmd.ExitStatus())
}
output := strings.TrimSpace(stdout.String())
log.Printf("OS detection output: %s", output)
// Parse output
var osType, osArch string
if connType == "winrm" {
osType = "Windows"
osArch = strings.ToLower(output) // AMD64, ARM64, etc.
} else {
parts := strings.Fields(output)
if len(parts) >= 2 {
osType = parts[0] // Linux, Darwin, FreeBSD, etc.
osArch = parts[1] // x86_64, aarch64, etc.
} else if len(parts) == 1 {
// Some systems might only return one value
osType = parts[0]
osArch = "unknown"
}
}
if osType == "" || osArch == "" {
return "", "", fmt.Errorf("failed to parse OS detection output: %s", output)
}
// Store in generatedData for potential reuse
generatedData["OSType"] = osType
generatedData["OSArch"] = osArch
return osType, osArch, nil
}
// getUserDestination determines and returns the destination path for the user SBOM file.
func (p *Provisioner) getUserDestination() (string, error) {
dst := p.config.Destination
// Check if the destination exists and determine its type
info, err := os.Stat(dst)
if err == nil {
if info.IsDir() {
// If the destination is a directory, create a temporary file inside it
tmpFile, err := os.CreateTemp(dst, "packer-user-sbom-*.json")
if err != nil {
return "", fmt.Errorf("failed to create temporary file in user SBOM directory %s: %s", dst, err)
}
dst = tmpFile.Name()
_ = tmpFile.Close() // Ignore error on close after getting name
}
return dst, nil
}
outDir := filepath.Dir(dst)
// In case the destination does not exist, we'll get the dirpath,
// and create it if it doesn't already exist
err = os.MkdirAll(outDir, 0755)
if err != nil {
return "", fmt.Errorf("failed to create destination directory for user SBOM: %s", err)
}
// Check if the destination is a directory after the previous step.
//
// This happens if the path specified ends with a `/`, in which case the
// destination is a directory, and we must create a temporary file in
// this destination directory.
destStat, statErr := os.Stat(dst)
if statErr == nil && destStat.IsDir() {
tmpFile, err := os.CreateTemp(outDir, "packer-user-sbom-*.json")
if err != nil {
return "", fmt.Errorf("failed to create temporary file in user SBOM directory %s: %s", dst, err)
}
dst = tmpFile.Name()
_ = tmpFile.Close() // Ignore error on close after getting name
}
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)
}
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)
}
// 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
})
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)
if err != nil {
return "", fmt.Errorf("failed to copy zip file: %s", err)
}
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 ""
}
// 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)
if err != nil {
log.Printf("[WARN] Failed to fetch Syft releases (page %d): %s", page, err)
return ""
}
// 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 ""
}
// 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 ""
}
_ = 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
}
}
// 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
}
// 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"
}
// 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
}
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)
if err != nil {
return "", fmt.Errorf("scanner binary '%s' not found in downloaded files", binaryName)
}
}
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
}
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
}
}
return nil
})
if archivePath == "" {
return "", fmt.Errorf("no tar.gz or zip archive found")
}
if isZip {
return p.extractFromZip(archivePath, dir, binaryName)
}
return p.extractFromTarGz(archivePath, dir, binaryName)
}
// 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
}
defer func() {
_ = file.Close() // Cleanup, ignore error
}()
gzr, err := gzip.NewReader(file)
if err != nil {
return "", 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
}
}
return "", fmt.Errorf("binary '%s' not found in tar.gz archive", binaryName)
}
// 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
}
return tmpBinary.Name(), nil
}
}
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
}
defer func() {
_ = tmpFile.Close() // Cleanup, ignore error
}()
// Open source
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer func() {
_ = src.Close() // Cleanup, ignore error
}()
// 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)
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"
}
// Open local file
localFile, err := os.Open(localPath)
if err != nil {
return "", fmt.Errorf("failed to open local scanner: %s", err)
}
defer func() {
_ = localFile.Close() // Cleanup, ignore error
}()
// 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)
}
// 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())
}
}
return remotePath, nil
}
// runScanner executes the scanner 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")
if isWindows {
outputPath = "C:\\Windows\\Temp\\packer-sbom.json"
} else {
outputPath = "/tmp/packer-sbom.json"
}
// Prepare template data
templateData := make(map[string]interface{})
// Copy generatedData
for k, v := range p.generatedData {
templateData[k] = v
}
// 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
// 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}}"
}
// Render the execute command template
cmdStr, err := interpolate.Render(executeCommand, &p.config.ctx)
if err != nil {
return "", fmt.Errorf("failed to render execute_command: %s", err)
}
// For Windows with elevated user, wrap command with elevated runner
if isWindows && p.config.ElevatedUser != "" {
log.Printf("Using elevated user '%s' for scanner execution", p.config.ElevatedUser)
elevatedCmd, err := guestexec.GenerateElevatedRunner(cmdStr, p)
if err != nil {
return "", fmt.Errorf("failed to generate elevated runner: %s", err)
}
cmdStr = elevatedCmd
}
log.Printf("Executing: %s", cmdStr)
// Execute scanner
var stdout, stderr bytes.Buffer
cmd := &packersdk.RemoteCmd{
Command: cmdStr,
Stdout: &stdout,
Stderr: &stderr,
}
if err := comm.Start(ctx, cmd); err != nil {
return "", fmt.Errorf("failed to start scanner: %s", err)
}
cmd.Wait()
// Log output
if stdout.Len() > 0 {
ui.Say(fmt.Sprintf("Scanner stdout: %s", stdout.String()))
}
if stderr.Len() > 0 {
ui.Say(fmt.Sprintf("Scanner stderr: %s", stderr.String()))
}
if cmd.ExitStatus() != 0 {
return "", fmt.Errorf("scanner exited with status %d", cmd.ExitStatus())
}
return outputPath, nil
}
// 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) {
var buf bytes.Buffer
log.Printf("Downloading SBOM from %s...", remotePath)
if err := comm.Download(remotePath, &buf); err != nil {
return nil, fmt.Errorf("failed to download SBOM: %s", err)
}
if buf.Len() == 0 {
return nil, fmt.Errorf("downloaded SBOM is empty")
}
log.Printf("Downloaded SBOM (%d bytes)", buf.Len())
return buf.Bytes(), nil
}
// cleanupRemoteFile removes a file or directory from the remote host
func (p *Provisioner) cleanupRemoteFile(ctx context.Context, ui packersdk.Ui,
comm packersdk.Communicator, remotePath string) {
if remotePath == "" {
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)
}
// Determine delete command based on type and path
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)
} else {
// Quote path for Unix to handle spaces
cmdStr = fmt.Sprintf("rm -f \"%s\"", cleanupPath)
}
cmd := &packersdk.RemoteCmd{
Command: cmdStr,
}
if err := comm.Start(ctx, cmd); err != nil {
ui.Error(fmt.Sprintf("Failed to cleanup: %s", err))
return
}
cmd.Wait()
if cmd.ExitStatus() != 0 {
ui.Error(fmt.Sprintf("Cleanup command failed for %s with exit status %d", remotePath, cmd.ExitStatus()))
}
}
// processSBOMForHCP validates, compresses, and prepares SBOM for HCP upload
func (p *Provisioner) processSBOMForHCP(generatedData map[string]interface{}, sbomData []byte) error {
// Validate SBOM format
format, err := validateSBOM(sbomData)
if err != nil {
return fmt.Errorf("SBOM validation failed: %s", err)
}
// Get destination path from generatedData
pkrDst, ok := generatedData["dst"].(string)
if !ok || pkrDst == "" {
return fmt.Errorf("packer destination path missing from configs: this is an internal error")
}
// Write PackerSBOM to destination
outFile, err := os.Create(pkrDst)
if err != nil {
return fmt.Errorf("failed to create output file %q: %s", pkrDst, err)
}
defer func() {
_ = outFile.Close() // Cleanup, ignore error
}()
err = json.NewEncoder(outFile).Encode(PackerSBOM{
RawSBOM: sbomData,
Format: format,
Name: p.config.SbomName,
})
if err != nil {
return fmt.Errorf("failed to write SBOM to %q: %s", pkrDst, err)
}
// Also save to user destination if specified
if p.config.Destination != "" {
usrDst, err := p.getUserDestination()
if err != nil {
return fmt.Errorf("failed to compute destination path %q: %s", p.config.Destination, err)
}
if err := os.WriteFile(usrDst, sbomData, 0644); err != nil {
return fmt.Errorf("failed to write SBOM to destination %q: %s", usrDst, err)
}
}
return nil
}
// Communicator returns the communicator for elevated execution
func (p *Provisioner) Communicator() packersdk.Communicator {
return p.communicator
}
// ElevatedUser returns the elevated user for Windows execution
func (p *Provisioner) ElevatedUser() string {
return p.config.ElevatedUser
}
// ElevatedPassword returns the elevated password for Windows execution
func (p *Provisioner) ElevatedPassword() string {
// Interpolate password if needed
p.config.ctx.Data = p.generatedData
elevatedPassword, _ := interpolate.Render(p.config.ElevatedPassword, &p.config.ctx)
return elevatedPassword
}