feat: remove Syft binary handling and streamline remote scanner preparation

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Hari Om 2026-05-14 15:03:39 +05:30
parent 661560c15d
commit 6ce4159a2a
2 changed files with 69 additions and 452 deletions

View file

@ -7,22 +7,16 @@
package hcp_sbom
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/hashicorp/hcl/v2/hcldec"
hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models"
@ -471,215 +465,53 @@ func (p *Provisioner) getUserDestination() (string, error) {
return dst, nil
}
// findModuleRoot walks up from the running executable's directory to find the
// nearest directory containing a go.mod file (the module root for dev builds).
func findModuleRoot() (string, error) {
exe, err := os.Executable()
// provisionWithNativeGeneration handles the native SBOM generation flow by
// downloading and extracting a Packer binary on the remote host and running
// `packer sbom-generate` there.
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 and extract a release scanner directly on the remote host.
remoteScannerPath, remoteZipPath, err := p.prepareRemoteScannerOnRemote(ctx, ui, comm, osType, osArch)
if err != nil {
return "", fmt.Errorf("could not find Packer executable: %w", err)
return fmt.Errorf("failed to prepare scanner on remote host: %s", err)
}
dir := filepath.Dir(exe)
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
defer p.cleanupRemoteFile(ctx, ui, comm, remoteScannerPath)
if remoteZipPath != "" {
defer p.cleanupRemoteFile(ctx, ui, comm, remoteZipPath)
}
return "", fmt.Errorf("could not find go.mod walking up from %s (is this a dev build?)", filepath.Dir(exe))
// Step 2: 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 3: 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 4: 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
}
func downloadText(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)
}
func (p *Provisioner) prepareRemoteScannerOnRemote(ctx context.Context, ui packersdk.Ui,
comm packersdk.Communicator, osType, osArch string) (remoteScannerPath, remoteZipPath string, err error) {
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 downloads the Packer release binary for the given
// GOOS/GOARCH from releases.hashicorp.com. Used for release builds when the
// remote host differs from the Packer host.
func downloadPackerRelease(ctx context.Context, goos, goarch, version string) (string, error) {
// Packer releases use the format: packer_{version}_{os}_{arch}.zip
// e.g. https://releases.hashicorp.com/packer/1.12.0/packer_1.12.0_linux_arm64.zip
fileName := fmt.Sprintf("packer_%s_%s_%s.zip", version, goos, goarch)
url := fmt.Sprintf("https://releases.hashicorp.com/packer/%s/%s", version, fileName)
shaSumsURL := fmt.Sprintf("https://releases.hashicorp.com/packer/%s/packer_%s_SHA256SUMS", version, version)
log.Printf("[INFO] Downloading Packer %s for %s/%s from %s...", version, goos, goarch, url)
client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("failed to build download request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download Packer release: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: HTTP %d for %s", resp.StatusCode, url)
}
// Write zip to a temp file
zipFile, err := os.CreateTemp("", "packer-release-*.zip")
if err != nil {
return "", fmt.Errorf("failed to create temp zip file: %w", err)
}
zipPath := zipFile.Name()
defer func() {
if err := os.Remove(zipPath); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("[WARN] failed to remove temp release zip %s: %v", zipPath, err)
}
}()
if _, err := io.Copy(zipFile, resp.Body); err != nil {
_ = zipFile.Close()
return "", fmt.Errorf("failed to write zip file: %w", err)
}
_ = zipFile.Close()
// Verify ZIP integrity against official HashiCorp SHA256SUMS before extracting.
sumsContent, err := downloadText(ctx, client, shaSumsURL)
if err != nil {
return "", fmt.Errorf("failed to retrieve release checksums: %w", err)
}
expectedSHA, err := expectedZipSHA256FromSums(sumsContent, fileName)
if err != nil {
return "", fmt.Errorf("failed to resolve expected checksum: %w", err)
}
actualSHA, err := fileSHA256(zipPath)
if err != nil {
return "", err
}
if !strings.EqualFold(expectedSHA, actualSHA) {
return "", fmt.Errorf("release checksum verification failed for %s: expected %s, got %s", fileName, expectedSHA, actualSHA)
}
// Extract the packer binary from the zip
binaryName := "packer"
if goos == "windows" {
binaryName = "packer.exe"
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return "", fmt.Errorf("failed to open downloaded zip: %w", err)
}
defer func() { _ = zr.Close() }()
for _, f := range zr.File {
if f.Name == binaryName {
rc, err := f.Open()
if err != nil {
return "", fmt.Errorf("failed to open %s in zip: %w", binaryName, err)
}
defer func() { _ = rc.Close() }()
out, err := os.CreateTemp("", fmt.Sprintf("packer-%s-%s-*", goos, goarch))
if err != nil {
return "", fmt.Errorf("failed to create temp binary file: %w", err)
}
outPath := out.Name()
if _, err := io.Copy(out, rc); err != nil {
_ = out.Close()
_ = os.Remove(outPath)
return "", fmt.Errorf("failed to extract Packer binary: %w", err)
}
_ = out.Close()
if err := os.Chmod(outPath, 0755); err != nil {
_ = os.Remove(outPath)
return "", fmt.Errorf("failed to make Packer binary executable: %w", err)
}
log.Printf("[INFO] Downloaded Packer binary to: %s", outPath)
return outPath, nil
}
}
return "", fmt.Errorf("packer binary not found in release zip %s", url)
}
// resolveScannerBinary returns the local path to a Packer binary that can run
// on the remote host (given its osType and osArch from uname output), plus a
// boolean indicating whether the caller must delete the file after use.
//
// Resolution behavior:
// 1. Download from releases.hashicorp.com (temp file, delete after)
func (p *Provisioner) resolveScannerBinary(ctx context.Context, ui packersdk.Ui, osType, osArch string) (path string, isTemp bool, err error) {
// Normalise uname-style OS/arch strings to GOOS/GOARCH values.
targetGOOS := strings.ToLower(osType)
archMap := map[string]string{
"x86_64": "amd64", "aarch64": "arm64", "i386": "386", "i686": "386", "armv7l": "arm", "armv7": "arm",
@ -690,116 +522,45 @@ func (p *Provisioner) resolveScannerBinary(ctx context.Context, ui packersdk.Ui,
}
version := packerversion.Version
fileName := fmt.Sprintf("packer_%s_%s_%s.zip", version, targetGOOS, targetGOARCH)
url := fmt.Sprintf("https://releases.hashicorp.com/packer/%s/%s", version, fileName)
ui.Say(fmt.Sprintf("Downloading Packer %s for %s/%s from releases.hashicorp.com...", version, targetGOOS, targetGOARCH))
binPath, err := downloadPackerRelease(ctx, targetGOOS, targetGOARCH, version)
if err != nil {
return "", false, fmt.Errorf("failed to download Packer release for %s/%s: %w", targetGOOS, targetGOARCH, err)
}
return binPath, true, nil
}
// provisionWithNativeGeneration handles the native SBOM generation flow by
// uploading a Packer binary (with embedded Syft SDK) to the remote host and
// running `packer sbom-generate` there. Automatically selects the right release
// binary for the remote OS/arch.
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: Get a Packer binary compatible with the remote host's OS/arch.
scannerLocalPath, isTemp, err := p.resolveScannerBinary(ctx, ui, osType, osArch)
if err != nil {
return fmt.Errorf("failed to obtain Packer binary for remote host: %s", err)
}
if isTemp {
defer func() {
log.Printf("Cleaning up temporary Packer binary: %s", scannerLocalPath)
if err := os.Remove(scannerLocalPath); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("[WARN] failed to remove temporary Packer binary %s: %v", scannerLocalPath, err)
}
}()
isWindows := strings.Contains(targetGOOS, "windows")
if isWindows {
remoteZipPath = "C:\\Windows\\Temp\\packer-sbom-runner.zip"
remoteScannerPath = "C:\\Windows\\Temp\\packer-sbom-runner.exe"
cmdStr := fmt.Sprintf("powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"$ErrorActionPreference='Stop'; Invoke-WebRequest -Uri '%s' -OutFile '%s'; Expand-Archive -Path '%s' -DestinationPath 'C:\\Windows\\Temp' -Force; Move-Item -Force 'C:\\Windows\\Temp\\packer.exe' '%s'\"", url, remoteZipPath, remoteZipPath, remoteScannerPath)
cmd := &packersdk.RemoteCmd{Command: cmdStr}
if err := comm.Start(ctx, cmd); err != nil {
return "", "", fmt.Errorf("failed to start remote download/extract command: %s", err)
}
cmd.Wait()
if cmd.ExitStatus() != 0 {
return "", "", fmt.Errorf("remote download/extract command failed with exit status %d", cmd.ExitStatus())
}
ui.Say(fmt.Sprintf("Downloaded Packer %s to remote path %s", version, remoteScannerPath))
return remoteScannerPath, remoteZipPath, nil
}
// 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)
remoteZipPath = "/tmp/packer-sbom-runner.zip"
remoteScannerPath = "/tmp/packer-sbom-runner"
cmdStr := fmt.Sprintf("set -e; if command -v curl >/dev/null 2>&1; then curl -fsSL '%s' -o '%s'; elif command -v wget >/dev/null 2>&1; then wget -qO '%s' '%s'; else echo 'curl or wget required' >&2; exit 1; fi; if command -v unzip >/dev/null 2>&1; then unzip -p '%s' packer > '%s'; elif command -v bsdtar >/dev/null 2>&1; then bsdtar -xOf '%s' packer > '%s'; else echo 'unzip or bsdtar required' >&2; exit 1; fi; chmod +x '%s'", url, remoteZipPath, remoteZipPath, url, remoteZipPath, remoteScannerPath, remoteZipPath, remoteScannerPath, remoteScannerPath)
cmd := &packersdk.RemoteCmd{Command: cmdStr}
if err := comm.Start(ctx, cmd); err != nil {
return "", "", fmt.Errorf("failed to start remote download/extract command: %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)
cmd.Wait()
if cmd.ExitStatus() != 0 {
return "", "", fmt.Errorf("remote download/extract command failed with exit status %d", cmd.ExitStatus())
}
// Step 5: Process SBOM for HCP (validate, compress, store)
log.Println("Processing SBOM for HCP Packer...")
if err := p.processSBOMForHCP(generatedData, sbomData); err != nil {
return fmt.Errorf("failed to process SBOM: %s", err)
}
ui.Say("Automatic SBOM generation completed successfully")
return nil
ui.Say(fmt.Sprintf("Downloaded Packer %s to remote path %s", version, remoteScannerPath))
return remoteScannerPath, remoteZipPath, nil
}
// uploadScanner uploads the Packer binary to the remote host.
// For Unix: uploads to /tmp/packer-sbom-runner and makes it executable.
// For Windows: uploads to C:\Windows\Temp\packer-sbom-runner.exe.
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")
var remotePath string
if isWindows {
remotePath = "C:\\Windows\\Temp\\packer-sbom-runner.exe"
} else {
remotePath = "/tmp/packer-sbom-runner"
}
localFile, err := os.Open(localPath)
if err != nil {
return "", fmt.Errorf("failed to open Packer binary: %s", err)
}
defer func() {
_ = localFile.Close()
}()
log.Printf("Uploading Packer binary to %s...", remotePath)
if err := comm.Upload(remotePath, localFile, nil); err != nil {
return "", fmt.Errorf("failed to upload Packer binary: %s", err)
}
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 Packer binary 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 `packer sbom-generate` on the remote host.
func (p *Provisioner) runScanner(ctx context.Context, ui packersdk.Ui,
comm packersdk.Communicator, scannerPath, osType string) (string, error) {

View file

@ -1,10 +1,6 @@
package hcp_sbom
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
@ -292,143 +288,3 @@ func TestNormalizeScannerExecuteCommand(t *testing.T) {
})
}
}
func TestExpectedZipSHA256FromSums(t *testing.T) {
tests := []struct {
name string
sumsContent string
fileName string
want string
wantErr string
}{
{
name: "matches standard sums line",
sumsContent: strings.Join([]string{
"1111111111111111111111111111111111111111111111111111111111111111 packer_1.12.0_linux_amd64.zip",
"2222222222222222222222222222222222222222222222222222222222222222 packer_1.12.0_linux_arm64.zip",
}, "\n"),
fileName: "packer_1.12.0_linux_arm64.zip",
want: "2222222222222222222222222222222222222222222222222222222222222222",
},
{
name: "matches starred filename",
sumsContent: strings.Join([]string{
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa *packer_1.12.0_windows_amd64.zip",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb *packer_1.12.0_linux_amd64.zip",
}, "\n"),
fileName: "packer_1.12.0_windows_amd64.zip",
want: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
{
name: "rejects malformed checksum format",
sumsContent: strings.Join([]string{
"not-a-valid-sha256 packer_1.12.0_linux_amd64.zip",
}, "\n"),
fileName: "packer_1.12.0_linux_amd64.zip",
wantErr: "invalid SHA256 checksum format",
},
{
name: "returns not found for missing file",
sumsContent: strings.Join([]string{
"1111111111111111111111111111111111111111111111111111111111111111 packer_1.12.0_linux_amd64.zip",
}, "\n"),
fileName: "packer_1.12.0_freebsd_amd64.zip",
wantErr: "not found in SHA256SUMS",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := expectedZipSHA256FromSums(tt.sumsContent, tt.fileName)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got != tt.want {
t.Fatalf("unexpected checksum: want %q, got %q", tt.want, got)
}
})
}
}
func TestIsValidSHA256Hex(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{
name: "valid lowercase sha256",
in: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
want: true,
},
{
name: "valid uppercase sha256",
in: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
want: true,
},
{
name: "rejects non-hex characters",
in: "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
want: false,
},
{
name: "rejects short length",
in: "abc123",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isValidSHA256Hex(tt.in); got != tt.want {
t.Fatalf("unexpected result: want %t, got %t", tt.want, got)
}
})
}
}
func TestFileSHA256(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.bin")
content := []byte("packer checksum test payload")
if err := os.WriteFile(path, content, 0600); err != nil {
t.Fatalf("failed to write temp file: %s", err)
}
got, err := fileSHA256(path)
if err != nil {
t.Fatalf("unexpected error hashing file: %s", err)
}
wantBytes := sha256.Sum256(content)
want := hex.EncodeToString(wantBytes[:])
if got != want {
t.Fatalf("unexpected hash: want %q, got %q", want, got)
}
}
func TestChecksumMismatchDetection(t *testing.T) {
fileName := "packer_1.12.0_linux_amd64.zip"
sumsContent := "1111111111111111111111111111111111111111111111111111111111111111 " + fileName
expected, err := expectedZipSHA256FromSums(sumsContent, fileName)
if err != nil {
t.Fatalf("unexpected error resolving expected checksum: %s", err)
}
actual := "2222222222222222222222222222222222222222222222222222222222222222"
if strings.EqualFold(expected, actual) {
t.Fatalf("expected checksum mismatch, but checksums compared equal")
}
}