From fc038d32e35c0b953c5bc2aeee604b294f9f0fb6 Mon Sep 17 00:00:00 2001 From: Hari Om Date: Tue, 19 May 2026 18:34:44 +0530 Subject: [PATCH] feat: enforce required auto_generate field and refactor Packer release download logic --- provisioner/hcp-sbom/provisioner.go | 181 +---------- provisioner/hcp-sbom/provisioner.hcl2spec.go | 2 +- provisioner/hcp-sbom/release_download.go | 307 +++++++++++++++++++ 3 files changed, 311 insertions(+), 179 deletions(-) create mode 100644 provisioner/hcp-sbom/release_download.go diff --git a/provisioner/hcp-sbom/provisioner.go b/provisioner/hcp-sbom/provisioner.go index 3c31b3db9..91932e9f0 100644 --- a/provisioner/hcp-sbom/provisioner.go +++ b/provisioner/hcp-sbom/provisioner.go @@ -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) } diff --git a/provisioner/hcp-sbom/provisioner.hcl2spec.go b/provisioner/hcp-sbom/provisioner.hcl2spec.go index b554ebe56..544af77a3 100644 --- a/provisioner/hcp-sbom/provisioner.hcl2spec.go +++ b/provisioner/hcp-sbom/provisioner.hcl2spec.go @@ -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"` diff --git a/provisioner/hcp-sbom/release_download.go b/provisioner/hcp-sbom/release_download.go new file mode 100644 index 000000000..77ae095ce --- /dev/null +++ b/provisioner/hcp-sbom/release_download.go @@ -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 +}