move implementation to hcp-sbom

This commit is contained in:
Anurag Sharma 2026-02-25 21:27:11 +05:30
parent 0929f45e72
commit 520e8dd5f4
2 changed files with 477 additions and 189 deletions

View file

@ -4,13 +4,11 @@
package packer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"time"
hcpSbomProvisioner "github.com/hashicorp/packer/provisioner/hcp-sbom"
@ -247,65 +245,18 @@ func (p *DebuggedProvisioner) Provision(ctx context.Context, ui packersdk.Ui, co
// SBOMInternalProvisioner is a wrapper provisioner for the `hcp-sbom` provisioner
// that sets the path for SBOM file download and, after the successful execution of
// the `hcp-sbom` provisioner, compresses the SBOM and prepares the data for API
// integration. It also supports native SBOM generation by automatically downloading
// and running a scanner tool (default: Syft) on the remote host.
// integration.
type SBOMInternalProvisioner struct {
Provisioner packersdk.Provisioner
CompressedData []byte
SBOMFormat hcpPackerModels.HashicorpCloudPacker20230101SbomFormat
SBOMName string
// Native SBOM generation configuration
EnableNativeGeneration bool // Flag to enable/disable native SBOM generation
ScannerURL string // URL to scanner tool (if empty, auto-download Syft based on detected OS)
ScannerChecksum string // Expected SHA256 checksum of scanner binary for verification
ScannerArgs []string // Arguments to pass to the scanner tool
ScanPath string // Path to scan on remote host (default: "/")
}
func (p *SBOMInternalProvisioner) ConfigSpec() hcldec.ObjectSpec { return p.Provisioner.ConfigSpec() }
func (p *SBOMInternalProvisioner) FlatConfig() interface{} { return nil }
func (p *SBOMInternalProvisioner) Prepare(raws ...interface{}) error {
// Parse configuration for native generation options
for _, raw := range raws {
if config, ok := raw.(map[string]interface{}); ok {
if val, ok := config["enable_native_generation"].(bool); ok {
p.EnableNativeGeneration = val
}
if val, ok := config["scanner_url"].(string); ok {
p.ScannerURL = val
}
if val, ok := config["scanner_checksum"].(string); ok {
p.ScannerChecksum = val
}
if val, ok := config["scanner_args"].([]interface{}); ok {
p.ScannerArgs = make([]string, len(val))
for i, arg := range val {
if argStr, ok := arg.(string); ok {
p.ScannerArgs[i] = argStr
}
}
}
if val, ok := config["scan_path"].(string); ok {
p.ScanPath = val
}
}
}
// Set defaults
if p.ScanPath == "" {
p.ScanPath = "/"
}
// Validate configuration
if p.EnableNativeGeneration {
// If checksum is provided, URL must also be provided
if p.ScannerChecksum != "" && p.ScannerURL == "" {
return fmt.Errorf("scanner_checksum requires scanner_url to be specified")
}
}
return p.Provisioner.Prepare(raws...)
}
@ -313,139 +264,7 @@ func (p *SBOMInternalProvisioner) Provision(
ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator,
generatedData map[string]interface{},
) error {
// Check if native generation is enabled
if !p.EnableNativeGeneration {
// Original behavior: user provides SBOM file
ui.Say("Using existing SBOM provisioner behavior (user-provided SBOM)")
return p.provisionWithExistingSBOM(ctx, ui, comm, generatedData)
}
// Native generation enabled
ui.Say("Native SBOM generation enabled")
var osType, osArch string
var err error
// Only detect OS if scanner_url is NOT provided
if p.ScannerURL == "" {
ui.Say("No scanner URL provided, detecting remote OS/Arch...")
osType, osArch, err = p.detectRemoteOS(ctx, ui, comm, generatedData)
if err != nil {
return fmt.Errorf("failed to detect remote OS: %s", err)
}
ui.Say(fmt.Sprintf("Detected: OS=%s, Arch=%s", osType, osArch))
} else {
ui.Say("Scanner URL provided, skipping OS detection")
// User provided scanner URL, assume they know their platform
osType = "unknown"
osArch = "unknown"
}
return p.provisionWithNativeGeneration(ctx, ui, comm, osType, osArch)
}
// detectRemoteOS performs OS detection within the provisioner
func (p *SBOMInternalProvisioner) 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 {
ui.Message("Using previously detected OS information from generated data")
return osType, osArch, nil
}
}
// Not in generatedData, detect now
ui.Message("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())
ui.Message(fmt.Sprintf("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
}
// provisionWithNativeGeneration handles the new native SBOM generation flow
func (p *SBOMInternalProvisioner) provisionWithNativeGeneration(
ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator,
osType, osArch string,
) error {
ui.Say("Starting native SBOM generation workflow...")
// TODO: Implement in next commits
// Step 1: Download scanner binary
// Step 2: Verify checksum if provided
// Step 3: Upload scanner to remote
// Step 4: Run scanner on remote
// Step 5: Download SBOM
// Step 6: Process SBOM for HCP
// Step 7: Cleanup remote files
return fmt.Errorf("native SBOM generation not yet fully implemented")
}
// provisionWithExistingSBOM handles the original SBOM provisioner behavior
func (p *SBOMInternalProvisioner) provisionWithExistingSBOM(
ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator,
generatedData map[string]interface{},
) error {
// Original implementation
// Original implementation - all logic now in hcp-sbom provisioner
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory for Packer SBOM: %s", err)

View file

@ -7,18 +7,23 @@
package hcp_sbom
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"path/filepath"
"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"
@ -32,7 +37,8 @@ type Config struct {
// The file path or URL to the SBOM file in the Packer artifact.
// This file must either be in the SPDX or CycloneDX format.
Source string `mapstructure:"source" required:"true"`
// Not required if enable_native_generation is true.
Source string `mapstructure:"source"`
// 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
@ -46,7 +52,28 @@ type Config struct {
// 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"`
ctx interpolate.Context
// Native SBOM generation configuration
// Enable native SBOM generation by automatically downloading and running a scanner
EnableNativeGeneration bool `mapstructure:"enable_native_generation"`
// URL to scanner tool (supports go-getter syntax: HTTP, local files, Git, S3, etc.)
// If empty and enable_native_generation is true, Syft will be auto-downloaded based on detected OS/Arch
ScannerURL string `mapstructure:"scanner_url"`
// Expected SHA256 checksum of scanner binary for verification
// If provided, scanner_url must also be specified
ScannerChecksum string `mapstructure:"scanner_checksum"`
// Arguments to pass to the scanner tool
// Default for Syft: ["-o", "spdx-json"]
ScannerArgs []string `mapstructure:"scanner_args"`
// Path to scan on remote host
// Default: "/"
ScanPath string `mapstructure:"scan_path"`
ctx interpolate.Context
}
type Provisioner struct {
@ -74,8 +101,26 @@ func (p *Provisioner) Prepare(raws ...interface{}) error {
var errs error
if p.config.Source == "" {
errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified"))
// Validate based on mode
if p.config.EnableNativeGeneration {
// Native generation mode: source is optional
// Set defaults
if p.config.ScanPath == "" {
p.config.ScanPath = "/"
}
if len(p.config.ScannerArgs) == 0 {
p.config.ScannerArgs = []string{"-o", "spdx-json"}
}
// 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"))
}
} else {
// Traditional mode: source is required
if p.config.Source == "" {
errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified (or enable enable_native_generation)"))
}
}
if p.config.SbomName != "" && !sbomFormatRegexp.MatchString(p.config.SbomName) {
@ -128,6 +173,42 @@ func (p *Provisioner) Provision(
}
p.config.ctx.Data = generatedData
// Check if native generation is enabled
if !p.config.EnableNativeGeneration {
// Original behavior: user provides SBOM file
ui.Say("Using existing SBOM provisioner behavior (user-provided SBOM)")
return p.provisionWithExistingSBOM(ctx, ui, comm, generatedData)
}
// Native generation enabled
ui.Say("Native SBOM generation enabled")
var osType, osArch string
var err error
// Only detect OS if scanner_url is NOT provided
if p.config.ScannerURL == "" {
ui.Say("No scanner URL provided, detecting remote OS/Arch...")
osType, osArch, err = p.detectRemoteOS(ctx, ui, comm, generatedData)
if err != nil {
return fmt.Errorf("failed to detect remote OS: %s", err)
}
ui.Say(fmt.Sprintf("Detected: OS=%s, Arch=%s", osType, osArch))
} else {
ui.Say("Scanner URL provided, skipping OS detection")
// User provided scanner URL, assume they know their platform
osType = "unknown"
osArch = "unknown"
}
return p.provisionWithNativeGeneration(ctx, ui, comm, generatedData, osType, osArch)
}
// 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
pkrDst := generatedData["dst"].(string)
@ -178,6 +259,83 @@ func (p *Provisioner) Provision(
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 {
ui.Message("Using previously detected OS information from generated data")
return osType, osArch, nil
}
}
// Not in generatedData, detect now
ui.Message("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())
ui.Message(fmt.Sprintf("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
@ -222,3 +380,314 @@ func (p *Provisioner) getUserDestination() (string, error) {
return dst, 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 native SBOM generation workflow...")
// Step 1: Download scanner binary
ui.Say("Downloading scanner binary...")
scannerLocalPath, err := p.downloadScanner(ctx, ui, osType, osArch)
if err != nil {
return fmt.Errorf("failed to download scanner: %s", err)
}
defer os.Remove(scannerLocalPath)
// Step 2: Verify checksum if provided
if p.config.ScannerChecksum != "" {
ui.Say("Verifying scanner checksum...")
if err := p.verifyChecksum(scannerLocalPath); err != nil {
return fmt.Errorf("checksum verification failed: %s", err)
}
ui.Say("Checksum verification passed")
}
// Step 3: Upload scanner to remote
// Step 4: Run scanner on remote
// Step 5: Download SBOM
// Step 6: Process SBOM for HCP
// Step 7: Cleanup remote files
// TODO: Implement in commits 4-6
return fmt.Errorf("native SBOM generation partially implemented (commits 4-6 pending)")
}
// downloadScanner downloads the scanner binary using go-getter
func (p *Provisioner) downloadScanner(ctx context.Context, ui packersdk.Ui,
osType, osArch string) (string, error) {
var downloadURL string
// If user provided a URL, use it
if p.config.ScannerURL != "" {
downloadURL = p.config.ScannerURL
ui.Message(fmt.Sprintf("Using custom scanner URL: %s", downloadURL))
} else {
// Default to Syft from GitHub releases
if osType == "unknown" || osArch == "unknown" {
return "", fmt.Errorf("cannot auto-download scanner: OS/Arch unknown (provide scanner_url)")
}
downloadURL = p.buildDefaultSyftURL(osType, osArch)
ui.Message(fmt.Sprintf("Auto-downloading Syft for %s/%s", osType, osArch))
ui.Message(fmt.Sprintf("Download URL: %s", downloadURL))
}
// 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 os.RemoveAll(tmpDir)
// Use go-getter to download
client := &getter.Client{}
req := &getter.Request{
Src: downloadURL,
Dst: tmpDir,
}
ui.Message("Downloading scanner binary...")
if _, err := client.Get(ctx, req); err != nil {
return "", fmt.Errorf("failed to download scanner: %s", err)
}
// Find the scanner binary in the downloaded files
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)
}
ui.Message(fmt.Sprintf("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)
// Default to latest stable version
version := "v0.100.0"
// Construct GitHub release URL
// Example: https://github.com/anchore/syft/releases/download/v0.100.0/syft_0.100.0_linux_amd64.tar.gz
versionNum := strings.TrimPrefix(version, "v")
fileName := fmt.Sprintf("syft_%s_%s_%s.tar.gz", versionNum, syftOS, syftArch)
return fmt.Sprintf("https://github.com/anchore/syft/releases/download/%s/%s",
version, fileName)
}
// 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
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"
}
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 == "" {
// If not found, try to extract from tar.gz
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 archive
func (p *Provisioner) extractScannerFromArchive(dir, binaryName string) (string, error) {
// Find tar.gz file
var archivePath string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".tar.gz") {
archivePath = path
return filepath.SkipDir
}
return nil
})
if archivePath == "" {
return "", fmt.Errorf("no tar.gz archive found")
}
// Open and extract
file, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return "", err
}
defer gzr.Close()
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 tmpBinary.Close()
// 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 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 tmpFile.Close()
// Open source
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
// 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 file.Close()
// 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
}