mirror of https://github.com/hashicorp/packer
- Introduced `scanner_args` field in FlatConfig for additional scanner arguments. - Updated `execute_command` to use `sbom-generate` for SBOM generation. - Enhanced tests to cover new configurations and command formats. - Added a new file for downloading the latest Packer release and verifying checksums. - Removed deprecated `scanner_url` and `scanner_checksum` fields, updating documentation accordingly. - Bumped version to 1.15.4.HRP-2522-Remove_syft_bin
parent
342fc1c554
commit
b90187ae09
@ -0,0 +1,167 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/packer/internal/sbom"
|
||||
)
|
||||
|
||||
type SBOMGenerateCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
func (cmd *SBOMGenerateCommand) Run(args []string) int {
|
||||
ctx, cleanup := handleTermInterrupt(cmd.Ui)
|
||||
defer cleanup()
|
||||
|
||||
cfg, ret := cmd.ParseArgs(args)
|
||||
if ret != 0 {
|
||||
return ret
|
||||
}
|
||||
return cmd.RunContext(ctx, cfg)
|
||||
}
|
||||
|
||||
func (cmd *SBOMGenerateCommand) ParseArgs(args []string) (*sbom.Config, int) {
|
||||
cfg := &sbom.Config{
|
||||
ScanPath: "/",
|
||||
Format: sbom.FormatCycloneDX, // default format
|
||||
Parallelism: 4, // default parallelism
|
||||
Scope: sbom.ScopeSquashed, // default scope
|
||||
}
|
||||
|
||||
//Parse Syft Style args
|
||||
// Parse Syft-style arguments
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
|
||||
switch arg {
|
||||
case "-o", "--output":
|
||||
// Next arg is format
|
||||
if i+1 >= len(args) {
|
||||
cmd.Ui.Error("Missing value for -o flag")
|
||||
return cfg, 1
|
||||
}
|
||||
i++
|
||||
formatStr := args[i]
|
||||
|
||||
// Parse format string
|
||||
format, err := sbom.ParseFormatFromArgs(formatStr)
|
||||
if err != nil {
|
||||
cmd.Ui.Error(err.Error())
|
||||
return cfg, 1
|
||||
}
|
||||
cfg.Format = format
|
||||
|
||||
case "--exclude":
|
||||
if i+1 >= len(args) {
|
||||
cmd.Ui.Error("Missing value for --exclude flag")
|
||||
return cfg, 1
|
||||
}
|
||||
i++
|
||||
cfg.Exclude = append(cfg.Exclude, args[i])
|
||||
|
||||
case "--scope":
|
||||
if i+1 >= len(args) {
|
||||
cmd.Ui.Error("Missing value for --scope flag")
|
||||
return cfg, 1
|
||||
}
|
||||
i++
|
||||
scope, err := sbom.ParseScopeFromArgs(args[i])
|
||||
if err != nil {
|
||||
cmd.Ui.Error(err.Error())
|
||||
return cfg, 1
|
||||
}
|
||||
cfg.Scope = scope
|
||||
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--exclude=") {
|
||||
value := strings.TrimPrefix(arg, "--exclude=")
|
||||
if value == "" {
|
||||
cmd.Ui.Error("Missing value for --exclude flag")
|
||||
return cfg, 1
|
||||
}
|
||||
cfg.Exclude = append(cfg.Exclude, value)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(arg, "--scope=") {
|
||||
value := strings.TrimPrefix(arg, "--scope=")
|
||||
if value == "" {
|
||||
cmd.Ui.Error("Missing value for --scope flag")
|
||||
return cfg, 1
|
||||
}
|
||||
scope, err := sbom.ParseScopeFromArgs(value)
|
||||
if err != nil {
|
||||
cmd.Ui.Error(err.Error())
|
||||
return cfg, 1
|
||||
}
|
||||
cfg.Scope = scope
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Assume it's the scan path (positional argument)
|
||||
cfg.ScanPath = arg
|
||||
}
|
||||
}
|
||||
return cfg, 0
|
||||
}
|
||||
|
||||
func (cmd *SBOMGenerateCommand) RunContext(ctx context.Context, cfg *sbom.Config) int {
|
||||
fmt.Fprintf(os.Stderr, "Generating %s SBOM for %s...\n", cfg.Format, cfg.ScanPath)
|
||||
|
||||
// Create generator
|
||||
generator := sbom.NewGenerator(*cfg)
|
||||
|
||||
// Generate SBOM
|
||||
sbomData, err := generator.Generate(ctx)
|
||||
if err != nil {
|
||||
cmd.Ui.Error(fmt.Sprintf("SBOM generation failed: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Write to stdout (will be redirected to file via > operator)
|
||||
_, err = os.Stdout.Write(sbomData)
|
||||
if err != nil {
|
||||
cmd.Ui.Error(fmt.Sprintf("Failed to write SBOM: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "✓ SBOM generation completed")
|
||||
|
||||
return 0
|
||||
|
||||
}
|
||||
func (c *SBOMGenerateCommand) Synopsis() string {
|
||||
return "Generate SBOM for the local system (internal use)"
|
||||
}
|
||||
func (c *SBOMGenerateCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: packer sbom-generate [options] <path>
|
||||
Generate a Software Bill of Materials (SBOM) for the local filesystem.
|
||||
This command is typically invoked internally by the hcp-sbom provisioner.
|
||||
Options:
|
||||
-o <format> Output format: cyclonedx-json, spdx-json (default: cyclonedx-json)
|
||||
--exclude <glob> Optional: exclude path glob from scanning (repeatable)
|
||||
--scope <scope> Optional: scan scope: squashed, all-layers (default: squashed)
|
||||
Arguments:
|
||||
<path> Path to scan (default: /)
|
||||
Examples:
|
||||
# Generate CycloneDX SBOM for root filesystem
|
||||
packer sbom-generate -o cyclonedx-json / > sbom.json
|
||||
# Generate SPDX SBOM
|
||||
packer sbom-generate -o spdx-json / > sbom.json
|
||||
# Scan specific directory
|
||||
packer sbom-generate -o cyclonedx-json /opt/app > app-sbom.json
|
||||
# Scan all image layers and exclude temporary paths
|
||||
packer sbom-generate --scope all-layers --exclude "/tmp/**" -o cyclonedx-json / > sbom.json
|
||||
Note: Output is written to stdout. Use shell redirection (>) to save to file.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
// Copyright IBM Corp. 2013, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package command
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
|
||||
"github.com/hashicorp/packer/internal/sbom"
|
||||
)
|
||||
|
||||
func TestSBOMGenerateCommand_ParseArgs_ExcludeAndScope(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
cmd := &SBOMGenerateCommand{
|
||||
Meta: Meta{
|
||||
Ui: &packersdk.BasicUi{
|
||||
Writer: &out,
|
||||
ErrorWriter: &errOut,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, ret := cmd.ParseArgs([]string{
|
||||
"--exclude", "/tmp/**",
|
||||
"--exclude=/var/cache/**",
|
||||
"--scope", "all-layers",
|
||||
"-o", "cyclonedx-json",
|
||||
"/",
|
||||
})
|
||||
if ret != 0 {
|
||||
t.Fatalf("expected parse success, got ret=%d err=%q", ret, errOut.String())
|
||||
}
|
||||
|
||||
if cfg.Scope != sbom.ScopeAllLayers {
|
||||
t.Fatalf("expected scope %q, got %q", sbom.ScopeAllLayers, cfg.Scope)
|
||||
}
|
||||
|
||||
if len(cfg.Exclude) != 2 {
|
||||
t.Fatalf("expected 2 exclude entries, got %d", len(cfg.Exclude))
|
||||
}
|
||||
if cfg.Exclude[0] != "/tmp/**" || cfg.Exclude[1] != "/var/cache/**" {
|
||||
t.Fatalf("unexpected exclude values: %#v", cfg.Exclude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSBOMGenerateCommand_ParseArgs_InvalidScope(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
cmd := &SBOMGenerateCommand{
|
||||
Meta: Meta{
|
||||
Ui: &packersdk.BasicUi{
|
||||
Writer: &out,
|
||||
ErrorWriter: &errOut,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, ret := cmd.ParseArgs([]string{"--scope", "bad-scope"})
|
||||
if ret == 0 {
|
||||
t.Fatalf("expected parse failure for invalid scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSBOMGenerateCommand_ParseArgs_UnsupportedFlagIgnored(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
cmd := &SBOMGenerateCommand{
|
||||
Meta: Meta{
|
||||
Ui: &packersdk.BasicUi{
|
||||
Writer: &out,
|
||||
ErrorWriter: &errOut,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, ret := cmd.ParseArgs([]string{"-q", "/opt/app"})
|
||||
if ret != 0 {
|
||||
t.Fatalf("expected parse success, got ret=%d err=%q", ret, errOut.String())
|
||||
}
|
||||
|
||||
if cfg.ScanPath != "/opt/app" {
|
||||
t.Fatalf("expected scan path /opt/app, got %q", cfg.ScanPath)
|
||||
}
|
||||
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout output for ignored arg, got %q", out.String())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package sbom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Format represents the SBOM output format
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatCycloneDX Format = "cyclonedx"
|
||||
FormatSPDX Format = "spdx"
|
||||
ScopeSquashed string = "squashed"
|
||||
ScopeAllLayers string = "all-layers"
|
||||
)
|
||||
|
||||
// Config holds configuration for SBOM generation
|
||||
type Config struct {
|
||||
ScanPath string // Path to scan (e.g., "/", "/opt/app")
|
||||
Format Format // Output format (cyclonedx or spdx)
|
||||
Parallelism int // Number of parallel catalogers (0 = auto-detect)
|
||||
Scope string // Scan scope (squashed or all-layers)
|
||||
Exclude []string
|
||||
}
|
||||
|
||||
// Generator generates SBOMs using embedded Syft SDK
|
||||
type Generator struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
// NewGenerator creates a new SBOM generator with the given configuration
|
||||
func NewGenerator(cfg Config) *Generator {
|
||||
// Set defaults
|
||||
if cfg.ScanPath == "" {
|
||||
cfg.ScanPath = "/"
|
||||
}
|
||||
if cfg.Format == "" {
|
||||
cfg.Format = FormatCycloneDX
|
||||
}
|
||||
if cfg.Parallelism == 0 {
|
||||
cfg.Parallelism = 4
|
||||
}
|
||||
if cfg.Scope == "" {
|
||||
cfg.Scope = ScopeSquashed
|
||||
}
|
||||
|
||||
return &Generator{config: cfg}
|
||||
}
|
||||
|
||||
// ParseFormatFromArgs parses Syft-style format argument
|
||||
// This is a PACKAGE-LEVEL EXPORTED FUNCTION
|
||||
func ParseFormatFromArgs(formatArg string) (Format, error) {
|
||||
formatArg = strings.ToLower(formatArg)
|
||||
|
||||
if strings.Contains(formatArg, "cyclonedx") {
|
||||
return FormatCycloneDX, nil
|
||||
}
|
||||
if strings.Contains(formatArg, "spdx") {
|
||||
return FormatSPDX, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported format: %s", formatArg)
|
||||
}
|
||||
|
||||
func ParseScopeFromArgs(scopeArg string) (string, error) {
|
||||
scopeArg = strings.ToLower(strings.TrimSpace(scopeArg))
|
||||
|
||||
switch scopeArg {
|
||||
case ScopeSquashed:
|
||||
return ScopeSquashed, nil
|
||||
case ScopeAllLayers, "all", "alllayers":
|
||||
return ScopeAllLayers, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported scope: %s (supported: squashed, all-layers)", scopeArg)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
// Copyright IBM Corp. 2013, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
//go:build !netbsd && !openbsd && !solaris && !mips && !mipsle && !mips64 && !(freebsd && 386)
|
||||
|
||||
package sbom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
||||
"github.com/anchore/syft/syft/format/spdxjson"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
// Generate creates an SBOM for the configured scan path and returns the encoded result.
|
||||
func (g *Generator) Generate(ctx context.Context) ([]byte, error) {
|
||||
sourceInput := g.config.ScanPath
|
||||
getSourceCfg := syft.DefaultGetSourceConfig()
|
||||
if len(g.config.Exclude) > 0 {
|
||||
getSourceCfg = getSourceCfg.WithExcludeConfig(source.ExcludeConfig{Paths: g.config.Exclude})
|
||||
}
|
||||
|
||||
src, err := syft.GetSource(ctx, sourceInput, getSourceCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get source: %w", err)
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
var scope source.Scope
|
||||
switch g.config.Scope {
|
||||
case ScopeAllLayers:
|
||||
scope = source.AllLayersScope
|
||||
case "", ScopeSquashed:
|
||||
scope = source.SquashedScope
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported scope: %s", g.config.Scope)
|
||||
}
|
||||
|
||||
sbomCfg := syft.DefaultCreateSBOMConfig().
|
||||
WithSearchConfig(cataloging.SearchConfig{
|
||||
Scope: scope,
|
||||
}).
|
||||
WithParallelism(g.config.Parallelism)
|
||||
|
||||
sbomResult, err := syft.CreateSBOM(ctx, src, sbomCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SBOM: %w", err)
|
||||
}
|
||||
|
||||
return g.encodeToFormat(sbomResult)
|
||||
}
|
||||
|
||||
// encodeToFormat encodes the SBOM to the requested format.
|
||||
func (g *Generator) encodeToFormat(sbomData *sbom.SBOM) ([]byte, error) {
|
||||
switch g.config.Format {
|
||||
case FormatCycloneDX:
|
||||
encoder, err := cyclonedxjson.NewFormatEncoderWithConfig(
|
||||
cyclonedxjson.EncoderConfig{
|
||||
Version: "1.5",
|
||||
Pretty: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create CycloneDX encoder: %w", err)
|
||||
}
|
||||
return format.Encode(*sbomData, encoder)
|
||||
|
||||
case FormatSPDX:
|
||||
encoder, err := spdxjson.NewFormatEncoderWithConfig(
|
||||
spdxjson.EncoderConfig{
|
||||
Version: "2.3",
|
||||
Pretty: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SPDX encoder: %w", err)
|
||||
}
|
||||
return format.Encode(*sbomData, encoder)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s (supported: cyclonedx, spdx)", g.config.Format)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
// Copyright IBM Corp. 2013, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
//go:build netbsd || openbsd || solaris || mips || mipsle || mips64 || (freebsd && 386)
|
||||
|
||||
package sbom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Generate returns an error on platforms where the Syft SDK cannot be built.
|
||||
func (g *Generator) Generate(ctx context.Context) ([]byte, error) {
|
||||
_ = ctx
|
||||
return nil, fmt.Errorf("sbom generation is not supported on %s builds", runtime.GOOS)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
// Copyright IBM Corp. 2013, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
// Exclude platforms where containerd (a transitive dependency of syft) doesn't compile.
|
||||
// Containerd has platform-specific code that lacks support for NetBSD, OpenBSD, and Solaris.
|
||||
// This exclusion only affects dependency tracking; the hcp-sbom provisioner downloads
|
||||
// pre-built syft binaries at runtime and works on all platforms where those binaries exist.
|
||||
//go:build !netbsd && !openbsd && !solaris
|
||||
|
||||
package hcp_sbom
|
||||
|
||||
import (
|
||||
// Blank import to register Syft as a dependency
|
||||
// This file exists to declare Syft as a dependency for license and security scanning purposes.
|
||||
// While Packer downloads and executes Syft binaries at runtime, this import ensures
|
||||
// the Syft project appears in dependency analysis tools and SBOMs generated for Packer itself.
|
||||
_ "github.com/anchore/syft/syft"
|
||||
)
|
||||
@ -1 +1 @@
|
||||
1.15.3
|
||||
1.15.4
|
||||
|
||||
Loading…
Reference in new issue