feat: enforce required auto_generate field and refactor Packer release download logic

This commit is contained in:
Hari Om 2026-05-19 18:34:44 +05:30
parent 3ec8bb8811
commit fc038d32e3
3 changed files with 311 additions and 179 deletions

View file

@ -10,19 +10,15 @@ 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"
@ -31,7 +27,6 @@ import (
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/hashicorp/packer-plugin-sdk/template/config"
"github.com/hashicorp/packer-plugin-sdk/template/interpolate"
packerversion "github.com/hashicorp/packer/version"
)
type Config struct {
@ -60,7 +55,7 @@ type Config struct {
// the remote host. When enabled, the provisioner uploads the running Packer
// binary (which embeds the Syft SDK) to the remote VM and executes it there
// to generate an SBOM. Mutually exclusive with `source`.
AutoGenerate bool `mapstructure:"auto_generate" required:"false"`
AutoGenerate bool `mapstructure:"auto_generate" required:"true"`
// Arguments to pass to `packer sbom-generate`. Default:
// `["-o", "cyclonedx-json"]`.
@ -471,175 +466,6 @@ func (p *Provisioner) getUserDestination() (string, error) {
return dst, 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)
}
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
}
// 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
}
// downloadPackerRelease downloads the Packer release zip for the given
// GOOS/GOARCH and verifies its checksum. It returns the local temp zip path.
// Set PACKER_RELEASE_SERVER=http://127.0.0.1:3231 to use the local release
// server instead of releases.hashicorp.com.
func downloadPackerRelease(ctx context.Context, goos, goarch, version string) (string, error) {
base := getReleaseBaseURL()
fileName := fmt.Sprintf("packer_%s_%s_%s.zip", version, goos, goarch)
url := 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 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)
}
zipFile, err := os.CreateTemp("", "packer-release-*.zip")
if err != nil {
return "", fmt.Errorf("failed to create temp zip file: %w", err)
}
zipPath := zipFile.Name()
if _, err := io.Copy(zipFile, resp.Body); err != nil {
_ = zipFile.Close()
return "", fmt.Errorf("failed to write zip file: %w", err)
}
_ = zipFile.Close()
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)
}
binaryName := "packer"
if goos == "windows" {
binaryName = "packer.exe"
}
// Validate expected binary exists in the archive before uploading it remotely.
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 not found in release zip %s", url)
}
log.Printf("[INFO] Downloaded Packer release zip to: %s", zipPath)
return zipPath, 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.
@ -799,9 +625,8 @@ func (p *Provisioner) provisionWithNativeGeneration(
if mapped, ok := archMap[targetGOARCH]; ok {
targetGOARCH = mapped
}
version := packerversion.Version
ui.Say(fmt.Sprintf("Downloading Packer %s for %s/%s from %s...", version, targetGOOS, targetGOARCH, getReleaseBaseURL()))
scannerZipPath, err := downloadPackerRelease(ctx, targetGOOS, targetGOARCH, version)
ui.Say(fmt.Sprintf("Downloading latest Packer release for %s/%s from %s...", targetGOOS, targetGOARCH, getReleaseBaseURL()))
scannerZipPath, err := downloadPackerRelease(ctx, targetGOOS, targetGOARCH)
if err != nil {
return fmt.Errorf("failed to download Packer release for %s/%s: %w", targetGOOS, targetGOARCH, err)
}

View file

@ -21,7 +21,7 @@ type FlatConfig struct {
Source *string `mapstructure:"source" required:"true" cty:"source" hcl:"source"`
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:"false" cty:"auto_generate" hcl:"auto_generate"`
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"`

View file

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