mirror of
https://github.com/hashicorp/packer.git
synced 2026-09-22 07:51:39 -04:00
* feat(provenance): add SLSA provenance and attestation signing libraries Add internal/provenance for deriving in-toto subjects from Packer artifacts, building SLSA Provenance v1 predicates, wrapping in-toto statements, and best-effort git/CI source detection. Add internal/attestation for DSSE envelope handling and a pluggable Signer/Verifier backend supporting key (local PEM), kms (aws/gcp/ azure/hashivault), and keyless (Sigstore Fulcio) modes, plus Sigstore bundle handling and DSSE/policy verification. Add the supporting module dependencies in go.mod/go.sum. * feat(provenance): add provenance post-processor Add the opt-in "provenance" post-processor that runs after a build, derives subjects from the artifact, emits DSSE-wrapped SLSA provenance (and optional SBOM) attestations, signs them via the configured signing backend, and writes sidecar files (including *.sigstore.json bundles in keyless mode). Register it in the core post-processor set. The provenance enable flag is a tri-state so an unset value stays enabled through HCL2 decoding instead of being silently disabled. * feat(provenance): add verify-attestation command Add "packer verify-attestation" to verify signed DSSE attestations against key, KMS, and keyless policy inputs, including optional Sigstore bundle checks for Rekor and timestamp evidence. Register the command in the CLI. * docs(provenance): add reference CI workflows and changelog Add reference GitHub Actions workflows under examples/ci for SLSA L2 keyless signing and L3-compatible delegated signing * fix: lint and tests * Added docs for Provenance PostProcessor
245 lines
7.6 KiB
Go
245 lines
7.6 KiB
Go
// Copyright IBM Corp. 2024, 2025
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package command
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
|
|
internalattestation "github.com/hashicorp/packer/internal/attestation"
|
|
internalprovenance "github.com/hashicorp/packer/internal/provenance"
|
|
)
|
|
|
|
func TestVerifyAttestationCommandRun(t *testing.T) {
|
|
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
|
|
if err := os.WriteFile(artifactPath, []byte("hello verify"), 0600); err != nil {
|
|
t.Fatalf("write artifact: %v", err)
|
|
}
|
|
|
|
privateKeyPath, publicKeyPath := writeVerifierKeypair(t)
|
|
attestationPath := writeSignedAttestation(t, privateKeyPath, artifactPath, "https://example.com/builder", "git+https://github.com/hashicorp/packer@refs/heads/main")
|
|
|
|
meta := testMeta(t)
|
|
command := &VerifyAttestationCommand{Meta: meta}
|
|
ret := command.Run([]string{
|
|
"-verifier=" + publicKeyPath,
|
|
"-predicate-type=" + internalprovenance.SLSAProvenanceV1PredicateType,
|
|
"-builder-id=https://example.com/builder",
|
|
"-source-uri=git+https://github.com/hashicorp/packer@refs/heads/main",
|
|
"-artifact=" + artifactPath,
|
|
attestationPath,
|
|
})
|
|
if ret != 0 {
|
|
fatalCommand(t, meta)
|
|
}
|
|
|
|
ui := meta.Ui.(*packersdk.BasicUi)
|
|
if got := ui.Writer.(*bytes.Buffer).String(); !strings.Contains(got, "Attestation verified.") {
|
|
t.Fatalf("expected success output, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestVerifyAttestationCommandRejectsMismatchedBuilderID(t *testing.T) {
|
|
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
|
|
if err := os.WriteFile(artifactPath, []byte("hello verify"), 0600); err != nil {
|
|
t.Fatalf("write artifact: %v", err)
|
|
}
|
|
|
|
privateKeyPath, publicKeyPath := writeVerifierKeypair(t)
|
|
attestationPath := writeSignedAttestation(t, privateKeyPath, artifactPath, "https://example.com/builder", "git+https://github.com/hashicorp/packer@refs/heads/main")
|
|
|
|
meta := testMeta(t)
|
|
command := &VerifyAttestationCommand{Meta: meta}
|
|
ret := command.Run([]string{
|
|
"-verifier=" + publicKeyPath,
|
|
"-builder-id=https://example.com/other-builder",
|
|
attestationPath,
|
|
})
|
|
if ret == 0 {
|
|
t.Fatalf("expected builder mismatch to fail")
|
|
}
|
|
}
|
|
|
|
func TestVerifyAttestationCommandRequiresBundleForRekorChecks(t *testing.T) {
|
|
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
|
|
if err := os.WriteFile(artifactPath, []byte("hello verify"), 0600); err != nil {
|
|
t.Fatalf("write artifact: %v", err)
|
|
}
|
|
|
|
privateKeyPath, publicKeyPath := writeVerifierKeypair(t)
|
|
attestationPath := writeSignedAttestation(t, privateKeyPath, artifactPath, "https://example.com/builder", "git+https://github.com/hashicorp/packer@refs/heads/main")
|
|
|
|
meta := testMeta(t)
|
|
command := &VerifyAttestationCommand{Meta: meta}
|
|
ret := command.Run([]string{
|
|
"-verifier=" + publicKeyPath,
|
|
"-require-rekor",
|
|
attestationPath,
|
|
})
|
|
if ret == 0 {
|
|
t.Fatalf("expected missing bundle to fail")
|
|
}
|
|
|
|
ui := meta.Ui.(*packersdk.BasicUi)
|
|
if got := ui.ErrorWriter.(*bytes.Buffer).String(); !strings.Contains(got, "requires -bundle") {
|
|
t.Fatalf("expected missing bundle error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestVerifyAttestationCommandRejectsTamperedPayload(t *testing.T) {
|
|
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
|
|
if err := os.WriteFile(artifactPath, []byte("hello verify"), 0600); err != nil {
|
|
t.Fatalf("write artifact: %v", err)
|
|
}
|
|
|
|
privateKeyPath, publicKeyPath := writeVerifierKeypair(t)
|
|
attestationPath := writeSignedAttestation(t, privateKeyPath, artifactPath, "https://example.com/builder", "git+https://github.com/hashicorp/packer@refs/heads/main")
|
|
|
|
tamperAttestationPayload(t, attestationPath)
|
|
|
|
meta := testMeta(t)
|
|
command := &VerifyAttestationCommand{Meta: meta}
|
|
ret := command.Run([]string{
|
|
"-verifier=" + publicKeyPath,
|
|
attestationPath,
|
|
})
|
|
if ret == 0 {
|
|
t.Fatalf("expected tampered payload to fail verification")
|
|
}
|
|
}
|
|
|
|
func tamperAttestationPayload(t *testing.T, attestationPath string) {
|
|
t.Helper()
|
|
|
|
contents, err := os.ReadFile(attestationPath)
|
|
if err != nil {
|
|
t.Fatalf("read attestation: %v", err)
|
|
}
|
|
|
|
var envelope internalattestation.Envelope
|
|
if err := json.Unmarshal(contents, &envelope); err != nil {
|
|
t.Fatalf("unmarshal envelope: %v", err)
|
|
}
|
|
|
|
payload, err := internalattestation.DecodeEnvelopePayload(envelope)
|
|
if err != nil {
|
|
t.Fatalf("decode payload: %v", err)
|
|
}
|
|
|
|
tampered := bytes.Replace(payload, []byte("https://example.com/builder"), []byte("https://example.com/evilbuilder"), 1)
|
|
if bytes.Equal(tampered, payload) {
|
|
t.Fatalf("expected payload to be modified")
|
|
}
|
|
envelope.Payload = base64.StdEncoding.EncodeToString(tampered)
|
|
|
|
tamperedEnvelope, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
t.Fatalf("marshal tampered envelope: %v", err)
|
|
}
|
|
if err := os.WriteFile(attestationPath, tamperedEnvelope, 0600); err != nil {
|
|
t.Fatalf("write tampered attestation: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeSignedAttestation(t *testing.T, privateKeyPath, artifactPath, builderID, sourceURI string) string {
|
|
t.Helper()
|
|
|
|
artifactDigest := sha256.Sum256([]byte(readFileString(t, artifactPath)))
|
|
statement := internalprovenance.WrapInToto(
|
|
[]internalprovenance.Subject{{
|
|
Name: filepath.Base(artifactPath),
|
|
Digest: internalprovenance.DigestSet{
|
|
"sha256": hex.EncodeToString(artifactDigest[:]),
|
|
},
|
|
}},
|
|
internalprovenance.SLSAProvenanceV1PredicateType,
|
|
internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{
|
|
BuilderID: builderID,
|
|
ResolvedDependencies: []internalprovenance.ResolvedDependency{{URI: sourceURI}},
|
|
}),
|
|
)
|
|
payload, err := internalattestation.MarshalPayload(statement)
|
|
if err != nil {
|
|
t.Fatalf("marshal statement: %v", err)
|
|
}
|
|
|
|
signer, err := internalattestation.NewSigner(context.Background(), internalattestation.BackendConfig{Mode: internalattestation.SigningModeKey, SignerRef: privateKeyPath})
|
|
if err != nil {
|
|
t.Fatalf("create signer: %v", err)
|
|
}
|
|
|
|
signature, err := signer.Sign(context.Background(), internalattestation.InTotoPayloadType, payload)
|
|
if err != nil {
|
|
t.Fatalf("sign statement: %v", err)
|
|
}
|
|
|
|
attestationPath := filepath.Join(t.TempDir(), "attestation.json")
|
|
envelope, err := json.Marshal(internalattestation.NewEnvelope(internalattestation.InTotoPayloadType, payload, signature))
|
|
if err != nil {
|
|
t.Fatalf("marshal envelope: %v", err)
|
|
}
|
|
if err := os.WriteFile(attestationPath, envelope, 0600); err != nil {
|
|
t.Fatalf("write attestation: %v", err)
|
|
}
|
|
|
|
return attestationPath
|
|
}
|
|
|
|
func writeVerifierKeypair(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
|
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate private key: %v", err)
|
|
}
|
|
|
|
privateKeyDER, err := x509.MarshalECPrivateKey(privateKey)
|
|
if err != nil {
|
|
t.Fatalf("marshal private key: %v", err)
|
|
}
|
|
|
|
publicKeyDER, err := x509.MarshalPKIXPublicKey(privateKey.Public())
|
|
if err != nil {
|
|
t.Fatalf("marshal public key: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
privateKeyPath := filepath.Join(dir, "signer.pem")
|
|
publicKeyPath := filepath.Join(dir, "verifier.pem")
|
|
|
|
if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyDER}), 0600); err != nil {
|
|
t.Fatalf("write private key: %v", err)
|
|
}
|
|
if err := os.WriteFile(publicKeyPath, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicKeyDER}), 0600); err != nil {
|
|
t.Fatalf("write public key: %v", err)
|
|
}
|
|
|
|
return privateKeyPath, publicKeyPath
|
|
}
|
|
|
|
func readFileString(t *testing.T, path string) string {
|
|
t.Helper()
|
|
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read file %q: %v", path, err)
|
|
}
|
|
|
|
return string(contents)
|
|
}
|