FEAT(provenance): Add SLSA provenance attestation and verification (#13667)

* 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
This commit is contained in:
Tanmay Jain
2026-07-21 13:40:42 +05:30
committed by GitHub
parent 65f0fb3ff2
commit 014f8d1bbe
41 changed files with 6246 additions and 334 deletions
+1 -1
View File
@@ -1 +1 @@
1.25.11
1.26.3
+2
View File
@@ -25,6 +25,7 @@ import (
checksumpostprocessor "github.com/hashicorp/packer/post-processor/checksum"
compresspostprocessor "github.com/hashicorp/packer/post-processor/compress"
manifestpostprocessor "github.com/hashicorp/packer/post-processor/manifest"
provenancepostprocessor "github.com/hashicorp/packer/post-processor/provenance"
shelllocalpostprocessor "github.com/hashicorp/packer/post-processor/shell-local"
breakpointprovisioner "github.com/hashicorp/packer/provisioner/breakpoint"
fileprovisioner "github.com/hashicorp/packer/provisioner/file"
@@ -63,6 +64,7 @@ var PostProcessors = map[string]packersdk.PostProcessor{
"checksum": new(checksumpostprocessor.PostProcessor),
"compress": new(compresspostprocessor.PostProcessor),
"manifest": new(manifestpostprocessor.PostProcessor),
"provenance": new(provenancepostprocessor.PostProcessor),
"shell-local": new(shelllocalpostprocessor.PostProcessor),
}
+155
View File
@@ -0,0 +1,155 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"context"
"strings"
internalattestation "github.com/hashicorp/packer/internal/attestation"
"github.com/posener/complete"
)
type VerifyAttestationCommand struct {
Meta
}
type VerifyAttestationArgs struct {
AttestationPath string
SigningMode string
Key string
Verifier string
PredicateType string
BuilderID string
SourceURI string
ArtifactPath string
TrustedRootPath string
KeylessIdentity string
KeylessOIDCIssuer string
SigstoreBundlePath string
RequireTransparencyLog bool
RequireObserverTimestamp bool
}
func (c *VerifyAttestationCommand) Run(args []string) int {
ctx, cleanup := handleTermInterrupt(c.Ui)
defer cleanup()
cfg, ret := c.ParseArgs(args)
if ret != 0 {
return ret
}
return c.RunContext(ctx, cfg)
}
func (c *VerifyAttestationCommand) ParseArgs(args []string) (*VerifyAttestationArgs, int) {
var cfg VerifyAttestationArgs
flags := c.FlagSet("verify-attestation")
flags.Usage = func() { c.Ui.Say(c.Help()) }
flags.StringVar(&cfg.SigningMode, "signing-mode", "", "Signing mode used for the attestation")
flags.StringVar(&cfg.Key, "key", "", "PEM path or KMS/Vault URI")
flags.StringVar(&cfg.Verifier, "verifier", "", "PEM verifier path")
flags.StringVar(&cfg.PredicateType, "predicate-type", "", "Expected attestation predicate type")
flags.StringVar(&cfg.BuilderID, "builder-id", "", "Expected SLSA builder ID")
flags.StringVar(&cfg.SourceURI, "source-uri", "", "Expected resolved source URI")
flags.StringVar(&cfg.ArtifactPath, "artifact", "", "Artifact path to match against attestation subjects")
flags.StringVar(&cfg.TrustedRootPath, "trusted-root-path", "", "Optional Sigstore trusted-root JSON file")
flags.StringVar(&cfg.KeylessIdentity, "keyless-identity", "", "Expected keyless signing identity")
flags.StringVar(&cfg.KeylessOIDCIssuer, "keyless-oidc-issuer", "", "Expected keyless OIDC issuer")
flags.StringVar(&cfg.SigstoreBundlePath, "bundle", "", "Optional Sigstore bundle JSON file for Rekor or timestamp verification")
flags.BoolVar(&cfg.RequireTransparencyLog, "require-rekor", false, "Require Rekor transparency log verification using the Sigstore bundle")
flags.BoolVar(&cfg.RequireObserverTimestamp, "require-timestamp", false, "Require a trusted observer timestamp from Rekor integrated time or RFC3161 timestamp evidence")
if err := flags.Parse(args); err != nil {
return &cfg, 1
}
args = flags.Args()
if len(args) != 1 {
flags.Usage()
return &cfg, 1
}
cfg.AttestationPath = args[0]
return &cfg, 0
}
func (c *VerifyAttestationCommand) RunContext(ctx context.Context, cfg *VerifyAttestationArgs) int {
_, err := internalattestation.VerifyAttestationFile(ctx, cfg.AttestationPath, internalattestation.BackendConfig{
Mode: cfg.SigningMode,
SignerRef: cfg.Key,
VerifierRef: cfg.Verifier,
TrustedRootPath: cfg.TrustedRootPath,
KeylessIdentity: cfg.KeylessIdentity,
KeylessOIDCIssuer: cfg.KeylessOIDCIssuer,
}, internalattestation.VerificationPolicy{
PredicateType: cfg.PredicateType,
BuilderID: cfg.BuilderID,
SourceURI: cfg.SourceURI,
ArtifactPath: cfg.ArtifactPath,
SigstoreBundlePath: cfg.SigstoreBundlePath,
RequireTransparencyLog: cfg.RequireTransparencyLog,
RequireObserverTimestamp: cfg.RequireObserverTimestamp,
})
if err != nil {
c.Ui.Error(err.Error())
return 1
}
c.Ui.Say("Attestation verified.")
return 0
}
func (*VerifyAttestationCommand) Help() string {
helpText := `
Usage: packer verify-attestation [options] ATTESTATION
Verifies a signed DSSE attestation and optionally enforces policy checks
such as predicate type, builder identity, source URI, and subject digest.
Options:
-signing-mode=MODE Signing mode: key, kms, keyless. Auto-detected when possible.
-key=PATH_OR_URI PEM key path or KMS/Vault URI used for verification when no verifier is supplied.
-verifier=PATH PEM verifier path.
-predicate-type=TYPE Expected attestation predicate type.
-builder-id=ID Expected SLSA builder ID.
-source-uri=URI Expected resolved source URI.
-artifact=PATH Artifact path to match against attestation subjects.
-trusted-root-path=PATH Optional Sigstore trusted-root JSON for keyless verification.
-keyless-identity=IDENTITY Expected keyless signing identity.
-keyless-oidc-issuer=ISSUER Expected keyless OIDC issuer.
-bundle=PATH Optional Sigstore bundle JSON for Rekor or timestamp verification.
-require-rekor Require Rekor transparency log verification from the bundle.
-require-timestamp Require a trusted observer timestamp from Rekor integrated time or RFC3161 evidence.
`
return strings.TrimSpace(helpText)
}
func (*VerifyAttestationCommand) Synopsis() string {
return "verify a signed attestation against policy"
}
func (*VerifyAttestationCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (*VerifyAttestationCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
"-signing-mode": complete.PredictNothing,
"-key": complete.PredictNothing,
"-verifier": complete.PredictNothing,
"-predicate-type": complete.PredictNothing,
"-builder-id": complete.PredictNothing,
"-source-uri": complete.PredictNothing,
"-artifact": complete.PredictNothing,
"-trusted-root-path": complete.PredictNothing,
"-keyless-identity": complete.PredictNothing,
"-keyless-oidc-issuer": complete.PredictNothing,
"-bundle": complete.PredictNothing,
"-require-rekor": complete.PredictNothing,
"-require-timestamp": complete.PredictNothing,
}
}
+244
View File
@@ -0,0 +1,244 @@
// 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)
}
+4
View File
@@ -112,6 +112,10 @@ func init() {
return &command.SBOMGenerateCommand{Meta: *CommandMeta}, nil
},
"verify-attestation": func() (cli.Command, error) {
return &command.VerifyAttestationCommand{Meta: *CommandMeta}, nil
},
// plugin is essentially an alias to the plugins command
//
// It is not meant to be documented or used outside of simple
+34
View File
@@ -0,0 +1,34 @@
# Provenance CI reference workflows
These are copy-paste reference workflows for producing SLSA provenance with the
Packer `provenance` post-processor. They are **not** wired into this
repository's own CI — copy them into your project's `.github/workflows/`
directory and adapt the template and artifact paths.
## SLSA levels and Packer
SLSA Build levels are mostly properties of the build **platform**, not the
build **tool**. Packer is a tool, so its reach is:
| SLSA Build level | Requirement | Packer's role | What Packer provides |
|---|---|---|---|
| **L1** | Provenance exists and is distributed | Fully in Packer | Provenance generation |
| **L2** | Provenance signed by a hosted platform | Packer signs via CI OIDC identity | Keyless signing in CI |
| **L3** | Hardened platform; build steps cannot reach the signing key | Platform property; Packer is compatible | Delegated-signing pattern |
| **L4** | — | Not defined in SLSA v1.0 | — |
Packer generates SLSA Provenance v1 and reaches Build L1, and L2 when run on a
hosted CI with keyless signing. L3 is a property of the build platform: it
requires the signing key to be unreachable by the build steps. Packer does not
confer L3 on its own, but the delegated-signing pattern below is compatible with
an L3 platform.
## Workflows
- [`github-actions-l2-keyless.yml`](github-actions-l2-keyless.yml) — L2: Packer
signs provenance keyless using the workflow's OIDC identity, uploads to Rekor,
and verifies the signed attestation with `packer verify-attestation`.
- [`github-actions-l3-delegated.yml`](github-actions-l3-delegated.yml) —
L3-compatible: the build job only builds and publishes a digest; provenance
generation and signing are delegated to an isolated reusable workflow so the
build steps cannot reach the signing material.
+93
View File
@@ -0,0 +1,93 @@
# Reference workflow — NOT wired into this repository's CI.
#
# Copy this into your own project's .github/workflows/ directory.
#
# SLSA Build L2 pattern: Packer signs the provenance attestation keyless, using
# the GitHub Actions workflow's own OIDC identity (Fulcio) and records the
# signature in the Rekor transparency log. This yields signed, transparently
# logged provenance produced by a hosted platform.
#
# This does NOT confer SLSA L3 on its own: the build job can still reach the
# (ephemeral) signing material. See github-actions-l3-delegated.yml for the
# L3-compatible delegated-signing pattern.
name: build-and-sign-provenance
on:
push:
tags:
- "v*"
permissions:
contents: read
# Required so Packer's keyless signing can request an OIDC token from GitHub
# and exchange it with Fulcio for a short-lived signing certificate.
id-token: write
jobs:
build:
runs-on: ubuntu-latest
env:
# Must match the workflow's OIDC identity so verification can pin it.
KEYLESS_IDENTITY: "https://github.com/${{ github.repository }}/.github/workflows/build-and-sign-provenance.yml@${{ github.ref }}"
KEYLESS_OIDC_ISSUER: "https://token.actions.githubusercontent.com"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Packer
uses: hashicorp/setup-packer@main
with:
version: latest
- name: Initialize plugins
run: packer init .
# The template below is expected to declare two input variables and a
# keyless provenance post-processor. NOTE: Packer's `env()` function may
# only appear in a variable `default`, never inline in a block, so the
# workflow passes the values with `-var` instead.
#
# variable "keyless_identity" { type = string }
# variable "keyless_oidc_issuer" { type = string }
#
# post-processor "provenance" {
# signing_mode = "keyless"
# upload_tlog = true
# keyless_identity = var.keyless_identity
# keyless_oidc_issuer = var.keyless_oidc_issuer
# }
#
# Packer picks up the ambient GitHub OIDC token automatically from the
# ACTIONS_ID_TOKEN_REQUEST_URL / ACTIONS_ID_TOKEN_REQUEST_TOKEN variables
# that are available when `id-token: write` is granted above.
- name: Build and sign
run: |
packer build \
-var "keyless_identity=${KEYLESS_IDENTITY}" \
-var "keyless_oidc_issuer=${KEYLESS_OIDC_ISSUER}" \
.
# Verify the signed attestation with Rekor-backed transparency evidence
# using the generated Sigstore bundle sidecar (*.sigstore.json).
- name: Verify attestation
run: |
for att in *.provenance.json; do
packer verify-attestation \
-signing-mode=keyless \
-bundle="${att%.json}.sigstore.json" \
-require-rekor \
-require-timestamp \
-keyless-identity="${KEYLESS_IDENTITY}" \
-keyless-oidc-issuer="${KEYLESS_OIDC_ISSUER}" \
-predicate-type="https://slsa.dev/provenance/v1" \
"${att}"
done
- name: Upload provenance artifacts
uses: actions/upload-artifact@v4
with:
name: provenance
path: |
*.provenance.json
*.sigstore.json
@@ -0,0 +1,64 @@
# Reference workflow — NOT wired into this repository's CI.
#
# Copy this into your own project's .github/workflows/ directory.
#
# SLSA Build L3-compatible pattern: the build job ONLY builds the artifact and
# publishes its digest. Provenance generation and signing are delegated to an
# isolated, reusable workflow (the SLSA GitHub generator) that runs in a
# separate job the build steps cannot influence or reach. This keeps the
# signing material unreachable from the build, which is what L3 requires.
#
# Packer itself does not confer L3. It participates by producing the artifact;
# the platform (isolated signer) provides the L3 property.
name: build-and-delegate-provenance
on:
push:
tags:
- "v*"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.hash.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Packer
uses: hashicorp/setup-packer@main
with:
version: latest
- name: Initialize plugins
run: packer init .
# Build only. Do NOT sign here: signing in the build job would defeat the
# isolation that the delegated signer provides.
- name: Build artifact
run: packer build .
# Emit a base64-encoded subject digest for the delegated generator.
- name: Compute artifact digest
id: hash
run: |
# Adjust the artifact path to match your build output.
echo "digest=$(sha256sum output/image.qcow2 | base64 -w0)" >> "${GITHUB_OUTPUT}"
# Isolated, reusable workflow that generates and signs SLSA provenance in a
# job the build cannot reach. This is the component that provides the L3
# property; pin it to a released tag in real usage.
provenance:
needs: [build]
permissions:
actions: read
id-token: write
contents: write
uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
with:
base64-subjects: ${{ needs.build.outputs.digest }}
+140 -106
View File
@@ -7,7 +7,7 @@ require (
github.com/chzyer/readline v1.5.1
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707
github.com/go-git/go-git/v5 v5.19.1
github.com/go-openapi/runtime v0.28.0
github.com/go-openapi/runtime v0.32.3
github.com/gobwas/glob v0.2.3
github.com/gofrs/flock v0.8.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
@@ -24,7 +24,7 @@ require (
github.com/hashicorp/hcp-sdk-go v0.174.0
github.com/hashicorp/packer-plugin-sdk v0.6.10
github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869
github.com/klauspost/compress v1.18.5
github.com/klauspost/compress v1.18.6
github.com/klauspost/pgzip v1.2.6
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -57,37 +57,51 @@ require (
github.com/CycloneDX/cyclonedx-go v0.11.0
github.com/Masterminds/semver/v3 v3.4.0
github.com/anchore/syft v1.42.3
github.com/go-openapi/strfmt v0.23.0
github.com/go-openapi/strfmt v0.26.3
github.com/google/go-github/v75 v75.0.0
github.com/oklog/ulid v1.3.1
github.com/pierrec/lz4/v4 v4.1.22
github.com/shirou/gopsutil/v3 v3.23.4
github.com/sigstore/protobuf-specs v0.5.1
github.com/sigstore/sigstore v1.10.8
github.com/sigstore/sigstore-go v1.2.1
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8
github.com/spdx/tools-golang v0.5.7
google.golang.org/grpc v1.79.3
google.golang.org/grpc v1.81.1
modernc.org/sqlite v1.46.1
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
cloud.google.com/go/storage v1.61.3 // indirect
cyphar.com/go-pathrs v0.2.1 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/kms v1.31.0 // indirect
cloud.google.com/go/longrunning v1.0.0 // indirect
cloud.google.com/go/monitoring v1.25.0 // indirect
cloud.google.com/go/storage v1.62.2 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect
github.com/DataDog/zstd v1.5.5 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.14.1 // indirect
github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect
github.com/Microsoft/hcsshim v0.15.0-rc.1 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
@@ -116,40 +130,42 @@ require (
github.com/armon/go-metrics v0.4.1 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.45.6 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect
github.com/aws/aws-sdk-go v1.55.5 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.37.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/becheran/wildmatch-go v1.0.0 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/bgentry/speakeasy v0.2.0 // indirect
github.com/bitnami/go-version v0.0.0-20250131085805-b1f57a8634ef // indirect
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v3 v3.2.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
@@ -159,36 +175,38 @@ require (
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/containerd/cgroups/v3 v3.1.2 // indirect
github.com/containerd/containerd/api v1.10.0 // indirect
github.com/containerd/containerd/v2 v2.2.5 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/containerd/cgroups/v3 v3.1.3 // indirect
github.com/containerd/containerd/api v1.11.1 // indirect
github.com/containerd/containerd/v2 v2.3.3 // indirect
github.com/containerd/continuity v0.5.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/fifo v1.1.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v1.0.0-rc.2 // indirect
github.com/containerd/plugin v1.0.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
github.com/containerd/ttrpc v1.2.7 // indirect
github.com/containerd/platforms v1.0.0-rc.4 // indirect
github.com/containerd/plugin v1.1.0 // indirect
github.com/containerd/ttrpc v1.2.8 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da // indirect
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.3.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/cli v29.4.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dylanmei/iso8601 v0.1.0 // indirect
github.com/elliotchance/phpserialize v1.4.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/facebookincubator/nvdtools v0.1.5 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
@@ -202,40 +220,44 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/errors v0.22.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/loads v0.22.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.24.1 // indirect
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
github.com/go-openapi/swag/conv v0.24.0 // indirect
github.com/go-openapi/swag/fileutils v0.24.0 // indirect
github.com/go-openapi/swag/jsonname v0.24.0 // indirect
github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
github.com/go-openapi/swag/loading v0.24.0 // indirect
github.com/go-openapi/swag/mangling v0.24.0 // indirect
github.com/go-openapi/swag/netutils v0.24.0 // indirect
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
github.com/go-openapi/validate v0.24.0 // indirect
github.com/go-openapi/analysis v0.25.2 // indirect
github.com/go-openapi/errors v0.22.7 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.6 // indirect
github.com/go-openapi/loads v0.23.3 // indirect
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
github.com/go-openapi/spec v0.22.5 // indirect
github.com/go-openapi/swag v0.26.0 // indirect
github.com/go-openapi/swag/cmdutils v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/fileutils v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/mangling v0.26.0 // indirect
github.com/go-openapi/swag/netutils v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-openapi/validate v0.25.3 // indirect
github.com/go-restruct/restruct v1.2.0-alpha // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/google/go-containerregistry v0.21.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/certificate-transparency-go v1.3.3 // indirect
github.com/google/go-containerregistry v0.21.6 // indirect
github.com/google/licensecheck v0.3.1 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/gpustack/gguf-parser-go v0.24.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72 // indirect
github.com/hashicorp/consul/api v1.25.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -245,21 +267,23 @@ require (
github.com/hashicorp/go-getter/s3/v2 v2.2.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-safetemp v1.0.0 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/hashicorp/serf v0.10.1 // indirect
github.com/hashicorp/vault/api v1.14.0 // indirect
github.com/hashicorp/vault/api v1.22.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/henvic/httpretty v0.1.4 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/in-toto/attestation v1.2.0 // indirect
github.com/in-toto/in-toto-golang v0.11.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
@@ -268,17 +292,18 @@ require (
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect
github.com/jellydator/ttlcache/v3 v3.4.0 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -294,8 +319,8 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/moby/api v1.54.0 // indirect
github.com/moby/moby/client v0.3.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
@@ -304,10 +329,12 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.6 // indirect
@@ -315,12 +342,11 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runtime-spec v1.3.0 // indirect
github.com/opencontainers/selinux v1.13.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pborman/indent v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/profile v1.7.0 // indirect
github.com/pkg/xattr v0.4.9 // indirect
@@ -331,36 +357,45 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/sassoftware/go-rpmutils v0.4.0 // indirect
github.com/sassoftware/relic v7.2.1+incompatible // indirect
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect
github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/shoenig/go-m1cpu v0.1.5 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sigstore/rekor v1.5.2 // indirect
github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect
github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.20.0 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/sylabs/sif/v2 v2.24.0 // indirect
github.com/sylabs/squashfs v1.0.6 // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/theupdateframework/go-tuf v0.7.0 // indirect
github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect
github.com/tklauser/go-sysconf v0.3.11 // indirect
github.com/tklauser/numcpus v0.6.0 // indirect
github.com/transparency-dev/formats v0.1.1 // indirect
github.com/transparency-dev/merkle v0.0.2 // indirect
github.com/ugorji/go/codec v1.2.6 // indirect
github.com/vbatts/go-mtree v0.7.0 // indirect
github.com/vbatts/tar-split v0.12.2 // indirect
github.com/vifraa/gopom v1.0.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
@@ -370,39 +405,38 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.2 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
gonum.org/v1/gonum v0.16.0 // indirect
google.golang.org/api v0.271.0 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gonum.org/v1/gonum v0.17.0 // indirect
google.golang.org/api v0.280.0 // indirect
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
go 1.25.11
go 1.26.3
replace github.com/zclconf/go-cty => github.com/nywilken/go-cty v1.13.3 // added by packer-sdc fix as noted in github.com/hashicorp/packer-plugin-sdk/issues/187
+368 -227
View File
File diff suppressed because it is too large Load Diff
@@ -76,6 +76,7 @@ func (cfg *PackerConfig) startPostProcessor(source SourceUseBlock, pp *PostProce
builderVars["packer_debug"] = strconv.FormatBool(cfg.debug)
builderVars["packer_force"] = strconv.FormatBool(cfg.force)
builderVars["packer_on_error"] = cfg.onError
builderVars["packer_sensitive_variables"] = cfg.sensitiveInputVariableKeys()
hclPostProcessor := &HCL2PostProcessor{
PostProcessor: postProcessor,
+22
View File
@@ -0,0 +1,22 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"context"
"fmt"
)
type bundleSigner interface {
SignBundle(ctx context.Context, payloadType string, payload []byte, cfg BackendConfig) (Envelope, []byte, error)
}
func BuildBundleForSigner(ctx context.Context, signer Signer, cfg BackendConfig, payloadType string, payload []byte) (Envelope, []byte, error) {
bundler, ok := signer.(bundleSigner)
if !ok {
return Envelope{}, nil, fmt.Errorf("signing_mode %q does not support Sigstore bundle emission", cfg.Mode)
}
return bundler.SignBundle(ctx, payloadType, payload, cfg)
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"encoding/base64"
"encoding/json"
"fmt"
)
const InTotoPayloadType = "application/vnd.in-toto+json"
type Envelope struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []EnvelopeSignature `json:"signatures"`
}
type EnvelopeSignature struct {
KeyID string `json:"keyid,omitempty"`
Sig string `json:"sig"`
Cert string `json:"cert,omitempty"`
}
func MarshalPayload(value interface{}) ([]byte, error) {
return json.Marshal(value)
}
func PreAuthEncode(payloadType string, payload []byte) []byte {
return []byte(fmt.Sprintf("DSSEv1 %d %s %d %s", len(payloadType), payloadType, len(payload), payload))
}
func NewEnvelope(payloadType string, payload []byte, signature Signature) Envelope {
envelope := Envelope{
PayloadType: payloadType,
Payload: base64.StdEncoding.EncodeToString(payload),
Signatures: []EnvelopeSignature{{
KeyID: signature.KeyID,
Sig: base64.StdEncoding.EncodeToString(signature.Sig),
}},
}
if len(signature.CertPEM) > 0 {
envelope.Signatures[0].Cert = string(signature.CertPEM)
}
return envelope
}
func DecodeEnvelopePayload(envelope Envelope) ([]byte, error) {
decoded, err := base64.StdEncoding.DecodeString(envelope.Payload)
if err != nil {
return nil, fmt.Errorf("decode envelope payload: %w", err)
}
return decoded, nil
}
func DecodeEnvelopeSignature(signature EnvelopeSignature) ([]byte, error) {
decoded, err := base64.StdEncoding.DecodeString(signature.Sig)
if err != nil {
return nil, fmt.Errorf("decode envelope signature: %w", err)
}
return decoded, nil
}
+238
View File
@@ -0,0 +1,238 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"os"
)
func init() {
RegisterSigner(SigningModeKey, newPEMSigner)
}
type pemSigner struct {
signer crypto.Signer
verifier *pemVerifier
}
type pemVerifier struct {
publicKey crypto.PublicKey
keyID string
}
func newPEMSigner(_ context.Context, cfg BackendConfig) (Signer, error) {
if cfg.SignerRef == "" {
return nil, fmt.Errorf("signing_mode %q requires signer", SigningModeKey)
}
signer, verifier, err := loadPEMSigner(cfg.SignerRef)
if err != nil {
return nil, err
}
return &pemSigner{signer: signer, verifier: verifier}, nil
}
func (s *pemSigner) Sign(_ context.Context, payloadType string, payload []byte) (Signature, error) {
pae := PreAuthEncode(payloadType, payload)
var message []byte
var opts crypto.SignerOpts
if _, ok := s.signer.Public().(ed25519.PublicKey); ok {
message = pae
opts = crypto.Hash(0)
} else {
digest := sha256.Sum256(pae)
message = digest[:]
opts = crypto.SHA256
}
signature, err := s.signer.Sign(rand.Reader, message, opts)
if err != nil {
return Signature{}, fmt.Errorf("sign payload: %w", err)
}
return Signature{
KeyID: s.verifier.KeyID(),
Sig: signature,
}, nil
}
func (s *pemSigner) Verifier(context.Context, BackendConfig) (Verifier, error) {
return s.verifier, nil
}
func (v *pemVerifier) Verify(_ context.Context, payloadType string, payload, signature []byte) error {
pae := PreAuthEncode(payloadType, payload)
switch publicKey := v.publicKey.(type) {
case *rsa.PublicKey:
digest := sha256.Sum256(pae)
return rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature)
case *ecdsa.PublicKey:
digest := sha256.Sum256(pae)
if !ecdsa.VerifyASN1(publicKey, digest[:], signature) {
return fmt.Errorf("ECDSA verification failed")
}
return nil
case ed25519.PublicKey:
if !ed25519.Verify(publicKey, pae, signature) {
return fmt.Errorf("Ed25519 verification failed")
}
return nil
default:
return fmt.Errorf("unsupported public key type %T", v.publicKey)
}
}
func (v *pemVerifier) KeyID() string {
return v.keyID
}
func LoadPEMVerifier(path string) (Verifier, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read verifier %q: %w", path, err)
}
publicKey, rawVerifier, err := loadPEMPublicKey(contents)
if err != nil {
return nil, fmt.Errorf("load verifier %q: %w", path, err)
}
return &pemVerifier{
publicKey: publicKey,
keyID: sha256Hex(rawVerifier),
}, nil
}
func LoadPEMVerifierBytes(contents []byte) (*pemVerifier, error) {
publicKey, rawVerifier, err := loadPEMPublicKey(contents)
if err != nil {
return nil, err
}
return &pemVerifier{
publicKey: publicKey,
keyID: sha256Hex(rawVerifier),
}, nil
}
func loadPEMSigner(path string) (crypto.Signer, *pemVerifier, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("read signer %q: %w", path, err)
}
block, _ := pem.Decode(contents)
if block == nil {
return nil, nil, fmt.Errorf("decode signer %q: no PEM block found", path)
}
var signer crypto.Signer
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
var ok bool
signer, ok = key.(crypto.Signer)
if !ok {
return nil, nil, fmt.Errorf("signer %q does not implement crypto.Signer", path)
}
} else if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
signer = key
} else if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
signer = key
} else {
return nil, nil, fmt.Errorf("unsupported private key in signer %q", path)
}
publicKeyPEM, err := marshalPublicKeyPEM(signer.Public())
if err != nil {
return nil, nil, err
}
verifier, err := LoadPEMVerifierBytes(publicKeyPEM)
if err != nil {
return nil, nil, err
}
return signer, verifier, nil
}
func loadPEMPublicKey(contents []byte) (crypto.PublicKey, []byte, error) {
block, _ := pem.Decode(contents)
if block == nil {
return nil, nil, fmt.Errorf("no PEM block found")
}
if publicKey, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
return publicKey, pem.EncodeToMemory(block), nil
}
if certificate, err := x509.ParseCertificate(block.Bytes); err == nil {
return certificate.PublicKey, pem.EncodeToMemory(block), nil
}
if privateKey, verifier, err := loadPEMPrivateKeyAsPublic(contents); err == nil {
return privateKey, verifier, nil
}
return nil, nil, fmt.Errorf("unsupported PEM verifier data")
}
func loadPEMPrivateKeyAsPublic(contents []byte) (crypto.PublicKey, []byte, error) {
block, _ := pem.Decode(contents)
if block == nil {
return nil, nil, fmt.Errorf("no PEM block found")
}
var signer crypto.Signer
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
var ok bool
signer, ok = key.(crypto.Signer)
if !ok {
return nil, nil, fmt.Errorf("private key does not implement crypto.Signer")
}
} else if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
signer = key
} else if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
signer = key
} else {
return nil, nil, fmt.Errorf("unsupported private key data")
}
publicKeyPEM, err := marshalPublicKeyPEM(signer.Public())
if err != nil {
return nil, nil, err
}
publicKey, _, err := loadPEMPublicKey(publicKeyPEM)
if err != nil {
return nil, nil, err
}
return publicKey, publicKeyPEM, nil
}
func marshalPublicKeyPEM(publicKey crypto.PublicKey) ([]byte, error) {
encoded, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("marshal public key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: encoded}), nil
}
func sha256Hex(value []byte) string {
digest := sha256.Sum256(value)
return hex.EncodeToString(digest[:])
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
)
func TestPEMSignerAndVerifier(t *testing.T) {
privateKeyPath, publicKeyPath := writeECDSAKeypair(t)
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKey,
SignerRef: privateKeyPath,
})
if err != nil {
t.Fatalf("create signer: %v", err)
}
verifier, err := NewVerifier(context.Background(), BackendConfig{
Mode: SigningModeKey,
VerifierRef: publicKeyPath,
}, signer)
if err != nil {
t.Fatalf("create verifier: %v", err)
}
payload := []byte(`{"hello":"world"}`)
signature, err := signer.Sign(context.Background(), InTotoPayloadType, payload)
if err != nil {
t.Fatalf("sign payload: %v", err)
}
envelope := NewEnvelope(InTotoPayloadType, payload, signature)
if err := VerifyEnvelope(context.Background(), envelope, verifier); err != nil {
t.Fatalf("verify envelope: %v", err)
}
}
func TestVerifierOverrideMismatchFails(t *testing.T) {
privateKeyPath, _ := writeECDSAKeypair(t)
_, mismatchedPublicKeyPath := writeECDSAKeypair(t)
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKey,
SignerRef: privateKeyPath,
})
if err != nil {
t.Fatalf("create signer: %v", err)
}
verifier, err := NewVerifier(context.Background(), BackendConfig{
Mode: SigningModeKey,
VerifierRef: mismatchedPublicKeyPath,
}, signer)
if err != nil {
t.Fatalf("create verifier: %v", err)
}
signature, err := signer.Sign(context.Background(), InTotoPayloadType, []byte(`{"hello":"world"}`))
if err != nil {
t.Fatalf("sign payload: %v", err)
}
envelope := NewEnvelope(InTotoPayloadType, []byte(`{"hello":"world"}`), signature)
if err := VerifyEnvelope(context.Background(), envelope, verifier); err == nil {
t.Fatalf("expected verifier mismatch to fail")
}
}
func writeECDSAKeypair(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
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/url"
"strings"
"time"
sigstorebundle "github.com/sigstore/sigstore-go/pkg/bundle"
fulciocertificate "github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
sigstoreroot "github.com/sigstore/sigstore-go/pkg/root"
sigstoregosign "github.com/sigstore/sigstore-go/pkg/sign"
sigstoreverify "github.com/sigstore/sigstore-go/pkg/verify"
)
const defaultFulcioURL = "https://fulcio.sigstore.dev"
const defaultRekorURL = "https://rekor.sigstore.dev"
var newKeylessEphemeralKeypair = func() (sigstoregosign.Keypair, error) {
return sigstoregosign.NewEphemeralKeypair(nil)
}
var newKeylessFulcio = func(baseURL string) sigstoregosign.CertificateProvider {
return sigstoregosign.NewFulcio(&sigstoregosign.FulcioOptions{BaseURL: baseURL})
}
var newKeylessBundle = sigstoregosign.Bundle
var newKeylessRekor = func(baseURL string) sigstoregosign.Transparency {
return sigstoregosign.NewRekor(&sigstoregosign.RekorOptions{BaseURL: baseURL})
}
var loadKeylessTrustedMaterial = func(cfg BackendConfig) (sigstoreroot.TrustedMaterial, error) {
trustedRootPath := strings.TrimSpace(cfg.TrustedRootPath)
if trustedRootPath == "" {
return sigstoreroot.FetchTrustedRoot()
}
return sigstoreroot.NewTrustedRootFromPath(trustedRootPath)
}
var verifyKeylessCertificate = func(certificate *x509.Certificate, trustedMaterial sigstoreroot.TrustedMaterial, expectedIdentity, expectedOIDCIssuer, trustedRootPath string) error {
chains, err := sigstoreverify.VerifyLeafCertificate(time.Now().UTC(), certificate, trustedMaterial)
if err != nil {
return fmt.Errorf("verify Fulcio certificate chain: %w", err)
}
// When using the public Sigstore root (no custom trusted root configured),
// require a valid SCT so certificates issued outside a public CT log are rejected.
if strings.TrimSpace(trustedRootPath) == "" {
if err := sigstoreverify.VerifySignedCertificateTimestamp(chains, 1, trustedMaterial); err != nil {
return fmt.Errorf("verify Fulcio certificate SCT: %w", err)
}
}
summary, err := fulciocertificate.SummarizeCertificate(certificate)
if err != nil {
return fmt.Errorf("summarize Fulcio certificate: %w", err)
}
identity, err := sigstoreverify.NewShortCertificateIdentity(expectedOIDCIssuer, "", expectedIdentity, "")
if err != nil {
return fmt.Errorf("build keyless identity policy: %w", err)
}
if err := identity.Verify(summary); err != nil {
return fmt.Errorf("verify keyless certificate identity: %w", err)
}
return nil
}
func init() {
RegisterSigner(SigningModeKeyless, newKeylessSigner)
}
type keylessSigner struct {
keypair sigstoregosign.Keypair
certPEM []byte
cert *x509.Certificate
verifier Verifier
keyID string
}
func newKeylessSigner(ctx context.Context, cfg BackendConfig) (Signer, error) {
fulcioURL := strings.TrimSpace(cfg.FulcioURL)
if fulcioURL == "" {
fulcioURL = defaultFulcioURL
}
idToken, err := resolveAmbientIDToken(ctx, cfg.Env)
if err != nil {
return nil, err
}
keypair, err := newKeylessEphemeralKeypair()
if err != nil {
return nil, fmt.Errorf("generate ephemeral keypair: %w", err)
}
fulcio := newKeylessFulcio(fulcioURL)
certDER, err := fulcio.GetCertificate(ctx, keypair, &sigstoregosign.CertificateProviderOptions{IDToken: idToken})
if err != nil {
return nil, fmt.Errorf("request Fulcio certificate: %w", err)
}
certificate, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("parse Fulcio certificate: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
verifier, err := newSigstoreVerifierFromPublicKey(certificate.PublicKey)
if err != nil {
return nil, fmt.Errorf("create keyless verifier: %w", err)
}
return &keylessSigner{
keypair: keypair,
certPEM: certPEM,
cert: certificate,
verifier: verifier,
keyID: hex.EncodeToString(keypair.GetHint()),
}, nil
}
func (s *keylessSigner) Sign(ctx context.Context, payloadType string, payload []byte) (Signature, error) {
signature, _, err := s.keypair.SignData(ctx, PreAuthEncode(payloadType, payload))
if err != nil {
return Signature{}, fmt.Errorf("sign payload with keyless signer: %w", err)
}
return Signature{
KeyID: s.keyID,
Sig: signature,
CertPEM: append([]byte(nil), s.certPEM...),
}, nil
}
func (s *keylessSigner) SignBundle(ctx context.Context, payloadType string, payload []byte, cfg BackendConfig) (Envelope, []byte, error) {
content := &sigstoregosign.DSSEData{Data: payload, PayloadType: payloadType}
options := sigstoregosign.BundleOptions{
CertificateProvider: staticCertificateProvider{certDER: append([]byte(nil), s.cert.Raw...)},
Context: ctx,
}
if cfg.UploadTlog {
rekorURL := strings.TrimSpace(cfg.RekorURL)
if rekorURL == "" {
rekorURL = defaultRekorURL
}
trustedMaterial, err := loadKeylessTrustedMaterial(cfg)
if err != nil {
return Envelope{}, nil, fmt.Errorf("load keyless trusted root: %w", err)
}
options.TransparencyLogs = []sigstoregosign.Transparency{newKeylessRekor(rekorURL)}
options.TrustedRoot = trustedMaterial
}
protobufBundle, err := newKeylessBundle(content, s.keypair, options)
if err != nil {
return Envelope{}, nil, fmt.Errorf("build Sigstore bundle: %w", err)
}
bundleWrapper, err := sigstorebundle.NewBundle(protobufBundle)
if err != nil {
return Envelope{}, nil, fmt.Errorf("decode Sigstore bundle: %w", err)
}
bundleEnvelope, err := bundleWrapper.Envelope()
if err != nil {
return Envelope{}, nil, fmt.Errorf("extract envelope from Sigstore bundle: %w", err)
}
rawEnvelope := bundleEnvelope.RawEnvelope()
if rawEnvelope == nil {
return Envelope{}, nil, fmt.Errorf("sigstore bundle does not contain a DSSE envelope")
}
bundleJSON, err := bundleWrapper.MarshalJSON()
if err != nil {
return Envelope{}, nil, fmt.Errorf("marshal Sigstore bundle: %w", err)
}
envelope := Envelope{
PayloadType: rawEnvelope.PayloadType,
Payload: rawEnvelope.Payload,
Signatures: []EnvelopeSignature{{
KeyID: s.keyID,
Sig: base64.StdEncoding.EncodeToString(bundleEnvelope.Signature()),
Cert: string(s.certPEM),
}},
}
return envelope, bundleJSON, nil
}
func (s *keylessSigner) Verifier(ctx context.Context, cfg BackendConfig) (Verifier, error) {
return newKeylessVerifier(cfg, s.cert)
}
func newKeylessVerifierForEnvelope(cfg BackendConfig, envelope Envelope) (Verifier, error) {
certificate, err := certificateFromEnvelope(envelope)
if err != nil {
return nil, err
}
return newKeylessVerifier(cfg, certificate)
}
func newKeylessVerifier(cfg BackendConfig, certificate *x509.Certificate) (Verifier, error) {
if strings.TrimSpace(cfg.KeylessIdentity) == "" || strings.TrimSpace(cfg.KeylessOIDCIssuer) == "" {
return nil, fmt.Errorf("signing_mode %q requires keyless_identity and keyless_oidc_issuer unless verifier is explicitly configured", SigningModeKeyless)
}
trustedMaterial, err := loadKeylessTrustedMaterial(cfg)
if err != nil {
return nil, fmt.Errorf("load keyless trusted root: %w", err)
}
signatureVerifier, err := newSigstoreVerifierFromPublicKey(certificate.PublicKey)
if err != nil {
return nil, fmt.Errorf("create keyless verifier: %w", err)
}
return &keylessVerifier{
certificate: certificate,
signatureVerifier: signatureVerifier,
trustedMaterial: trustedMaterial,
identity: cfg.KeylessIdentity,
oidcIssuer: cfg.KeylessOIDCIssuer,
trustedRootPath: cfg.TrustedRootPath,
}, nil
}
type keylessVerifier struct {
certificate *x509.Certificate
signatureVerifier Verifier
trustedMaterial sigstoreroot.TrustedMaterial
identity string
oidcIssuer string
trustedRootPath string
}
func (v *keylessVerifier) Verify(ctx context.Context, payloadType string, payload, signature []byte) error {
if err := verifyKeylessCertificate(v.certificate, v.trustedMaterial, v.identity, v.oidcIssuer, v.trustedRootPath); err != nil {
return err
}
return v.signatureVerifier.Verify(ctx, payloadType, payload, signature)
}
func (v *keylessVerifier) KeyID() string {
return v.signatureVerifier.KeyID()
}
func certificateFromEnvelope(envelope Envelope) (*x509.Certificate, error) {
for _, signature := range envelope.Signatures {
if strings.TrimSpace(signature.Cert) == "" {
continue
}
block, _ := pem.Decode([]byte(signature.Cert))
if block == nil {
return nil, fmt.Errorf("decode keyless certificate: no PEM block found")
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse keyless certificate: %w", err)
}
return certificate, nil
}
return nil, fmt.Errorf("keyless attestation does not contain a signing certificate")
}
type staticCertificateProvider struct {
certDER []byte
}
func (p staticCertificateProvider) GetCertificate(context.Context, sigstoregosign.Keypair, *sigstoregosign.CertificateProviderOptions) ([]byte, error) {
if len(p.certDER) == 0 {
return nil, fmt.Errorf("static certificate provider is missing a certificate")
}
return append([]byte(nil), p.certDER...), nil
}
func resolveAmbientIDToken(ctx context.Context, env map[string]string) (string, error) {
if token := strings.TrimSpace(env["SIGSTORE_ID_TOKEN"]); token != "" {
return token, nil
}
if token := strings.TrimSpace(env["CI_JOB_JWT_V2"]); token != "" {
return token, nil
}
if token := strings.TrimSpace(env["CI_JOB_JWT"]); token != "" {
return token, nil
}
if token, err := resolveGitHubActionsIDToken(ctx, env); err == nil && token != "" {
return token, nil
}
return "", fmt.Errorf("signing_mode %q requires an ambient OIDC token; set SIGSTORE_ID_TOKEN, use a CI-provided OIDC token, or switch to signing_mode=\"none\" or a key-backed mode", SigningModeKeyless)
}
func resolveGitHubActionsIDToken(ctx context.Context, env map[string]string) (string, error) {
requestURL := strings.TrimSpace(env["ACTIONS_ID_TOKEN_REQUEST_URL"])
requestToken := strings.TrimSpace(env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"])
if requestURL == "" || requestToken == "" {
return "", nil
}
parsedURL, err := url.Parse(requestURL)
if err != nil {
return "", fmt.Errorf("parse GitHub OIDC request URL: %w", err)
}
query := parsedURL.Query()
if query.Get("audience") == "" {
query.Set("audience", "sigstore")
parsedURL.RawQuery = query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
if err != nil {
return "", fmt.Errorf("create GitHub OIDC request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+requestToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request GitHub OIDC token: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("request GitHub OIDC token: unexpected status %s", resp.Status)
}
var payload struct {
Value string `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("decode GitHub OIDC token response: %w", err)
}
return strings.TrimSpace(payload.Value), nil
}
+140
View File
@@ -0,0 +1,140 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"bytes"
"context"
"crypto"
"errors"
"fmt"
"strings"
sigstoresignature "github.com/sigstore/sigstore/pkg/signature"
sigstorekms "github.com/sigstore/sigstore/pkg/signature/kms"
)
var newKMSSignerVerifier = func(ctx context.Context, keyResourceID string) (sigstorekms.SignerVerifier, error) {
return sigstorekms.Get(ctx, keyResourceID, crypto.SHA256)
}
func init() {
RegisterSigner(SigningModeKMS, newKMSSigner)
}
type kmsSigner struct {
signerVerifier sigstorekms.SignerVerifier
verifier Verifier
keyID string
}
func newKMSSigner(ctx context.Context, cfg BackendConfig) (Signer, error) {
if cfg.SignerRef == "" {
return nil, fmt.Errorf("signing_mode %q requires signer or key", SigningModeKMS)
}
signerVerifier, err := newKMSSignerVerifier(ctx, cfg.SignerRef)
if err != nil {
var notFound *sigstorekms.ProviderNotFoundError
if errors.As(err, &notFound) {
return nil, fmt.Errorf("initialize KMS signer %q: %w%s", cfg.SignerRef, err, kmsProviderBuildHint(cfg.SignerRef))
}
return nil, fmt.Errorf("initialize KMS signer %q: %w", cfg.SignerRef, err)
}
publicKey, err := signerVerifier.PublicKey()
if err != nil {
return nil, fmt.Errorf("load KMS public key %q: %w", cfg.SignerRef, err)
}
verifier, err := newSigstoreVerifierFromPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("create KMS verifier %q: %w", cfg.SignerRef, err)
}
return &kmsSigner{
signerVerifier: signerVerifier,
verifier: verifier,
keyID: verifier.KeyID(),
}, nil
}
func (s *kmsSigner) Sign(_ context.Context, payloadType string, payload []byte) (Signature, error) {
encoded := PreAuthEncode(payloadType, payload)
signature, err := s.signerVerifier.SignMessage(bytes.NewReader(encoded))
if err != nil {
return Signature{}, fmt.Errorf("sign payload with KMS: %w", err)
}
return Signature{
KeyID: s.keyID,
Sig: signature,
}, nil
}
func (s *kmsSigner) Verifier(context.Context, BackendConfig) (Verifier, error) {
return s.verifier, nil
}
// kmsProviderBuildTags maps a KMS/Vault URI scheme to the build tag that
// compiles its provider into the binary. Providers are included by default;
// builds using the "kms_cherrypick" tag opt in to individual providers.
var kmsProviderBuildTags = map[string]string{
"awskms": "kms_aws",
"gcpkms": "kms_gcp",
"azurekms": "kms_azure",
"hashivault": "kms_hashivault",
}
// kmsProviderBuildHint returns guidance when a recognized KMS provider scheme is
// requested but no provider is registered, which happens when the binary was
// built with "kms_cherrypick" and did not opt that provider in.
func kmsProviderBuildHint(ref string) string {
scheme := ref
if idx := strings.Index(ref, "://"); idx >= 0 {
scheme = ref[:idx]
}
tag, ok := kmsProviderBuildTags[scheme]
if !ok {
return ""
}
return fmt.Sprintf("; the %s KMS provider is not compiled into this build (rebuild without \"kms_cherrypick\", or with -tags 'kms_cherrypick %s')", scheme, tag)
}
func newSigstoreVerifierFromPublicKey(publicKey crypto.PublicKey) (Verifier, error) {
verifier, err := sigstoresignature.LoadDefaultVerifier(publicKey)
if err != nil {
return nil, fmt.Errorf("load sigstore verifier: %w", err)
}
publicKeyPEM, err := marshalPublicKeyPEM(publicKey)
if err != nil {
return nil, err
}
return &sigstoreVerifier{
verifier: verifier,
keyID: sha256Hex(publicKeyPEM),
}, nil
}
type sigstoreVerifier struct {
verifier sigstoresignature.Verifier
keyID string
}
func (v *sigstoreVerifier) Verify(_ context.Context, payloadType string, payload, signature []byte) error {
encoded := PreAuthEncode(payloadType, payload)
if err := v.verifier.VerifySignature(bytes.NewReader(signature), bytes.NewReader(encoded)); err != nil {
return err
}
return nil
}
func (v *sigstoreVerifier) KeyID() string {
return v.keyID
}
@@ -0,0 +1,762 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/json"
"fmt"
"math/big"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
internalprovenance "github.com/hashicorp/packer/internal/provenance"
protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
sigstorebundle "github.com/sigstore/sigstore-go/pkg/bundle"
fulciocertificate "github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
sigstoreroot "github.com/sigstore/sigstore-go/pkg/root"
sigstoregosign "github.com/sigstore/sigstore-go/pkg/sign"
sigstoresignature "github.com/sigstore/sigstore/pkg/signature"
sigstorekms "github.com/sigstore/sigstore/pkg/signature/kms"
)
func TestKMSSignerAndVerifier(t *testing.T) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate private key: %v", err)
}
localSignerVerifier, err := sigstoresignature.LoadDefaultSignerVerifier(privateKey)
if err != nil {
t.Fatalf("load local signer verifier: %v", err)
}
originalFactory := newKMSSignerVerifier
newKMSSignerVerifier = func(ctx context.Context, keyResourceID string) (sigstorekms.SignerVerifier, error) {
if got, want := keyResourceID, "awskms://alias/example"; got != want {
t.Fatalf("unexpected KMS key resource %q, want %q", got, want)
}
return &fakeKMSSignerVerifier{
SignerVerifier: localSignerVerifier,
publicKey: privateKey.Public(),
}, nil
}
t.Cleanup(func() {
newKMSSignerVerifier = originalFactory
})
signer, err := NewSigner(context.Background(), BackendConfig{Mode: SigningModeKMS, SignerRef: "awskms://alias/example"})
if err != nil {
t.Fatalf("create KMS signer: %v", err)
}
verifier, err := NewVerifier(context.Background(), BackendConfig{Mode: SigningModeKMS}, signer)
if err != nil {
t.Fatalf("create KMS verifier: %v", err)
}
payload := []byte(`{"hello":"kms"}`)
signature, err := signer.Sign(context.Background(), InTotoPayloadType, payload)
if err != nil {
t.Fatalf("sign payload: %v", err)
}
if signature.KeyID == "" {
t.Fatalf("expected KMS signature key ID to be populated")
}
envelope := NewEnvelope(InTotoPayloadType, payload, signature)
if err := VerifyEnvelope(context.Background(), envelope, verifier); err != nil {
t.Fatalf("verify envelope: %v", err)
}
}
func TestKeylessSignerAndVerifier(t *testing.T) {
originalKeypairFactory := newKeylessEphemeralKeypair
originalFulcioFactory := newKeylessFulcio
originalTrustedMaterialLoader := loadKeylessTrustedMaterial
originalCertificateVerifier := verifyKeylessCertificate
newKeylessEphemeralKeypair = func() (sigstoregosign.Keypair, error) {
return sigstoregosign.NewEphemeralKeypair(nil)
}
newKeylessFulcio = func(baseURL string) sigstoregosign.CertificateProvider {
if got, want := baseURL, "https://fulcio.example.test"; got != want {
t.Fatalf("unexpected Fulcio URL %q, want %q", got, want)
}
return fakeCertificateProvider{t: t, wantToken: "test-oidc-token"}
}
loadKeylessTrustedMaterial = func(cfg BackendConfig) (sigstoreroot.TrustedMaterial, error) {
if got, want := cfg.TrustedRootPath, "/tmp/test-root.json"; got != want {
t.Fatalf("unexpected trusted root path %q, want %q", got, want)
}
return nil, nil
}
verifyKeylessCertificate = func(certificate *x509.Certificate, trustedMaterial sigstoreroot.TrustedMaterial, expectedIdentity, expectedOIDCIssuer, trustedRootPath string) error {
summary, err := fulciocertificate.SummarizeCertificate(certificate)
if err != nil {
t.Fatalf("summarize certificate: %v", err)
}
if got, want := summary.SubjectAlternativeName, "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"; got != want {
t.Fatalf("unexpected certificate SAN %q, want %q", got, want)
}
if got, want := summary.Issuer, "https://token.actions.githubusercontent.com"; got != want {
t.Fatalf("unexpected certificate OIDC issuer %q, want %q", got, want)
}
if got, want := expectedIdentity, "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"; got != want {
t.Fatalf("unexpected expected identity %q, want %q", got, want)
}
if got, want := expectedOIDCIssuer, "https://token.actions.githubusercontent.com"; got != want {
t.Fatalf("unexpected expected OIDC issuer %q, want %q", got, want)
}
return nil
}
t.Cleanup(func() {
newKeylessEphemeralKeypair = originalKeypairFactory
newKeylessFulcio = originalFulcioFactory
loadKeylessTrustedMaterial = originalTrustedMaterialLoader
verifyKeylessCertificate = originalCertificateVerifier
})
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKeyless,
Env: map[string]string{"SIGSTORE_ID_TOKEN": "test-oidc-token"},
FulcioURL: "https://fulcio.example.test",
})
if err != nil {
t.Fatalf("create keyless signer: %v", err)
}
verifier, err := NewVerifier(context.Background(), BackendConfig{
Mode: SigningModeKeyless,
TrustedRootPath: "/tmp/test-root.json",
KeylessIdentity: "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main",
KeylessOIDCIssuer: "https://token.actions.githubusercontent.com",
}, signer)
if err != nil {
t.Fatalf("create keyless verifier: %v", err)
}
payload := []byte(`{"hello":"keyless"}`)
signature, err := signer.Sign(context.Background(), InTotoPayloadType, payload)
if err != nil {
t.Fatalf("sign payload: %v", err)
}
if len(signature.CertPEM) == 0 {
t.Fatalf("expected keyless signature to embed a certificate")
}
envelope := NewEnvelope(InTotoPayloadType, payload, signature)
if err := VerifyEnvelope(context.Background(), envelope, verifier); err != nil {
t.Fatalf("verify envelope: %v", err)
}
}
func TestBuildBundleForKeylessSigner(t *testing.T) {
originalKeypairFactory := newKeylessEphemeralKeypair
originalFulcioFactory := newKeylessFulcio
originalBundleFactory := newKeylessBundle
originalRekorFactory := newKeylessRekor
originalTrustedMaterialLoader := loadKeylessTrustedMaterial
newKeylessEphemeralKeypair = func() (sigstoregosign.Keypair, error) {
return sigstoregosign.NewEphemeralKeypair(nil)
}
newKeylessFulcio = func(string) sigstoregosign.CertificateProvider {
return fakeCertificateProvider{t: t, wantToken: "test-oidc-token"}
}
loadKeylessTrustedMaterial = func(cfg BackendConfig) (sigstoreroot.TrustedMaterial, error) {
if got, want := cfg.TrustedRootPath, "/tmp/test-root.json"; got != want {
t.Fatalf("unexpected trusted root path %q, want %q", got, want)
}
return nil, nil
}
rekorCalled := false
newKeylessRekor = func(baseURL string) sigstoregosign.Transparency {
if got, want := baseURL, "https://rekor.example.test"; got != want {
t.Fatalf("unexpected Rekor URL %q, want %q", got, want)
}
rekorCalled = true
return fakeTransparency{}
}
newKeylessBundle = func(content sigstoregosign.Content, keypair sigstoregosign.Keypair, opts sigstoregosign.BundleOptions) (*protobundle.Bundle, error) {
if len(opts.TransparencyLogs) != 1 {
t.Fatalf("expected one transparency log, got %d", len(opts.TransparencyLogs))
}
return sigstoregosign.Bundle(content, keypair, sigstoregosign.BundleOptions{
CertificateProvider: opts.CertificateProvider,
Context: opts.Context,
TrustedRoot: opts.TrustedRoot,
})
}
t.Cleanup(func() {
newKeylessEphemeralKeypair = originalKeypairFactory
newKeylessFulcio = originalFulcioFactory
newKeylessBundle = originalBundleFactory
newKeylessRekor = originalRekorFactory
loadKeylessTrustedMaterial = originalTrustedMaterialLoader
})
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKeyless,
Env: map[string]string{"SIGSTORE_ID_TOKEN": "test-oidc-token"},
FulcioURL: "https://fulcio.example.test",
})
if err != nil {
t.Fatalf("create keyless signer: %v", err)
}
envelope, bundleJSON, err := BuildBundleForSigner(context.Background(), signer, BackendConfig{
Mode: SigningModeKeyless,
UploadTlog: true,
RekorURL: "https://rekor.example.test",
TrustedRootPath: "/tmp/test-root.json",
}, InTotoPayloadType, []byte(`{"hello":"bundle"}`))
if err != nil {
t.Fatalf("build bundle: %v", err)
}
if !rekorCalled {
t.Fatalf("expected Rekor constructor to be used")
}
if got, want := envelope.PayloadType, InTotoPayloadType; got != want {
t.Fatalf("unexpected envelope payload type %q, want %q", got, want)
}
if len(envelope.Signatures) != 1 || envelope.Signatures[0].Cert == "" {
t.Fatalf("expected bundled envelope to include one certificate-backed signature")
}
bundlePath := filepath.Join(t.TempDir(), "bundle.json")
if err := os.WriteFile(bundlePath, bundleJSON, 0600); err != nil {
t.Fatalf("write bundle: %v", err)
}
if _, err := sigstorebundle.LoadJSONFromPath(bundlePath); err != nil {
t.Fatalf("load bundle: %v", err)
}
}
func TestKeylessBundleAndRekorIntegration(t *testing.T) {
if os.Getenv("PACKER_ACC") == "" {
t.Skip("acceptance-style keyless integration test skipped unless PACKER_ACC is set")
}
env := currentProcessEnv()
if _, err := resolveAmbientIDToken(context.Background(), env); err != nil {
t.Skipf("keyless integration test skipped without an ambient OIDC token: %v", err)
}
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
if err := os.WriteFile(artifactPath, []byte("hello live keyless"), 0600); err != nil {
t.Fatalf("write artifact: %v", err)
}
digest, err := sha256File(artifactPath)
if err != nil {
t.Fatalf("hash artifact: %v", err)
}
statement := internalprovenance.WrapInToto(
[]internalprovenance.Subject{{Name: filepath.Base(artifactPath), Digest: internalprovenance.DigestSet{"sha256": digest}}},
internalprovenance.SLSAProvenanceV1PredicateType,
internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{}),
)
payload, err := MarshalPayload(statement)
if err != nil {
t.Fatalf("marshal statement: %v", err)
}
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKeyless,
Env: env,
})
if err != nil {
t.Fatalf("create live keyless signer: %v", err)
}
envelope, bundleJSON, err := BuildBundleForSigner(context.Background(), signer, BackendConfig{
Mode: SigningModeKeyless,
Env: env,
UploadTlog: true,
}, InTotoPayloadType, payload)
if err != nil {
t.Fatalf("build live Sigstore bundle: %v", err)
}
certificate, err := certificateFromEnvelope(envelope)
if err != nil {
t.Fatalf("extract certificate from live envelope: %v", err)
}
summary, err := fulciocertificate.SummarizeCertificate(certificate)
if err != nil {
t.Fatalf("summarize live certificate: %v", err)
}
if summary.SubjectAlternativeName == "" || summary.Issuer == "" {
t.Fatalf("expected live certificate to contain SAN and issuer, got SAN=%q issuer=%q", summary.SubjectAlternativeName, summary.Issuer)
}
envelopePath := filepath.Join(t.TempDir(), "attestation.json")
if err := os.WriteFile(envelopePath, mustJSONMarshal(t, envelope), 0600); err != nil {
t.Fatalf("write live envelope: %v", err)
}
bundlePath := filepath.Join(t.TempDir(), "attestation.sigstore.json")
if err := os.WriteFile(bundlePath, bundleJSON, 0600); err != nil {
t.Fatalf("write live bundle: %v", err)
}
if _, err := sigstorebundle.LoadJSONFromPath(bundlePath); err != nil {
t.Fatalf("load live bundle: %v", err)
}
verifiedStatement, err := VerifyAttestationFile(context.Background(), envelopePath, BackendConfig{
Mode: SigningModeKeyless,
KeylessIdentity: summary.SubjectAlternativeName,
KeylessOIDCIssuer: summary.Issuer,
}, VerificationPolicy{
PredicateType: internalprovenance.SLSAProvenanceV1PredicateType,
ArtifactPath: artifactPath,
SigstoreBundlePath: bundlePath,
RequireTransparencyLog: true,
})
if err != nil {
t.Fatalf("verify live attestation with Rekor bundle: %v", err)
}
if got, want := verifiedStatement.PredicateType, internalprovenance.SLSAProvenanceV1PredicateType; got != want {
t.Fatalf("unexpected predicate type %q, want %q", got, want)
}
}
func TestKeylessSignerRequiresAmbientToken(t *testing.T) {
_, err := NewSigner(context.Background(), BackendConfig{Mode: SigningModeKeyless, Env: map[string]string{}})
if err == nil {
t.Fatalf("expected keyless signer creation to fail without an ambient token")
}
if !strings.Contains(err.Error(), "ambient OIDC token") {
t.Fatalf("unexpected keyless token error: %v", err)
}
}
func TestKeylessVerifierRequiresIdentityPolicy(t *testing.T) {
originalKeypairFactory := newKeylessEphemeralKeypair
originalFulcioFactory := newKeylessFulcio
originalTrustedMaterialLoader := loadKeylessTrustedMaterial
newKeylessEphemeralKeypair = func() (sigstoregosign.Keypair, error) {
return sigstoregosign.NewEphemeralKeypair(nil)
}
newKeylessFulcio = func(string) sigstoregosign.CertificateProvider {
return fakeCertificateProvider{t: t, wantToken: "test-oidc-token"}
}
loadKeylessTrustedMaterial = func(cfg BackendConfig) (sigstoreroot.TrustedMaterial, error) {
return nil, nil
}
t.Cleanup(func() {
newKeylessEphemeralKeypair = originalKeypairFactory
newKeylessFulcio = originalFulcioFactory
loadKeylessTrustedMaterial = originalTrustedMaterialLoader
})
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKeyless,
Env: map[string]string{"SIGSTORE_ID_TOKEN": "test-oidc-token"},
FulcioURL: "https://fulcio.example.test",
})
if err != nil {
t.Fatalf("create keyless signer: %v", err)
}
_, err = NewVerifier(context.Background(), BackendConfig{Mode: SigningModeKeyless}, signer)
if err == nil {
t.Fatalf("expected keyless verifier creation to fail without identity policy")
}
if !strings.Contains(err.Error(), "keyless_identity") {
t.Fatalf("unexpected keyless verifier error: %v", err)
}
}
func TestVerifyAttestationFileWithKeylessEnvelope(t *testing.T) {
originalKeypairFactory := newKeylessEphemeralKeypair
originalFulcioFactory := newKeylessFulcio
originalTrustedMaterialLoader := loadKeylessTrustedMaterial
originalCertificateVerifier := verifyKeylessCertificate
newKeylessEphemeralKeypair = func() (sigstoregosign.Keypair, error) {
return sigstoregosign.NewEphemeralKeypair(nil)
}
newKeylessFulcio = func(string) sigstoregosign.CertificateProvider {
return fakeCertificateProvider{t: t, wantToken: "test-oidc-token"}
}
loadKeylessTrustedMaterial = func(cfg BackendConfig) (sigstoreroot.TrustedMaterial, error) {
if got, want := cfg.TrustedRootPath, "/tmp/test-root.json"; got != want {
t.Fatalf("unexpected trusted root path %q, want %q", got, want)
}
return nil, nil
}
verifyKeylessCertificate = func(certificate *x509.Certificate, trustedMaterial sigstoreroot.TrustedMaterial, expectedIdentity, expectedOIDCIssuer, trustedRootPath string) error {
summary, err := fulciocertificate.SummarizeCertificate(certificate)
if err != nil {
t.Fatalf("summarize certificate: %v", err)
}
if got, want := summary.SubjectAlternativeName, "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"; got != want {
t.Fatalf("unexpected certificate SAN %q, want %q", got, want)
}
if got, want := expectedIdentity, "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"; got != want {
t.Fatalf("unexpected expected identity %q, want %q", got, want)
}
if got, want := expectedOIDCIssuer, "https://token.actions.githubusercontent.com"; got != want {
t.Fatalf("unexpected expected OIDC issuer %q, want %q", got, want)
}
return nil
}
t.Cleanup(func() {
newKeylessEphemeralKeypair = originalKeypairFactory
newKeylessFulcio = originalFulcioFactory
loadKeylessTrustedMaterial = originalTrustedMaterialLoader
verifyKeylessCertificate = originalCertificateVerifier
})
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
if err := os.WriteFile(artifactPath, []byte("hello keyless"), 0600); err != nil {
t.Fatalf("write artifact: %v", err)
}
digest, err := sha256File(artifactPath)
if err != nil {
t.Fatalf("hash artifact: %v", err)
}
statement := internalprovenance.WrapInToto(
[]internalprovenance.Subject{{Name: filepath.Base(artifactPath), Digest: internalprovenance.DigestSet{"sha256": digest}}},
internalprovenance.SLSAProvenanceV1PredicateType,
internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{
BuilderID: "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main",
ResolvedDependencies: []internalprovenance.ResolvedDependency{{URI: "git+https://github.com/hashicorp/packer@refs/heads/main"}},
}),
)
payload, err := MarshalPayload(statement)
if err != nil {
t.Fatalf("marshal statement: %v", err)
}
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKeyless,
Env: map[string]string{"SIGSTORE_ID_TOKEN": "test-oidc-token"},
FulcioURL: "https://fulcio.example.test",
})
if err != nil {
t.Fatalf("create keyless signer: %v", err)
}
signature, err := signer.Sign(context.Background(), InTotoPayloadType, payload)
if err != nil {
t.Fatalf("sign statement: %v", err)
}
envelopePath := filepath.Join(t.TempDir(), "attestation.json")
envelopeJSON, err := json.Marshal(NewEnvelope(InTotoPayloadType, payload, signature))
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
if err := os.WriteFile(envelopePath, envelopeJSON, 0600); err != nil {
t.Fatalf("write envelope: %v", err)
}
verifiedStatement, err := VerifyAttestationFile(context.Background(), envelopePath, BackendConfig{
Mode: SigningModeKeyless,
TrustedRootPath: "/tmp/test-root.json",
KeylessIdentity: "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main",
KeylessOIDCIssuer: "https://token.actions.githubusercontent.com",
}, VerificationPolicy{
PredicateType: internalprovenance.SLSAProvenanceV1PredicateType,
BuilderID: "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main",
SourceURI: "git+https://github.com/hashicorp/packer@refs/heads/main",
ArtifactPath: artifactPath,
})
if err != nil {
t.Fatalf("verify attestation file: %v", err)
}
if got, want := verifiedStatement.PredicateType, internalprovenance.SLSAProvenanceV1PredicateType; got != want {
t.Fatalf("unexpected predicate type %q, want %q", got, want)
}
}
func TestVerifyAttestationFileRequiresBundleForTransparencyChecks(t *testing.T) {
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
if err := os.WriteFile(artifactPath, []byte("hello keyless"), 0600); err != nil {
t.Fatalf("write artifact: %v", err)
}
digest, err := sha256File(artifactPath)
if err != nil {
t.Fatalf("hash artifact: %v", err)
}
statement := internalprovenance.WrapInToto(
[]internalprovenance.Subject{{Name: filepath.Base(artifactPath), Digest: internalprovenance.DigestSet{"sha256": digest}}},
internalprovenance.SLSAProvenanceV1PredicateType,
internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{}),
)
payload, err := MarshalPayload(statement)
if err != nil {
t.Fatalf("marshal statement: %v", err)
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate private key: %v", err)
}
verifier, err := newSigstoreVerifierFromPublicKey(privateKey.Public())
if err != nil {
t.Fatalf("create verifier: %v", err)
}
sig, err := privateKey.Sign(rand.Reader, PreAuthEncode(InTotoPayloadType, payload), crypto.SHA256)
if err != nil {
t.Fatalf("sign payload: %v", err)
}
envelopePath := filepath.Join(t.TempDir(), "attestation.json")
envelopeJSON, err := json.Marshal(NewEnvelope(InTotoPayloadType, payload, Signature{Sig: sig, CertPEM: []byte("-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n")}))
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
if err := os.WriteFile(envelopePath, envelopeJSON, 0600); err != nil {
t.Fatalf("write envelope: %v", err)
}
_ = verifier
_, err = VerifyAttestationFile(context.Background(), envelopePath, BackendConfig{
Mode: SigningModeKeyless,
KeylessIdentity: "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main",
KeylessOIDCIssuer: "https://token.actions.githubusercontent.com",
}, VerificationPolicy{RequireTransparencyLog: true})
if err == nil || !strings.Contains(err.Error(), "requires -bundle") {
t.Fatalf("expected missing bundle error, got %v", err)
}
}
func TestVerifyAttestationFileWithBundleRequirements(t *testing.T) {
originalKeypairFactory := newKeylessEphemeralKeypair
originalFulcioFactory := newKeylessFulcio
originalTrustedMaterialLoader := loadKeylessTrustedMaterial
originalCertificateVerifier := verifyKeylessCertificate
originalBundleVerifier := verifySigstoreBundleEvidence
newKeylessEphemeralKeypair = func() (sigstoregosign.Keypair, error) {
return sigstoregosign.NewEphemeralKeypair(nil)
}
newKeylessFulcio = func(string) sigstoregosign.CertificateProvider {
return fakeCertificateProvider{t: t, wantToken: "test-oidc-token"}
}
loadKeylessTrustedMaterial = func(cfg BackendConfig) (sigstoreroot.TrustedMaterial, error) {
return nil, nil
}
verifyKeylessCertificate = func(*x509.Certificate, sigstoreroot.TrustedMaterial, string, string, string) error {
return nil
}
called := false
verifySigstoreBundleEvidence = func(envelope Envelope, cfg BackendConfig, policy VerificationPolicy) error {
called = true
if got, want := policy.SigstoreBundlePath, "bundle.json"; got != want {
t.Fatalf("unexpected bundle path %q, want %q", got, want)
}
if !policy.RequireTransparencyLog || !policy.RequireObserverTimestamp {
t.Fatalf("expected Rekor and timestamp requirements to be set")
}
if len(envelope.Signatures) != 1 {
t.Fatalf("unexpected signature count %d", len(envelope.Signatures))
}
if got, want := cfg.KeylessIdentity, "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"; got != want {
t.Fatalf("unexpected keyless identity %q, want %q", got, want)
}
return nil
}
t.Cleanup(func() {
newKeylessEphemeralKeypair = originalKeypairFactory
newKeylessFulcio = originalFulcioFactory
loadKeylessTrustedMaterial = originalTrustedMaterialLoader
verifyKeylessCertificate = originalCertificateVerifier
verifySigstoreBundleEvidence = originalBundleVerifier
})
artifactPath := filepath.Join(t.TempDir(), "artifact.txt")
if err := os.WriteFile(artifactPath, []byte("hello keyless"), 0600); err != nil {
t.Fatalf("write artifact: %v", err)
}
digest, err := sha256File(artifactPath)
if err != nil {
t.Fatalf("hash artifact: %v", err)
}
statement := internalprovenance.WrapInToto(
[]internalprovenance.Subject{{Name: filepath.Base(artifactPath), Digest: internalprovenance.DigestSet{"sha256": digest}}},
internalprovenance.SLSAProvenanceV1PredicateType,
internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{}),
)
payload, err := MarshalPayload(statement)
if err != nil {
t.Fatalf("marshal statement: %v", err)
}
signer, err := NewSigner(context.Background(), BackendConfig{
Mode: SigningModeKeyless,
Env: map[string]string{"SIGSTORE_ID_TOKEN": "test-oidc-token"},
FulcioURL: "https://fulcio.example.test",
})
if err != nil {
t.Fatalf("create keyless signer: %v", err)
}
signature, err := signer.Sign(context.Background(), InTotoPayloadType, payload)
if err != nil {
t.Fatalf("sign statement: %v", err)
}
envelopePath := filepath.Join(t.TempDir(), "attestation.json")
envelopeJSON, err := json.Marshal(NewEnvelope(InTotoPayloadType, payload, signature))
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
if err := os.WriteFile(envelopePath, envelopeJSON, 0600); err != nil {
t.Fatalf("write envelope: %v", err)
}
_, err = VerifyAttestationFile(context.Background(), envelopePath, BackendConfig{
Mode: SigningModeKeyless,
KeylessIdentity: "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main",
KeylessOIDCIssuer: "https://token.actions.githubusercontent.com",
}, VerificationPolicy{
SigstoreBundlePath: "bundle.json",
RequireTransparencyLog: true,
RequireObserverTimestamp: true,
})
if err != nil {
t.Fatalf("verify attestation with bundle requirements: %v", err)
}
if !called {
t.Fatalf("expected Sigstore bundle verifier to be called")
}
_ = sigstorebundle.Bundle{}
}
type fakeTransparency struct{}
func (fakeTransparency) GetTransparencyLogEntry(context.Context, []byte, *protobundle.Bundle) error {
return nil
}
func currentProcessEnv() map[string]string {
env := make(map[string]string)
for _, item := range os.Environ() {
parts := strings.SplitN(item, "=", 2)
if len(parts) != 2 {
continue
}
env[parts[0]] = parts[1]
}
return env
}
func mustJSONMarshal(t *testing.T, value interface{}) []byte {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal JSON: %v", err)
}
return encoded
}
type fakeKMSSignerVerifier struct {
sigstoresignature.SignerVerifier
publicKey crypto.PublicKey
}
func (f *fakeKMSSignerVerifier) CreateKey(context.Context, string) (crypto.PublicKey, error) {
return f.publicKey, nil
}
func (f *fakeKMSSignerVerifier) CryptoSigner(context.Context, func(error)) (crypto.Signer, crypto.SignerOpts, error) {
return nil, nil, fmt.Errorf("not implemented in tests")
}
func (f *fakeKMSSignerVerifier) SupportedAlgorithms() []string {
return []string{"ecdsa-p256-sha256"}
}
func (f *fakeKMSSignerVerifier) DefaultAlgorithm() string {
return "ecdsa-p256-sha256"
}
type fakeCertificateProvider struct {
t *testing.T
wantToken string
}
func (f fakeCertificateProvider) GetCertificate(_ context.Context, keypair sigstoregosign.Keypair, opts *sigstoregosign.CertificateProviderOptions) ([]byte, error) {
f.t.Helper()
if opts == nil {
f.t.Fatalf("expected certificate options to be provided")
}
if got, want := opts.IDToken, f.wantToken; got != want {
f.t.Fatalf("unexpected Fulcio ID token %q, want %q", got, want)
}
return createCertificateDER(f.t, keypair.GetPublicKey()), nil
}
func createCertificateDER(t *testing.T, publicKey crypto.PublicKey) []byte {
t.Helper()
issuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate issuer key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "packer-keyless-test",
},
URIs: []*url.URL{mustParseURL(t, "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main")},
ExtraExtensions: []pkix.Extension{buildIssuerExtension(t, "https://token.actions.githubusercontent.com")},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, issuerKey)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return der
}
func buildIssuerExtension(t *testing.T, issuer string) pkix.Extension {
t.Helper()
value, err := asn1.MarshalWithParams(issuer, "utf8")
if err != nil {
t.Fatalf("marshal issuer extension: %v", err)
}
return pkix.Extension{Id: fulciocertificate.OIDIssuerV2, Value: value}
}
func mustParseURL(t *testing.T, rawURL string) *url.URL {
t.Helper()
parsedURL, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse url %q: %v", rawURL, err)
}
return parsedURL
}
@@ -0,0 +1,8 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:build kms_cherrypick && kms_aws
package attestation
import _ "github.com/sigstore/sigstore/pkg/signature/kms/aws"
@@ -0,0 +1,8 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:build kms_cherrypick && kms_azure
package attestation
import _ "github.com/sigstore/sigstore/pkg/signature/kms/azure"
@@ -0,0 +1,8 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:build kms_cherrypick && kms_gcp
package attestation
import _ "github.com/sigstore/sigstore/pkg/signature/kms/gcp"
@@ -0,0 +1,8 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:build kms_cherrypick && kms_hashivault
package attestation
import _ "github.com/sigstore/sigstore/pkg/signature/kms/hashivault"
@@ -0,0 +1,18 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:build !kms_cherrypick
// Default builds include every KMS/Vault provider so that all signing_mode=kms
// URIs work out of the box. Builds using the "kms_cherrypick" tag exclude this
// file and instead opt into individual providers via the per-provider tags
// (kms_aws, kms_azure, kms_gcp, kms_hashivault).
package attestation
import (
_ "github.com/sigstore/sigstore/pkg/signature/kms/aws"
_ "github.com/sigstore/sigstore/pkg/signature/kms/azure"
_ "github.com/sigstore/sigstore/pkg/signature/kms/gcp"
_ "github.com/sigstore/sigstore/pkg/signature/kms/hashivault"
)
@@ -0,0 +1,37 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:build !kms_cherrypick
package attestation
import (
"strings"
"testing"
sigstorekms "github.com/sigstore/sigstore/pkg/signature/kms"
)
func TestDefaultBuildRegistersAllKMSProviders(t *testing.T) {
registered := map[string]bool{}
for _, scheme := range sigstorekms.SupportedProviders() {
registered[scheme] = true
}
for _, scheme := range []string{"awskms://", "azurekms://", "gcpkms://", "hashivault://"} {
if !registered[scheme] {
t.Errorf("default build is missing KMS provider %q (registered: %v)", scheme, sigstorekms.SupportedProviders())
}
}
}
func TestKMSProviderBuildHint(t *testing.T) {
hint := kmsProviderBuildHint("awskms://alias/example")
if !strings.Contains(hint, "kms_aws") || !strings.Contains(hint, "awskms") {
t.Fatalf("unexpected hint for awskms reference: %q", hint)
}
if got := kmsProviderBuildHint("file:///tmp/key.pem"); got != "" {
t.Fatalf("expected no build hint for non-KMS reference, got %q", got)
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"context"
"fmt"
)
const (
SigningModeNone = "none"
SigningModeKey = "key"
SigningModeKMS = "kms"
SigningModeKeyless = "keyless"
)
type Signature struct {
KeyID string
Sig []byte
CertPEM []byte
}
type Signer interface {
Sign(ctx context.Context, payloadType string, payload []byte) (Signature, error)
Verifier(ctx context.Context, cfg BackendConfig) (Verifier, error)
}
type Verifier interface {
Verify(ctx context.Context, payloadType string, payload, signature []byte) error
KeyID() string
}
type BackendConfig struct {
Mode string
SignerRef string
VerifierRef string
Env map[string]string
FulcioURL string
RekorURL string
UploadTlog bool
TrustedRootPath string
KeylessIdentity string
KeylessOIDCIssuer string
}
type signerFactory func(context.Context, BackendConfig) (Signer, error)
var signerFactories = map[string]signerFactory{}
func RegisterSigner(mode string, factory signerFactory) {
signerFactories[mode] = factory
}
func NewSigner(ctx context.Context, cfg BackendConfig) (Signer, error) {
factory, ok := signerFactories[cfg.Mode]
if !ok {
return nil, fmt.Errorf("signing_mode %q is not implemented", cfg.Mode)
}
return factory(ctx, cfg)
}
func NewVerifier(ctx context.Context, cfg BackendConfig, signer Signer) (Verifier, error) {
if cfg.VerifierRef != "" {
return LoadPEMVerifier(cfg.VerifierRef)
}
return signer.Verifier(ctx, cfg)
}
func VerifyEnvelope(ctx context.Context, envelope Envelope, verifier Verifier) error {
if len(envelope.Signatures) == 0 {
return fmt.Errorf("envelope has no signatures")
}
payload, err := DecodeEnvelopePayload(envelope)
if err != nil {
return err
}
for _, signature := range envelope.Signatures {
decodedSignature, decodeErr := DecodeEnvelopeSignature(signature)
if decodeErr != nil {
return decodeErr
}
if verifyErr := verifier.Verify(ctx, envelope.PayloadType, payload, decodedSignature); verifyErr == nil {
return nil
}
}
return fmt.Errorf("signature verification failed")
}
+412
View File
@@ -0,0 +1,412 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
internalprovenance "github.com/hashicorp/packer/internal/provenance"
sigstorebundle "github.com/sigstore/sigstore-go/pkg/bundle"
sigstoreverify "github.com/sigstore/sigstore-go/pkg/verify"
)
type VerificationPolicy struct {
PredicateType string
BuilderID string
SourceURI string
ArtifactPath string
SigstoreBundlePath string
RequireTransparencyLog bool
RequireObserverTimestamp bool
}
var loadSigstoreBundle = sigstorebundle.LoadJSONFromPath
var newSigstoreBundleVerifier = sigstoreverify.NewVerifier
var verifySigstoreBundleEvidence = func(envelope Envelope, cfg BackendConfig, policy VerificationPolicy) error {
return verifySigstoreBundleEvidenceImpl(envelope, cfg, policy)
}
func VerifyAttestationFile(ctx context.Context, path string, cfg BackendConfig, policy VerificationPolicy) (*internalprovenance.Statement, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read attestation %q: %w", path, err)
}
var envelope Envelope
if err := json.Unmarshal(contents, &envelope); err != nil {
return nil, fmt.Errorf("decode attestation envelope %q: %w", path, err)
}
if envelope.PayloadType != InTotoPayloadType {
return nil, fmt.Errorf("attestation %q has unexpected payloadType %q (want %q)",
path, envelope.PayloadType, InTotoPayloadType)
}
if err := verifyEnvelopeSignature(ctx, path, cfg, policy, envelope); err != nil {
return nil, err
}
payload, err := DecodeEnvelopePayload(envelope)
if err != nil {
return nil, err
}
statement, err := verifyPolicy(payload, policy)
if err != nil {
return nil, err
}
return statement, nil
}
func verifyEnvelopeSignature(ctx context.Context, path string, cfg BackendConfig, policy VerificationPolicy, envelope Envelope) error {
// An explicit Rekor or timestamp policy always uses bundle-based verification.
if requiresSigstoreBundle(policy) {
return verifySigstoreBundleEvidence(envelope, cfg, policy)
}
// Keyless attestations are signed with a short-lived Fulcio certificate that
// appears expired against wall-clock time moments after signing. When a
// Sigstore bundle carrying transparency-log or timestamp evidence is
// available, prefer it so the certificate is validated as of the signing
// time recorded in that evidence rather than the current time.
if envelopeHasCertificate(envelope) {
if bundlePath := resolveSigstoreBundlePath(policy, path); bundlePath != "" && bundleAnchorsSigningTime(bundlePath) {
policy.SigstoreBundlePath = bundlePath
return verifySigstoreBundleEvidence(envelope, cfg, policy)
}
}
verifier, err := verifierForEnvelope(ctx, cfg, envelope)
if err != nil {
return err
}
if err := VerifyEnvelope(ctx, envelope, verifier); err != nil {
return fmt.Errorf("verify attestation envelope %q: %w", path, err)
}
return nil
}
// resolveSigstoreBundlePath returns an explicitly configured bundle path, or the
// conventional "<attestation>.sigstore.json" sidecar written alongside signed
// attestations when it exists on disk.
func resolveSigstoreBundlePath(policy VerificationPolicy, attestationPath string) string {
if trimmed := strings.TrimSpace(policy.SigstoreBundlePath); trimmed != "" {
return trimmed
}
candidate := defaultSigstoreBundlePath(attestationPath)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
return ""
}
func defaultSigstoreBundlePath(attestationPath string) string {
if strings.HasSuffix(attestationPath, ".json") {
return strings.TrimSuffix(attestationPath, ".json") + ".sigstore.json"
}
return attestationPath + ".sigstore.json"
}
// bundleAnchorsSigningTime reports whether the bundle carries a trusted time
// source (a transparency-log entry or an RFC3161 timestamp) that can validate
// the signing certificate as of signing time.
var bundleAnchorsSigningTime = func(bundlePath string) bool {
bundle, err := loadSigstoreBundle(bundlePath)
if err != nil {
return false
}
if entries, err := bundle.TlogEntries(); err == nil && len(entries) > 0 {
return true
}
if timestamps, err := bundle.Timestamps(); err == nil && len(timestamps) > 0 {
return true
}
return false
}
func verifierForEnvelope(ctx context.Context, cfg BackendConfig, envelope Envelope) (Verifier, error) {
mode := normalizeVerificationMode(cfg, envelope)
if cfg.VerifierRef != "" {
if mode == SigningModeKeyless || envelopeHasCertificate(envelope) {
return nil, fmt.Errorf("verifier overrides are not supported for keyless attestations; verify with keyless_identity and keyless_oidc_issuer instead")
}
return LoadPEMVerifier(cfg.VerifierRef)
}
switch mode {
case SigningModeKey:
if cfg.SignerRef == "" {
return nil, fmt.Errorf("attestation verification for signing_mode %q requires verifier or key", SigningModeKey)
}
return LoadPEMVerifier(cfg.SignerRef)
case SigningModeKMS:
if cfg.SignerRef == "" {
return nil, fmt.Errorf("attestation verification for signing_mode %q requires key or verifier", SigningModeKMS)
}
signer, err := NewSigner(ctx, cfg)
if err != nil {
return nil, err
}
return signer.Verifier(ctx, cfg)
case SigningModeKeyless:
return newKeylessVerifierForEnvelope(cfg, envelope)
default:
return nil, fmt.Errorf("unable to determine attestation signing mode; set signing_mode or verifier explicitly")
}
}
func normalizeVerificationMode(cfg BackendConfig, envelope Envelope) string {
if cfg.Mode != "" {
return cfg.Mode
}
if envelopeHasCertificate(envelope) {
return SigningModeKeyless
}
if isRecognizedKMSReference(cfg.SignerRef) {
return SigningModeKMS
}
if cfg.SignerRef != "" || cfg.VerifierRef != "" {
return SigningModeKey
}
return ""
}
func envelopeHasCertificate(envelope Envelope) bool {
for _, signature := range envelope.Signatures {
if strings.TrimSpace(signature.Cert) != "" {
return true
}
}
return false
}
func isRecognizedKMSReference(value string) bool {
for _, prefix := range []string{"awskms://", "gcpkms://", "azurekms://", "hashivault://"} {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func verifyPolicy(payload []byte, policy VerificationPolicy) (*internalprovenance.Statement, error) {
var statement internalprovenance.Statement
if err := json.Unmarshal(payload, &statement); err != nil {
return nil, fmt.Errorf("decode attestation statement: %w", err)
}
if statement.Type != internalprovenance.StatementType {
return nil, fmt.Errorf("unexpected attestation statement type %q", statement.Type)
}
if policy.PredicateType != "" && statement.PredicateType != policy.PredicateType {
return nil, fmt.Errorf("attestation predicate type %q does not match expected %q", statement.PredicateType, policy.PredicateType)
}
if policy.ArtifactPath != "" {
if err := verifyArtifactSubject(statement.Subject, policy.ArtifactPath); err != nil {
return nil, err
}
}
if policy.BuilderID != "" || policy.SourceURI != "" {
if statement.PredicateType != internalprovenance.SLSAProvenanceV1PredicateType {
return nil, fmt.Errorf("builder and source policy checks require predicate type %q, got %q", internalprovenance.SLSAProvenanceV1PredicateType, statement.PredicateType)
}
var typedStatement struct {
Type string `json:"_type"`
Subject []internalprovenance.Subject `json:"subject"`
PredicateType string `json:"predicateType"`
Predicate internalprovenance.SLSAProvenancePredicate `json:"predicate"`
}
if err := json.Unmarshal(payload, &typedStatement); err != nil {
return nil, fmt.Errorf("decode SLSA predicate for policy verification: %w", err)
}
if policy.BuilderID != "" && typedStatement.Predicate.RunDetails.Builder.ID != policy.BuilderID {
return nil, fmt.Errorf("attestation builder id %q does not match expected %q", typedStatement.Predicate.RunDetails.Builder.ID, policy.BuilderID)
}
if policy.SourceURI != "" {
matched := false
for _, dependency := range typedStatement.Predicate.BuildDefinition.ResolvedDependencies {
if dependency.URI == policy.SourceURI {
matched = true
break
}
}
if !matched {
return nil, fmt.Errorf("attestation does not contain expected source URI %q", policy.SourceURI)
}
}
}
return &statement, nil
}
func requiresSigstoreBundle(policy VerificationPolicy) bool {
return policy.RequireTransparencyLog || policy.RequireObserverTimestamp
}
func verifySigstoreBundleEvidenceImpl(envelope Envelope, cfg BackendConfig, policy VerificationPolicy) error {
if strings.TrimSpace(policy.SigstoreBundlePath) == "" {
return fmt.Errorf("bundle-based Rekor or timestamp verification requires -bundle")
}
if normalizeVerificationMode(cfg, envelope) != SigningModeKeyless && !envelopeHasCertificate(envelope) {
return fmt.Errorf("bundle-based Rekor or timestamp verification currently requires a keyless attestation")
}
if strings.TrimSpace(cfg.KeylessIdentity) == "" || strings.TrimSpace(cfg.KeylessOIDCIssuer) == "" {
return fmt.Errorf("bundle-based Rekor or timestamp verification requires keyless_identity and keyless_oidc_issuer")
}
trustedMaterial, err := loadKeylessTrustedMaterial(cfg)
if err != nil {
return fmt.Errorf("load keyless trusted root: %w", err)
}
bundle, err := loadSigstoreBundle(policy.SigstoreBundlePath)
if err != nil {
return fmt.Errorf("load Sigstore bundle %q: %w", policy.SigstoreBundlePath, err)
}
if err := ensureBundleMatchesEnvelope(bundle, envelope); err != nil {
return err
}
verifierOptions := []sigstoreverify.VerifierOption{}
if policy.RequireTransparencyLog {
verifierOptions = append(verifierOptions, sigstoreverify.WithTransparencyLog(1))
}
if policy.RequireObserverTimestamp {
verifierOptions = append(verifierOptions, sigstoreverify.WithObserverTimestamps(1))
}
if len(verifierOptions) == 0 {
// A trusted time source is required to validate the short-lived Fulcio
// certificate as of signing time; default to observer timestamps when the
// caller has not explicitly required Rekor or timestamp evidence.
verifierOptions = append(verifierOptions, sigstoreverify.WithObserverTimestamps(1))
}
verifier, err := newSigstoreBundleVerifier(trustedMaterial, verifierOptions...)
if err != nil {
return fmt.Errorf("create Sigstore bundle verifier: %w", err)
}
artifactPolicy := sigstoreverify.WithoutArtifactUnsafe()
if policy.ArtifactPath != "" {
artifact, err := os.Open(policy.ArtifactPath)
if err != nil {
return fmt.Errorf("open artifact %q for bundle verification: %w", policy.ArtifactPath, err)
}
defer func() { _ = artifact.Close() }()
artifactPolicy = sigstoreverify.WithArtifact(artifact)
}
identity, err := sigstoreverify.NewShortCertificateIdentity(cfg.KeylessOIDCIssuer, "", cfg.KeylessIdentity, "")
if err != nil {
return fmt.Errorf("build keyless identity policy: %w", err)
}
policyBuilder := sigstoreverify.NewPolicy(artifactPolicy, sigstoreverify.WithCertificateIdentity(identity))
if _, err := verifier.Verify(bundle, policyBuilder); err != nil {
return fmt.Errorf("verify Sigstore bundle %q: %w", policy.SigstoreBundlePath, err)
}
return nil
}
func ensureBundleMatchesEnvelope(bundle *sigstorebundle.Bundle, envelope Envelope) error {
bundleEnvelope, err := bundle.Envelope()
if err != nil {
return fmt.Errorf("extract DSSE envelope from Sigstore bundle: %w", err)
}
rawEnvelope := bundleEnvelope.RawEnvelope()
if rawEnvelope == nil {
return fmt.Errorf("sigstore bundle does not contain a DSSE envelope")
}
if rawEnvelope.PayloadType != envelope.PayloadType || rawEnvelope.Payload != envelope.Payload {
return fmt.Errorf("sigstore bundle payload does not match attestation")
}
if len(envelope.Signatures) == 0 {
return fmt.Errorf("attestation envelope has no signatures")
}
bundleSignature := bundleEnvelope.Signature()
for i, envelopeSignature := range envelope.Signatures {
signature, err := DecodeEnvelopeSignature(envelopeSignature)
if err != nil {
return fmt.Errorf("decode attestation envelope signature %d: %w", i, err)
}
if bytes.Equal(bundleSignature, signature) {
return nil
}
}
return fmt.Errorf("sigstore bundle signature does not match any attestation signature")
}
func verifyArtifactSubject(subjects []internalprovenance.Subject, artifactPath string) error {
digest, err := sha256File(artifactPath)
if err != nil {
return fmt.Errorf("hash artifact %q: %w", artifactPath, err)
}
artifactName := filepath.Base(artifactPath)
for _, subject := range subjects {
if subject.Name == artifactName && strings.EqualFold(subject.Digest["sha256"], digest) {
return nil
}
}
return fmt.Errorf("attestation subject does not match artifact %q", artifactPath)
}
func sha256File(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
@@ -0,0 +1,175 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
internalprovenance "github.com/hashicorp/packer/internal/provenance"
)
func TestDefaultSigstoreBundlePath(t *testing.T) {
cases := map[string]string{
"artifact.provenance.json": "artifact.provenance.sigstore.json",
"dir/attestation.json": "dir/attestation.sigstore.json",
"attestation": "attestation.sigstore.json",
}
for input, want := range cases {
if got := defaultSigstoreBundlePath(input); got != want {
t.Fatalf("defaultSigstoreBundlePath(%q) = %q, want %q", input, got, want)
}
}
}
func TestResolveSigstoreBundlePath(t *testing.T) {
dir := t.TempDir()
attestationPath := filepath.Join(dir, "attestation.json")
// Explicit path always wins.
if got := resolveSigstoreBundlePath(VerificationPolicy{SigstoreBundlePath: "explicit.json"}, attestationPath); got != "explicit.json" {
t.Fatalf("explicit bundle path not honored, got %q", got)
}
// No sidecar on disk means no auto-discovered path.
if got := resolveSigstoreBundlePath(VerificationPolicy{}, attestationPath); got != "" {
t.Fatalf("expected empty path when sidecar is absent, got %q", got)
}
// A sidecar next to the attestation is auto-discovered.
sidecarPath := defaultSigstoreBundlePath(attestationPath)
if err := os.WriteFile(sidecarPath, []byte("{}"), 0600); err != nil {
t.Fatalf("write sidecar: %v", err)
}
if got := resolveSigstoreBundlePath(VerificationPolicy{}, attestationPath); got != sidecarPath {
t.Fatalf("expected auto-discovered sidecar %q, got %q", sidecarPath, got)
}
}
func TestVerifyAttestationFileAutoDiscoversKeylessBundle(t *testing.T) {
originalAnchor := bundleAnchorsSigningTime
originalBundleVerifier := verifySigstoreBundleEvidence
t.Cleanup(func() {
bundleAnchorsSigningTime = originalAnchor
verifySigstoreBundleEvidence = originalBundleVerifier
})
dir := t.TempDir()
attestationPath := filepath.Join(dir, "attestation.json")
sidecarPath := defaultSigstoreBundlePath(attestationPath)
statement := internalprovenance.WrapInToto(
[]internalprovenance.Subject{{Name: "artifact.txt", Digest: internalprovenance.DigestSet{"sha256": "abc"}}},
internalprovenance.SLSAProvenanceV1PredicateType,
internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{}),
)
payload, err := MarshalPayload(statement)
if err != nil {
t.Fatalf("marshal statement: %v", err)
}
envelope := NewEnvelope(InTotoPayloadType, payload, Signature{
Sig: []byte("signature"),
CertPEM: []byte("-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"),
})
envelopeJSON, err := json.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
if err := os.WriteFile(attestationPath, envelopeJSON, 0600); err != nil {
t.Fatalf("write attestation: %v", err)
}
if err := os.WriteFile(sidecarPath, []byte("{}"), 0600); err != nil {
t.Fatalf("write sidecar bundle: %v", err)
}
bundleAnchorsSigningTime = func(path string) bool {
if path != sidecarPath {
t.Fatalf("unexpected bundle path passed to anchor check %q, want %q", path, sidecarPath)
}
return true
}
called := false
verifySigstoreBundleEvidence = func(_ Envelope, _ BackendConfig, policy VerificationPolicy) error {
called = true
if policy.SigstoreBundlePath != sidecarPath {
t.Fatalf("bundle verification used path %q, want auto-discovered %q", policy.SigstoreBundlePath, sidecarPath)
}
return nil
}
if _, err := VerifyAttestationFile(context.Background(), attestationPath, BackendConfig{
Mode: SigningModeKeyless,
KeylessIdentity: "identity",
KeylessOIDCIssuer: "issuer",
}, VerificationPolicy{}); err != nil {
t.Fatalf("verify attestation file: %v", err)
}
if !called {
t.Fatalf("expected auto-discovered bundle verification to run")
}
}
func TestVerifyAttestationFileSkipsBundleWithoutTimeAnchor(t *testing.T) {
originalAnchor := bundleAnchorsSigningTime
originalBundleVerifier := verifySigstoreBundleEvidence
t.Cleanup(func() {
bundleAnchorsSigningTime = originalAnchor
verifySigstoreBundleEvidence = originalBundleVerifier
})
dir := t.TempDir()
attestationPath := filepath.Join(dir, "attestation.json")
sidecarPath := defaultSigstoreBundlePath(attestationPath)
statement := internalprovenance.WrapInToto(
[]internalprovenance.Subject{{Name: "artifact.txt", Digest: internalprovenance.DigestSet{"sha256": "abc"}}},
internalprovenance.SLSAProvenanceV1PredicateType,
internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{}),
)
payload, err := MarshalPayload(statement)
if err != nil {
t.Fatalf("marshal statement: %v", err)
}
envelope := NewEnvelope(InTotoPayloadType, payload, Signature{
Sig: []byte("signature"),
CertPEM: []byte("-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"),
})
envelopeJSON, err := json.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
if err := os.WriteFile(attestationPath, envelopeJSON, 0600); err != nil {
t.Fatalf("write attestation: %v", err)
}
if err := os.WriteFile(sidecarPath, []byte("{}"), 0600); err != nil {
t.Fatalf("write sidecar bundle: %v", err)
}
// The sidecar exists but carries no trusted time source, so bundle-based
// verification must not be used; the legacy certificate path runs instead.
bundleAnchorsSigningTime = func(string) bool { return false }
verifySigstoreBundleEvidence = func(Envelope, BackendConfig, VerificationPolicy) error {
t.Fatalf("bundle verification must not run when the bundle lacks a time anchor")
return nil
}
// The fallback path attempts certificate-based verification with a fake
// certificate, which fails; the important assertion is that bundle
// verification was not selected.
if _, err := VerifyAttestationFile(context.Background(), attestationPath, BackendConfig{
Mode: SigningModeKeyless,
KeylessIdentity: "identity",
KeylessOIDCIssuer: "issuer",
}, VerificationPolicy{}); err == nil {
t.Fatalf("expected fallback certificate verification to fail on the fake certificate")
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package attestation
import (
"encoding/base64"
"strings"
"testing"
protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
protodsse "github.com/sigstore/protobuf-specs/gen/pb-go/dsse"
sigstorebundle "github.com/sigstore/sigstore-go/pkg/bundle"
)
// newDSSEBundle builds a minimal Sigstore bundle wrapping a DSSE envelope with
// the provided raw payload and signature. It bypasses bundle.NewBundle because
// that constructor requires full verification material; ensureBundleMatchesEnvelope
// only inspects the DSSE content.
func newDSSEBundle(payload, signature []byte) *sigstorebundle.Bundle {
return &sigstorebundle.Bundle{
Bundle: &protobundle.Bundle{
Content: &protobundle.Bundle_DsseEnvelope{
DsseEnvelope: &protodsse.Envelope{
Payload: payload,
PayloadType: InTotoPayloadType,
Signatures: []*protodsse.Signature{{Sig: signature}},
},
},
},
}
}
func TestEnsureBundleMatchesEnvelopeMatchesNonFirstSignature(t *testing.T) {
payload := []byte(`{"hello":"world"}`)
bundleSignature := []byte("the-real-signature")
bundle := newDSSEBundle(payload, bundleSignature)
// The bundle signature is the second envelope signature, not the first.
envelope := Envelope{
PayloadType: InTotoPayloadType,
Payload: base64.StdEncoding.EncodeToString(payload),
Signatures: []EnvelopeSignature{
{Sig: base64.StdEncoding.EncodeToString([]byte("a-different-signature"))},
{Sig: base64.StdEncoding.EncodeToString(bundleSignature)},
},
}
if err := ensureBundleMatchesEnvelope(bundle, envelope); err != nil {
t.Fatalf("expected bundle to match a later envelope signature, got error: %v", err)
}
}
func TestEnsureBundleMatchesEnvelopeRejectsWhenNoSignatureMatches(t *testing.T) {
payload := []byte(`{"hello":"world"}`)
bundle := newDSSEBundle(payload, []byte("the-real-signature"))
envelope := Envelope{
PayloadType: InTotoPayloadType,
Payload: base64.StdEncoding.EncodeToString(payload),
Signatures: []EnvelopeSignature{
{Sig: base64.StdEncoding.EncodeToString([]byte("a-different-signature"))},
{Sig: base64.StdEncoding.EncodeToString([]byte("another-mismatch"))},
},
}
err := ensureBundleMatchesEnvelope(bundle, envelope)
if err == nil {
t.Fatalf("expected mismatch error when no envelope signature matches the bundle")
}
if !strings.Contains(err.Error(), "does not match any attestation signature") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestEnsureBundleMatchesEnvelopeRejectsPayloadMismatch(t *testing.T) {
bundle := newDSSEBundle([]byte(`{"hello":"world"}`), []byte("sig"))
envelope := Envelope{
PayloadType: InTotoPayloadType,
Payload: base64.StdEncoding.EncodeToString([]byte(`{"hello":"tampered"}`)),
Signatures: []EnvelopeSignature{
{Sig: base64.StdEncoding.EncodeToString([]byte("sig"))},
},
}
err := ensureBundleMatchesEnvelope(bundle, envelope)
if err == nil {
t.Fatalf("expected payload mismatch to be rejected")
}
if !strings.Contains(err.Error(), "payload does not match") {
t.Fatalf("unexpected error: %v", err)
}
}
+175
View File
@@ -0,0 +1,175 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import (
"fmt"
"net/url"
"os/exec"
"path/filepath"
"strings"
)
type gitRunner func(workingDir string, args ...string) (string, error)
func DetectBuilderID(env map[string]string) string {
for _, key := range []string{"GITHUB_WORKFLOW_REF", "CI_JOB_URL", "CI_PIPELINE_URL", "BUILD_URL"} {
if value := strings.TrimSpace(env[key]); value != "" {
return value
}
}
return DefaultLocalBuilderID
}
func DetectInvocationID(env map[string]string) string {
for _, key := range []string{"GITHUB_RUN_ID", "CI_PIPELINE_ID", "CI_JOB_ID", "BUILD_ID"} {
if value := strings.TrimSpace(env[key]); value != "" {
return value
}
}
return ""
}
func DetectGitDependency(workingDir string, env map[string]string) (ResolvedDependency, bool) {
return detectGitDependency(workingDir, env, runGitCommand)
}
func detectGitDependency(workingDir string, env map[string]string, runner gitRunner) (ResolvedDependency, bool) {
if dependency, ok := detectGitHubDependency(env); ok {
return dependency, true
}
if dependency, ok := detectGitLabDependency(env); ok {
return dependency, true
}
return detectLocalGitDependency(workingDir, runner)
}
func detectGitHubDependency(env map[string]string) (ResolvedDependency, bool) {
repository := strings.TrimSpace(env["GITHUB_REPOSITORY"])
commit := strings.TrimSpace(env["GITHUB_SHA"])
if repository == "" || commit == "" {
return ResolvedDependency{}, false
}
serverURL := strings.TrimRight(strings.TrimSpace(env["GITHUB_SERVER_URL"]), "/")
if serverURL == "" {
serverURL = "https://github.com"
}
uri := fmt.Sprintf("git+%s/%s", serverURL, strings.TrimLeft(repository, "/"))
if ref := strings.TrimSpace(env["GITHUB_REF"]); ref != "" {
uri += "@" + ref
}
return ResolvedDependency{
URI: uri,
Digest: DigestSet{
"gitCommit": commit,
},
}, true
}
func detectGitLabDependency(env map[string]string) (ResolvedDependency, bool) {
projectURL := strings.TrimSpace(env["CI_PROJECT_URL"])
commit := strings.TrimSpace(env["CI_COMMIT_SHA"])
if projectURL == "" || commit == "" {
return ResolvedDependency{}, false
}
uri := "git+" + strings.TrimRight(projectURL, "/")
if ref := strings.TrimSpace(env["CI_COMMIT_REF_NAME"]); ref != "" {
uri += "@refs/heads/" + ref
}
return ResolvedDependency{
URI: uri,
Digest: DigestSet{
"gitCommit": commit,
},
}, true
}
func detectLocalGitDependency(workingDir string, runner gitRunner) (ResolvedDependency, bool) {
if runner == nil {
return ResolvedDependency{}, false
}
commit, err := runner(workingDir, "rev-parse", "HEAD")
if err != nil || commit == "" {
return ResolvedDependency{}, false
}
repositoryURL, err := runner(workingDir, "config", "--get", "remote.origin.url")
if err != nil || repositoryURL == "" {
topLevel, topLevelErr := runner(workingDir, "rev-parse", "--show-toplevel")
if topLevelErr != nil || topLevel == "" {
repositoryURL = ""
} else {
repositoryURL = localGitFileURI(topLevel)
}
}
if repositoryURL == "" {
return ResolvedDependency{}, false
}
repositoryURL = sanitizeGitRemoteURL(repositoryURL)
uri := "git+" + repositoryURL
if branch, branchErr := runner(workingDir, "rev-parse", "--abbrev-ref", "HEAD"); branchErr == nil && branch != "" && branch != "HEAD" {
uri += "@refs/heads/" + branch
}
return ResolvedDependency{
URI: uri,
Digest: DigestSet{
"gitCommit": commit,
},
}, true
}
func sanitizeGitRemoteURL(raw string) string {
// SCP-style remotes (git@host:repo) carry no userinfo — leave them unchanged
if !strings.Contains(raw, "://") {
return raw
}
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.User = nil // strips embedded username and password/token
return u.String()
}
func localGitFileURI(path string) string {
cleaned := filepath.Clean(path)
if cleaned == "" {
return ""
}
slashed := filepath.ToSlash(cleaned)
if !strings.HasPrefix(slashed, "/") {
slashed = "/" + slashed
}
return (&url.URL{Scheme: "file", Path: slashed}).String()
}
func runGitCommand(workingDir string, args ...string) (string, error) {
command := exec.Command("git", args...)
if workingDir != "" {
command.Dir = workingDir
}
output, err := command.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import (
"fmt"
"strings"
"testing"
)
func TestDetectBuilderID(t *testing.T) {
env := map[string]string{"GITHUB_WORKFLOW_REF": "acme/images/.github/workflows/build.yml@refs/heads/main"}
if got, want := DetectBuilderID(env), env["GITHUB_WORKFLOW_REF"]; got != want {
t.Fatalf("unexpected builder id %q, want %q", got, want)
}
if got, want := DetectBuilderID(map[string]string{}), DefaultLocalBuilderID; got != want {
t.Fatalf("unexpected default builder id %q, want %q", got, want)
}
}
func TestDetectInvocationID(t *testing.T) {
env := map[string]string{"CI_PIPELINE_ID": "pipeline-42"}
if got, want := DetectInvocationID(env), "pipeline-42"; got != want {
t.Fatalf("unexpected invocation id %q, want %q", got, want)
}
}
func TestDetectGitDependencyFromGitHubEnv(t *testing.T) {
dependency, ok := detectGitDependency("", map[string]string{
"GITHUB_REPOSITORY": "acme/images",
"GITHUB_SHA": "deadbeef",
"GITHUB_REF": "refs/heads/main",
}, nil)
if !ok {
t.Fatalf("expected github dependency to be detected")
}
if got, want := dependency.URI, "git+https://github.com/acme/images@refs/heads/main"; got != want {
t.Fatalf("unexpected uri %q, want %q", got, want)
}
if got, want := dependency.Digest["gitCommit"], "deadbeef"; got != want {
t.Fatalf("unexpected commit %q, want %q", got, want)
}
}
func TestDetectGitDependencyFallsBackToLocalGit(t *testing.T) {
runner := func(_ string, args ...string) (string, error) {
switch strings.Join(args, " ") {
case "rev-parse HEAD":
return "cafebabe", nil
case "config --get remote.origin.url":
return "", fmt.Errorf("missing remote")
case "rev-parse --show-toplevel":
return "/workspace/packer", nil
case "rev-parse --abbrev-ref HEAD":
return "main", nil
default:
return "", fmt.Errorf("unexpected git args %q", strings.Join(args, " "))
}
}
dependency, ok := detectGitDependency("/workspace/packer", map[string]string{}, runner)
if !ok {
t.Fatalf("expected local git dependency to be detected")
}
if got, want := dependency.URI, "git+file:///workspace/packer@refs/heads/main"; got != want {
t.Fatalf("unexpected uri %q, want %q", got, want)
}
if got, want := dependency.Digest["gitCommit"], "cafebabe"; got != want {
t.Fatalf("unexpected commit %q, want %q", got, want)
}
}
func TestDetectGitDependencyGracefullySkipsWhenUnavailable(t *testing.T) {
runner := func(_ string, _ ...string) (string, error) {
return "", fmt.Errorf("git unavailable")
}
if _, ok := detectGitDependency("/workspace/packer", map[string]string{}, runner); ok {
t.Fatalf("expected dependency detection to skip unavailable git context")
}
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import packerversion "github.com/hashicorp/packer/version"
const (
DefaultBuildType = "https://packer.io/buildtypes/hcl2/v1"
DefaultLocalBuilderID = "https://packer.io/local-build"
SLSAProvenanceV1PredicateType = "https://slsa.dev/provenance/v1"
)
type PredicateInput struct {
BuildType string
ExternalParameters map[string]interface{}
InternalParameters map[string]interface{}
ResolvedDependencies []ResolvedDependency
BuilderID string
Byproducts []Byproduct
InvocationID string
StartedOn string
FinishedOn string
}
type SLSAProvenancePredicate struct {
BuildDefinition BuildDefinition `json:"buildDefinition"`
RunDetails RunDetails `json:"runDetails"`
}
type BuildDefinition struct {
BuildType string `json:"buildType"`
ExternalParameters map[string]interface{} `json:"externalParameters"`
InternalParameters map[string]interface{} `json:"internalParameters,omitempty"`
ResolvedDependencies []ResolvedDependency `json:"resolvedDependencies,omitempty"`
}
type ResolvedDependency struct {
URI string `json:"uri"`
Digest DigestSet `json:"digest,omitempty"`
}
type RunDetails struct {
Builder Builder `json:"builder"`
Metadata Metadata `json:"metadata,omitempty"`
Byproducts []Byproduct `json:"byproducts,omitempty"`
}
type Builder struct {
ID string `json:"id"`
Version map[string]string `json:"version,omitempty"`
}
type Metadata struct {
InvocationID string `json:"invocationId,omitempty"`
StartedOn string `json:"startedOn,omitempty"`
FinishedOn string `json:"finishedOn,omitempty"`
}
type Byproduct struct {
Name string `json:"name"`
Content interface{} `json:"content,omitempty"`
}
func BuildSLSAPredicate(input PredicateInput) SLSAProvenancePredicate {
buildType := input.BuildType
if buildType == "" {
buildType = DefaultBuildType
}
builderID := input.BuilderID
if builderID == "" {
builderID = DefaultLocalBuilderID
}
externalParameters := map[string]interface{}{}
for key, value := range input.ExternalParameters {
externalParameters[key] = value
}
internalParameters := map[string]interface{}{
"packerVersion": packerversion.String(),
}
for key, value := range input.InternalParameters {
internalParameters[key] = value
}
predicate := SLSAProvenancePredicate{
BuildDefinition: BuildDefinition{
BuildType: buildType,
ExternalParameters: externalParameters,
InternalParameters: internalParameters,
ResolvedDependencies: input.ResolvedDependencies,
},
RunDetails: RunDetails{
Builder: Builder{
ID: builderID,
Version: map[string]string{
"packer": packerversion.String(),
},
},
Byproducts: input.Byproducts,
},
}
if input.InvocationID != "" || input.StartedOn != "" || input.FinishedOn != "" {
predicate.RunDetails.Metadata = Metadata{
InvocationID: input.InvocationID,
StartedOn: input.StartedOn,
FinishedOn: input.FinishedOn,
}
}
return predicate
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import "testing"
func TestBuildSLSAPredicateDefaults(t *testing.T) {
predicate := BuildSLSAPredicate(PredicateInput{})
if got, want := predicate.BuildDefinition.BuildType, DefaultBuildType; got != want {
t.Fatalf("unexpected build type %q, want %q", got, want)
}
if got, want := predicate.RunDetails.Builder.ID, DefaultLocalBuilderID; got != want {
t.Fatalf("unexpected builder id %q, want %q", got, want)
}
if predicate.BuildDefinition.InternalParameters["packerVersion"] == "" {
t.Fatalf("expected packerVersion to be populated")
}
if predicate.RunDetails.Builder.Version["packer"] == "" {
t.Fatalf("expected builder version to be populated")
}
}
func TestBuildSLSAPredicateIncludesByproducts(t *testing.T) {
predicate := BuildSLSAPredicate(PredicateInput{
BuildType: "https://packer.io/buildtypes/json/v1",
Byproducts: []Byproduct{{
Name: "cloud-artifact-identity",
Content: map[string]interface{}{
"builderId": "packer.null",
},
}},
})
if got, want := len(predicate.RunDetails.Byproducts), 1; got != want {
t.Fatalf("unexpected byproduct count %d, want %d", got, want)
}
if got, want := predicate.BuildDefinition.BuildType, "https://packer.io/buildtypes/json/v1"; got != want {
t.Fatalf("unexpected build type %q, want %q", got, want)
}
if got, want := predicate.RunDetails.Byproducts[0].Name, "cloud-artifact-identity"; got != want {
t.Fatalf("unexpected byproduct name %q, want %q", got, want)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
const StatementType = "https://in-toto.io/Statement/v1"
type Statement struct {
Type string `json:"_type"`
Subject []Subject `json:"subject"`
PredicateType string `json:"predicateType"`
Predicate interface{} `json:"predicate"`
}
func WrapInToto(subjects []Subject, predicateType string, predicate interface{}) Statement {
return Statement{
Type: StatementType,
Subject: subjects,
PredicateType: predicateType,
Predicate: predicate,
}
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import "testing"
func TestWrapInToto(t *testing.T) {
statement := WrapInToto(
[]Subject{{Name: "artifact.bin", Digest: DigestSet{"sha256": "abc123"}}},
SLSAProvenanceV1PredicateType,
BuildSLSAPredicate(PredicateInput{}),
)
if got, want := statement.Type, StatementType; got != want {
t.Fatalf("unexpected statement type %q, want %q", got, want)
}
if got, want := statement.PredicateType, SLSAProvenanceV1PredicateType; got != want {
t.Fatalf("unexpected predicate type %q, want %q", got, want)
}
if got, want := len(statement.Subject), 1; got != want {
t.Fatalf("unexpected subject count %d, want %d", got, want)
}
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
registryimage "github.com/hashicorp/packer-plugin-sdk/packer/registry/image"
)
type DigestSet map[string]string
type Subject struct {
Name string `json:"name"`
Digest DigestSet `json:"digest"`
}
func DeriveSubjects(artifact packersdk.Artifact) ([]Subject, error) {
return deriveSubjects(artifact)
}
func DeriveIdentityRecord(artifact packersdk.Artifact) (map[string]interface{}, error) {
return deriveIdentityRecord(artifact)
}
func deriveSubjects(artifact packersdk.Artifact) ([]Subject, error) {
if artifact == nil {
return nil, fmt.Errorf("artifact is nil")
}
files := artifact.Files()
if len(files) > 0 {
subjects := make([]Subject, 0, len(files))
for _, file := range files {
digest, err := sha256File(file)
if err != nil {
return nil, fmt.Errorf("hash %q: %w", file, err)
}
subjects = append(subjects, Subject{
Name: filepath.Base(file),
Digest: DigestSet{
"sha256": digest,
},
})
}
return subjects, nil
}
identity, err := deriveIdentityRecord(artifact)
if err != nil {
return nil, err
}
canonicalIdentity, err := json.Marshal(identity)
if err != nil {
return nil, fmt.Errorf("marshal artifact identity: %w", err)
}
digest := sha256.Sum256(canonicalIdentity)
return []Subject{{
Name: fmt.Sprintf("%s:%s", artifact.BuilderId(), artifact.Id()),
Digest: DigestSet{
"sha256": hex.EncodeToString(digest[:]),
},
}}, nil
}
func deriveIdentityRecord(artifact packersdk.Artifact) (map[string]interface{}, error) {
if artifact == nil {
return nil, fmt.Errorf("artifact is nil")
}
record := map[string]interface{}{
"builderId": artifact.BuilderId(),
"id": artifact.Id(),
}
state := artifact.State(registryimage.ArtifactStateURI)
if state == nil {
return record, nil
}
normalizedState, err := normalizeJSONValue(state)
if err != nil {
return record, nil
}
record["state"] = normalizedState
return record, nil
}
func normalizeJSONValue(value interface{}) (interface{}, error) {
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
var decoded interface{}
if err := json.Unmarshal(encoded, &decoded); err != nil {
return nil, err
}
return decoded, nil
}
func sha256File(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"path/filepath"
"strings"
"testing"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
registryimage "github.com/hashicorp/packer-plugin-sdk/packer/registry/image"
"github.com/hashicorp/packer-plugin-sdk/template"
filebuilder "github.com/hashicorp/packer/builder/file"
nullbuilder "github.com/hashicorp/packer/builder/null"
)
func TestDeriveSubjectsFromFiles(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
subjects, err := deriveSubjects(artifact)
if err != nil {
t.Fatalf("derive subjects: %v", err)
}
if len(subjects) != 1 {
t.Fatalf("expected one subject, got %d", len(subjects))
}
if got, want := subjects[0].Name, "package.txt"; got != want {
t.Fatalf("unexpected subject name %q, want %q", got, want)
}
expectedDigest := sha256.Sum256([]byte("Hello world!"))
if got, want := subjects[0].Digest["sha256"], hex.EncodeToString(expectedDigest[:]); got != want {
t.Fatalf("unexpected digest %q, want %q", got, want)
}
}
func TestDeriveSubjectsFromIdentity(t *testing.T) {
artifact := new(nullbuilder.NullArtifact)
subjects, err := deriveSubjects(artifact)
if err != nil {
t.Fatalf("derive subjects: %v", err)
}
if len(subjects) != 1 {
t.Fatalf("expected one subject, got %d", len(subjects))
}
if got, want := subjects[0].Name, artifact.BuilderId()+":"+artifact.Id(); got != want {
t.Fatalf("unexpected subject name %q, want %q", got, want)
}
identity, err := deriveIdentityRecord(artifact)
if err != nil {
t.Fatalf("derive identity: %v", err)
}
if _, ok := identity["state"]; !ok {
t.Fatalf("expected identity state for cloud-style artifact")
}
encodedIdentity, err := json.Marshal(identity)
if err != nil {
t.Fatalf("marshal identity: %v", err)
}
expectedDigest := sha256.Sum256(encodedIdentity)
if got, want := subjects[0].Digest["sha256"], hex.EncodeToString(expectedDigest[:]); got != want {
t.Fatalf("unexpected digest %q, want %q", got, want)
}
state := artifact.State(registryimage.ArtifactStateURI)
if state == nil {
t.Fatalf("expected registry state")
}
}
func buildFileArtifact(t *testing.T) packersdk.Artifact {
t.Helper()
target := filepath.Join(t.TempDir(), "package.txt")
config := mustTemplateJSON(t, map[string]interface{}{
"builders": []map[string]string{{
"type": "file",
"target": target,
"content": "Hello world!",
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var builder filebuilder.Builder
_, warnings, err := builder.Prepare(tpl.Builders["file"].Config)
if err != nil {
t.Fatalf("prepare builder: %v", err)
}
if len(warnings) > 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
artifact, err := builder.Run(context.Background(), packersdk.TestUi(t), nil)
if err != nil {
t.Fatalf("run builder: %v", err)
}
return artifact
}
func mustTemplateJSON(t *testing.T, value interface{}) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal template config: %v", err)
}
return string(encoded)
}
+115
View File
@@ -0,0 +1,115 @@
# Provenance Post-Processor
The `provenance` post-processor writes in-toto attestations for the incoming
artifact.
It supports:
- SLSA provenance statements enriched with source-control and CI metadata.
- SBOM sidecars plus SBOM attestations.
- Optional DSSE signing with a configurable signer and verifier.
Signing defaults to `none`, which writes the unsigned JSON statement.
## Source detection
The provenance statement records the build's source repository as a resolved
dependency. Packer detects this from the Git repository containing the current
working directory (the directory Packer runs in). Set `source_uri` to override
the detected value, for example when the build runs outside the source checkout
or the remote URL should be normalized.
Signing modes:
- `key` signs with a PEM private key and verifies with either the signer's
derived public key or an explicit verifier PEM.
- `kms` signs with a KMS or Vault URI such as `awskms://...`, `gcpkms://...`,
`azurekms://...`, or `hashivault://...`. Verification uses the fetched KMS
public key or an explicit verifier PEM.
- `keyless` signs with an ephemeral keypair and a Fulcio-issued certificate.
It requires an ambient OIDC token, such as `SIGSTORE_ID_TOKEN`, or a CI
provider token that can be exchanged for a Sigstore identity. When using the
built-in verifier path, also configure the expected signing identity and OIDC
issuer. Set an optional trusted-root JSON path to pin verification to a
specific Sigstore root; otherwise the public Sigstore trusted root is fetched.
Keyless signing can also emit a Sigstore bundle sidecar and upload to Rekor.
Example:
```hcl
post-processor "provenance" {
build_type = "https://packer.io/buildtypes/hcl2/v1"
template = "ubuntu.pkr.hcl"
only_builds = ["qemu.ubuntu"]
user_variables = {
region = "us-east-1"
}
sbom = true
}
```
Signed example:
```hcl
post-processor "provenance" {
signing_mode = "key"
signer = "keys/provenance-signing.pem"
verifier = "keys/provenance-signing.pub.pem"
sbom = true
}
```
KMS example:
```hcl
post-processor "provenance" {
signing_mode = "kms"
signer = "awskms://alias/packer-provenance"
sbom = true
}
```
Keyless example:
```hcl
post-processor "provenance" {
signing_mode = "keyless"
fulcio_url = "https://fulcio.sigstore.dev"
rekor_url = "https://rekor.sigstore.dev"
upload_tlog = true
keyless_identity = "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"
keyless_oidc_issuer = "https://token.actions.githubusercontent.com"
trusted_root_path = "sigstore-trusted-root.json"
}
```
With keyless signing, Packer writes an additional `*.sigstore.json` sidecar next
to each signed attestation. When `upload_tlog = true`, that bundle includes
Rekor-backed transparency evidence for `packer verify-attestation -bundle ...`.
During a build, keyless verification enforces the Fulcio certificate chain and
the configured identity policy. For Rekor-backed verification, run
`packer verify-attestation` with the generated Sigstore bundle and the
`-require-rekor` and/or `-require-timestamp` flags.
## SLSA levels and CI
SLSA Build levels are mostly properties of the build platform, not the build
tool. Packer is a tool, so its reach is:
| SLSA Build level | Requirement | Packer's role | What Packer provides |
|---|---|---|---|
| **L1** | Provenance exists and is distributed | Fully in Packer | Provenance generation |
| **L2** | Provenance signed by a hosted platform | Packer signs via CI OIDC identity | Keyless signing in CI |
| **L3** | Hardened platform; build steps cannot reach the signing key | Platform property; Packer is compatible | Delegated-signing pattern |
| **L4** | — | Not defined in SLSA v1.0 | — |
Packer generates SLSA Provenance v1 and reaches Build L1, and L2 when run on a
hosted CI with keyless signing. L3 is a property of the build platform: it
requires the signing key to be unreachable by the build steps. Packer does not
confer L3 on its own, but the delegated-signing pattern is compatible with an L3
platform.
Reference GitHub Actions workflows for both the L2 keyless pattern and the
L3-compatible delegated-signing pattern live under
[`examples/ci/`](../../examples/ci/).
@@ -0,0 +1,57 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import (
"testing"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/hcl/v2/hclsyntax"
)
// TestConfigureHCL2DefaultProvenanceEnabled reproduces the HCL2 decode path used
// by packer core (hcldec.Decode -> cty.Value -> Configure) to ensure the
// default-true `provenance` gate survives when the field is omitted in HCL.
func TestConfigureHCL2DefaultProvenanceEnabled(t *testing.T) {
src := `output_dir = "out"`
body, diags := hclsyntax.ParseConfig([]byte(src), "test.pkr.hcl", hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
t.Fatalf("parse hcl: %s", diags)
}
var pp PostProcessor
spec := pp.ConfigSpec()
val, decodeDiags := hcldec.Decode(body.Body, spec, nil)
if decodeDiags.HasErrors() {
t.Fatalf("decode hcl2 spec: %s", decodeDiags)
}
if err := pp.Configure(val); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
if pp.config.Provenance.False() {
t.Fatalf("expected provenance to remain enabled by default under HCL2 decode")
}
if pp.config.SBOMFormat == "" {
t.Fatalf("expected SBOMFormat default to survive HCL2 decode, got empty")
}
if pp.config.SBOMScope == "" {
t.Fatalf("expected SBOMScope default to survive HCL2 decode, got empty")
}
if pp.config.BuildType == "" {
t.Fatalf("expected BuildType default to survive HCL2 decode, got empty")
}
if pp.config.SigningMode == "" {
t.Fatalf("expected SigningMode default to survive HCL2 decode, got empty")
}
if pp.config.FulcioURL == "" {
t.Fatalf("expected FulcioURL default to survive HCL2 decode, got empty")
}
if pp.config.RekorURL == "" {
t.Fatalf("expected RekorURL default to survive HCL2 decode, got empty")
}
}
+747
View File
@@ -0,0 +1,747 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
//go:generate packer-sdc mapstructure-to-hcl2 -type Config
//go:generate packer-sdc struct-markdown
package provenance
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/packer-plugin-sdk/common"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/hashicorp/packer-plugin-sdk/template/config"
"github.com/hashicorp/packer-plugin-sdk/template/interpolate"
internalattestation "github.com/hashicorp/packer/internal/attestation"
internalprovenance "github.com/hashicorp/packer/internal/provenance"
internalsbom "github.com/hashicorp/packer/internal/sbom"
)
var buildSigstoreBundleForSigner = internalattestation.BuildBundleForSigner
type Config struct {
common.PackerConfig `mapstructure:",squash"`
// Whether to emit provenance attestations. Enabled by default; set to
// `false` to skip the post-processor and pass the artifact through unchanged.
Provenance config.Trilean `mapstructure:"provenance"`
// The SLSA `buildType` URI recorded in the provenance predicate. Defaults to
// `https://packer.io/buildtypes/hcl2/v1`.
BuildType string `mapstructure:"build_type"`
// Directory where attestation sidecar files are written. Defaults to the
// directory containing the artifact's first file, or the current directory
// for artifacts without local files.
OutputDir string `mapstructure:"output_dir"`
// Path to the Packer template that produced the artifact. Recorded as an
// external parameter in the provenance predicate.
TemplatePath string `mapstructure:"template"`
// The list of builds this artifact came from. Recorded as an external
// parameter in the provenance predicate.
OnlyBuilds []string `mapstructure:"only_builds"`
// Additional user variables to record as external parameters in the
// provenance predicate. Values for variables named in
// `packer_sensitive_variables` are redacted.
UserVariables map[string]string `mapstructure:"user_variables"`
// Overrides the auto-detected source repository URI recorded as a resolved
// dependency. By default the source is detected from the Git repository
// containing the current working directory or from CI environment variables.
SourceURI string `mapstructure:"source_uri"`
// Whether to also generate a software bill of materials (SBOM) and a
// corresponding SBOM attestation alongside the provenance statement.
SBOM bool `mapstructure:"sbom"`
// The SBOM output format, either `cyclonedx` (default) or `spdx`.
SBOMFormat string `mapstructure:"sbom_format"`
// The path to scan when generating the SBOM. Defaults to the artifact's
// files or their common parent directory. Required when the artifact files
// span multiple directories.
SBOMScanPath string `mapstructure:"sbom_scan_path"`
// The SBOM scan scope, either `squashed` (default) or `all-layers`.
SBOMScope string `mapstructure:"sbom_scope"`
// Glob patterns of paths to exclude from the SBOM scan.
SBOMExclude []string `mapstructure:"sbom_exclude"`
// The signing mode for attestations: `none` (default, unsigned JSON),
// `key` (local PEM key), `kms` (KMS or Vault URI), or `keyless` (Sigstore
// Fulcio).
SigningMode string `mapstructure:"signing_mode"`
// The signer reference. A PEM private key path for `key` mode, or a KMS or
// Vault URI such as `awskms://...`, `gcpkms://...`, `azurekms://...`, or
// `hashivault://...` for `kms` mode.
Signer string `mapstructure:"signer"`
// An alias for `signer`. When both are set they must be equal.
Key string `mapstructure:"key"`
// The PEM verifier path used to verify the signature in `key` and `kms`
// modes. Defaults to the signer's derived public key.
Verifier string `mapstructure:"verifier"`
// The Fulcio certificate authority URL used for `keyless` signing. Defaults
// to `https://fulcio.sigstore.dev`.
FulcioURL string `mapstructure:"fulcio_url"`
// The Rekor transparency log URL used when `upload_tlog` is enabled.
// Defaults to `https://rekor.sigstore.dev`.
RekorURL string `mapstructure:"rekor_url"`
// Whether to upload the `keyless` signature to the Rekor transparency log
// and emit a Sigstore bundle carrying the transparency evidence.
UploadTlog bool `mapstructure:"upload_tlog"`
// An optional path to a Sigstore trusted-root JSON file used to pin keyless
// verification. When unset, the public Sigstore trusted root is fetched.
TrustedRootPath string `mapstructure:"trusted_root_path"`
// The expected signing identity for `keyless` mode, such as the workflow
// ref `https://github.com/OWNER/REPO/.github/workflows/build.yml@refs/heads/main`.
// Required for keyless signing.
KeylessIdentity string `mapstructure:"keyless_identity"`
// The expected OIDC issuer for `keyless` mode, such as
// `https://token.actions.githubusercontent.com`. Required for keyless signing.
KeylessOIDCIssuer string `mapstructure:"keyless_oidc_issuer"`
ctx interpolate.Context
}
type PostProcessor struct {
config Config
now func() time.Time
env map[string]string
workingDir string
generateSBOM func(context.Context, internalsbom.Config) ([]byte, error)
signingResourcesFn func(context.Context, internalattestation.BackendConfig) (internalattestation.Signer, internalattestation.Verifier, error)
}
func (p *PostProcessor) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() }
func (p *PostProcessor) Configure(raws ...interface{}) error {
err := config.Decode(&p.config, &config.DecodeOpts{
PluginType: "packer.post-processor.provenance",
Interpolate: true,
InterpolateContext: &p.config.ctx,
InterpolateFilter: &interpolate.RenderFilter{
Exclude: []string{},
},
}, raws...)
if err != nil {
return err
}
// Defaults are applied after decoding because the HCL2 decode path zeroes
// unset fields, which would otherwise clobber any pre-decode defaults.
if p.config.BuildType == "" {
p.config.BuildType = internalprovenance.DefaultBuildType
}
if p.config.SBOMFormat == "" {
p.config.SBOMFormat = string(internalsbom.FormatCycloneDX)
}
if p.config.SBOMScope == "" {
p.config.SBOMScope = internalsbom.ScopeSquashed
}
if p.config.SigningMode == "" {
p.config.SigningMode = internalattestation.SigningModeNone
}
if p.config.FulcioURL == "" {
p.config.FulcioURL = "https://fulcio.sigstore.dev"
}
if p.config.RekorURL == "" {
p.config.RekorURL = "https://rekor.sigstore.dev"
}
if p.config.OutputDir != "" {
if err := interpolate.Validate(p.config.OutputDir, &p.config.ctx); err != nil {
return fmt.Errorf("error parsing output_dir template: %w", err)
}
}
if _, err := p.signingBackendConfig(); err != nil {
return err
}
if p.generateSBOM == nil {
p.generateSBOM = func(ctx context.Context, cfg internalsbom.Config) ([]byte, error) {
return internalsbom.NewGenerator(cfg).Generate(ctx)
}
}
return nil
}
func (p *PostProcessor) PostProcess(ctx context.Context, ui packersdk.Ui, source packersdk.Artifact) (packersdk.Artifact, bool, bool, error) {
if p.config.Provenance.False() {
return source, true, true, nil
}
env := p.currentEnv()
select {
case <-ctx.Done():
return source, true, true, ctx.Err()
default:
}
subjects, err := internalprovenance.DeriveSubjects(source)
if err != nil {
return source, true, true, err
}
var byproducts []internalprovenance.Byproduct
if len(source.Files()) == 0 {
identityRecord, err := internalprovenance.DeriveIdentityRecord(source)
if err != nil {
return source, true, true, err
}
identityBytes, err := json.Marshal(identityRecord)
if err != nil {
return source, true, true, err
}
byproducts = append(byproducts, internalprovenance.Byproduct{
Name: "cloud-artifact-identity",
Content: base64.StdEncoding.EncodeToString(identityBytes),
})
}
invocationID := internalprovenance.DetectInvocationID(env)
if invocationID == "" {
invocationID, _ = uuid.GenerateUUID()
}
predicate := internalprovenance.BuildSLSAPredicate(internalprovenance.PredicateInput{
BuildType: p.config.BuildType,
ExternalParameters: p.externalParameters(env),
InternalParameters: p.internalParameters(),
ResolvedDependencies: p.resolvedDependencies(env),
BuilderID: internalprovenance.DetectBuilderID(env),
Byproducts: byproducts,
InvocationID: invocationID,
})
statement := internalprovenance.WrapInToto(subjects, internalprovenance.SLSAProvenanceV1PredicateType, predicate)
paths, err := p.outputPaths(source)
if err != nil {
return source, true, true, err
}
if err := p.writeAttestation(ctx, ui, statement, paths.ProvenanceStatement); err != nil {
return source, true, true, err
}
if p.config.SBOM {
if err := p.writeSBOMAttestation(ctx, ui, source, subjects, paths); err != nil {
return source, true, true, err
}
}
return source, true, true, nil
}
const (
predicateTypeCycloneDX = "https://cyclonedx.org/bom"
predicateTypeSPDX = "https://spdx.dev/Document"
)
// redactedSensitiveValue replaces sensitive user-variable values in the
// provenance predicate so secrets are never written to the attestation.
const redactedSensitiveValue = "[sensitive value redacted]"
type outputPaths struct {
BaseDir string
Stem string
ProvenanceStatement string
SBOMRaw string
SBOMAttestation string
}
func (p *PostProcessor) writeSBOMAttestation(ctx context.Context, ui packersdk.Ui, source packersdk.Artifact, subjects []internalprovenance.Subject, paths outputPaths) error {
format, rawSBOM, err := p.resolveSBOM(ctx, source, paths)
if err != nil {
return err
}
predicate, predicateType, err := buildSBOMPredicate(rawSBOM, format)
if err != nil {
return err
}
statement := internalprovenance.WrapInToto(subjects, predicateType, predicate)
if err := p.writeAttestation(ctx, ui, statement, paths.SBOMAttestation); err != nil {
return err
}
ui.Say(fmt.Sprintf("Wrote SBOM to %s", paths.SBOMRaw))
return nil
}
func (p *PostProcessor) writeAttestation(ctx context.Context, ui packersdk.Ui, statement interface{}, outputPath string) error {
if p.config.SigningMode == internalattestation.SigningModeNone {
payload, err := json.MarshalIndent(statement, "", " ")
if err != nil {
return fmt.Errorf("marshal attestation payload: %w", err)
}
if err := atomicWriteFile(outputPath, payload, 0664); err != nil {
return fmt.Errorf("write attestation %q: %w", outputPath, err)
}
ui.Say(fmt.Sprintf("Wrote attestation to %s", outputPath))
return nil
}
backendConfig, err := p.signingBackendConfig()
if err != nil {
return err
}
signer, verifier, err := p.signingResources(ctx, backendConfig)
if err != nil {
return err
}
payload, err := internalattestation.MarshalPayload(statement)
if err != nil {
return fmt.Errorf("marshal canonical attestation payload: %w", err)
}
bundlePath := sigstoreBundleOutputPath(outputPath)
bundleJSON := []byte(nil)
var envelope internalattestation.Envelope
if backendConfig.Mode == internalattestation.SigningModeKeyless {
envelope, bundleJSON, err = buildSigstoreBundleForSigner(ctx, signer, backendConfig, internalattestation.InTotoPayloadType, payload)
if err != nil {
return fmt.Errorf("sign attestation with Sigstore bundle: %w", err)
}
} else {
signature, signErr := signer.Sign(ctx, internalattestation.InTotoPayloadType, payload)
if signErr != nil {
return fmt.Errorf("sign attestation: %w", signErr)
}
envelope = internalattestation.NewEnvelope(internalattestation.InTotoPayloadType, payload, signature)
}
if err := internalattestation.VerifyEnvelope(ctx, envelope, verifier); err != nil {
return fmt.Errorf("verify signed attestation: %w", err)
}
output, err := json.MarshalIndent(envelope, "", " ")
if err != nil {
return fmt.Errorf("marshal signed envelope: %w", err)
}
if err := atomicWriteFile(outputPath, output, 0664); err != nil {
return fmt.Errorf("write attestation %q: %w", outputPath, err)
}
if len(bundleJSON) > 0 {
if err := atomicWriteFile(bundlePath, bundleJSON, 0664); err != nil {
return fmt.Errorf("write Sigstore bundle %q: %w", bundlePath, err)
}
ui.Say(fmt.Sprintf("Wrote Sigstore bundle to %s", bundlePath))
}
ui.Say(fmt.Sprintf("Wrote attestation to %s", outputPath))
return nil
}
func (p *PostProcessor) signingResources(ctx context.Context, backendConfig internalattestation.BackendConfig) (internalattestation.Signer, internalattestation.Verifier, error) {
if p.signingResourcesFn != nil {
return p.signingResourcesFn(ctx, backendConfig)
}
if backendConfig.Mode == internalattestation.SigningModeNone {
return nil, nil, nil
}
signer, err := internalattestation.NewSigner(ctx, backendConfig)
if err != nil {
return nil, nil, err
}
verifier, err := internalattestation.NewVerifier(ctx, backendConfig, signer)
if err != nil {
return nil, nil, err
}
return signer, verifier, nil
}
func (p *PostProcessor) signingBackendConfig() (internalattestation.BackendConfig, error) {
mode := p.config.SigningMode
if mode == "" {
mode = internalattestation.SigningModeNone
}
signerRef := p.config.Signer
if p.config.Key != "" {
if signerRef != "" && signerRef != p.config.Key {
return internalattestation.BackendConfig{}, fmt.Errorf("signer and key must match when both are set")
}
signerRef = p.config.Key
}
switch mode {
case internalattestation.SigningModeNone:
return internalattestation.BackendConfig{Mode: mode}, nil
case internalattestation.SigningModeKey:
if signerRef == "" {
return internalattestation.BackendConfig{}, fmt.Errorf("signing_mode %q requires signer or key", mode)
}
return internalattestation.BackendConfig{
Mode: mode,
SignerRef: signerRef,
VerifierRef: p.config.Verifier,
Env: p.currentEnv(),
}, nil
case internalattestation.SigningModeKMS:
if signerRef == "" {
return internalattestation.BackendConfig{}, fmt.Errorf("signing_mode %q requires signer or key", mode)
}
if !isRecognizedKMSSigner(signerRef) {
return internalattestation.BackendConfig{}, fmt.Errorf("signing_mode %q requires a recognized KMS or Vault URI: awskms://, gcpkms://, azurekms://, or hashivault://", mode)
}
return internalattestation.BackendConfig{
Mode: mode,
SignerRef: signerRef,
VerifierRef: p.config.Verifier,
Env: p.currentEnv(),
}, nil
case internalattestation.SigningModeKeyless:
if p.config.Verifier != "" {
return internalattestation.BackendConfig{}, fmt.Errorf("signing_mode %q does not support verifier overrides; keyless attestations are verified against keyless_identity and keyless_oidc_issuer", mode)
}
if strings.TrimSpace(p.config.KeylessIdentity) == "" || strings.TrimSpace(p.config.KeylessOIDCIssuer) == "" {
return internalattestation.BackendConfig{}, fmt.Errorf("signing_mode %q requires keyless_identity and keyless_oidc_issuer", mode)
}
return internalattestation.BackendConfig{
Mode: mode,
Env: p.currentEnv(),
FulcioURL: p.config.FulcioURL,
RekorURL: p.config.RekorURL,
UploadTlog: p.config.UploadTlog,
TrustedRootPath: p.config.TrustedRootPath,
KeylessIdentity: p.config.KeylessIdentity,
KeylessOIDCIssuer: p.config.KeylessOIDCIssuer,
}, nil
default:
return internalattestation.BackendConfig{}, fmt.Errorf("signing_mode %q is not implemented", mode)
}
}
func isRecognizedKMSSigner(value string) bool {
for _, prefix := range []string{"awskms://", "gcpkms://", "azurekms://", "hashivault://"} {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func (p *PostProcessor) resolveSBOM(ctx context.Context, source packersdk.Artifact, paths outputPaths) (internalsbom.Format, []byte, error) {
// The SBOM is always regenerated so it reflects the artifact being attested.
// Reusing a pre-existing SBOM file could attest stale contents if the
// artifact changed between runs.
format, err := internalsbom.ParseFormatFromArgs(p.config.SBOMFormat)
if err != nil {
return "", nil, err
}
scanPath, err := p.resolveSBOMScanPath(source)
if err != nil {
return "", nil, err
}
rawSBOM, err := p.generateSBOM(ctx, internalsbom.Config{
ScanPath: scanPath,
Format: format,
Scope: p.config.SBOMScope,
Exclude: append([]string(nil), p.config.SBOMExclude...),
})
if err != nil {
return "", nil, fmt.Errorf("generate SBOM: %w", err)
}
if err := atomicWriteFile(paths.SBOMRaw, rawSBOM, 0664); err != nil {
return "", nil, fmt.Errorf("write SBOM %q: %w", paths.SBOMRaw, err)
}
return format, rawSBOM, nil
}
func (p *PostProcessor) resolveSBOMScanPath(source packersdk.Artifact) (string, error) {
if p.config.SBOMScanPath != "" {
return p.config.SBOMScanPath, nil
}
files := source.Files()
if len(files) == 1 {
return files[0], nil
}
if len(files) > 1 {
parent := filepath.Dir(files[0])
for _, file := range files[1:] {
if filepath.Dir(file) != parent {
return "", fmt.Errorf("sbom=true requires sbom_scan_path when artifact files span multiple directories")
}
}
return parent, nil
}
return "", fmt.Errorf("sbom=true requires local artifact files or sbom_scan_path")
}
func buildSBOMPredicate(rawSBOM []byte, format internalsbom.Format) (interface{}, string, error) {
decoder := json.NewDecoder(bytes.NewReader(rawSBOM))
decoder.UseNumber()
var predicate interface{}
if err := decoder.Decode(&predicate); err != nil {
return nil, "", fmt.Errorf("decode SBOM payload: %w", err)
}
switch format {
case internalsbom.FormatCycloneDX:
return predicate, predicateTypeCycloneDX, nil
case internalsbom.FormatSPDX:
return predicate, predicateTypeSPDX, nil
default:
return nil, "", fmt.Errorf("unsupported SBOM format %q", format)
}
}
func (p *PostProcessor) externalParameters(env map[string]string) map[string]interface{} {
externalParameters := map[string]interface{}{}
if p.config.TemplatePath != "" {
externalParameters["template"] = p.config.TemplatePath
}
if len(p.config.OnlyBuilds) > 0 {
externalParameters["onlyBuilds"] = append([]string(nil), p.config.OnlyBuilds...)
}
userVariables := collectUserVariables(env)
for key, value := range p.config.UserVariables {
userVariables[key] = value
}
redactSensitiveVariables(userVariables, p.config.PackerSensitiveVars)
if len(userVariables) > 0 {
externalParameters["userVariables"] = userVariables
}
if len(externalParameters) == 0 {
return nil
}
return externalParameters
}
func (p *PostProcessor) internalParameters() map[string]interface{} {
return map[string]interface{}{
"packerBuildName": p.config.PackerBuildName,
"packerBuilderType": p.config.PackerBuilderType,
}
}
func (p *PostProcessor) resolvedDependencies(env map[string]string) []internalprovenance.ResolvedDependency {
workingDir := p.currentWorkingDir()
dependency, ok := internalprovenance.DetectGitDependency(workingDir, env)
if p.config.SourceURI != "" {
if ok {
dependency.URI = p.config.SourceURI
} else {
dependency = internalprovenance.ResolvedDependency{URI: p.config.SourceURI}
ok = true
}
}
if !ok {
return nil
}
return []internalprovenance.ResolvedDependency{dependency}
}
func (p *PostProcessor) currentEnv() map[string]string {
if p.env != nil {
copiedEnv := make(map[string]string, len(p.env))
for key, value := range p.env {
copiedEnv[key] = value
}
return copiedEnv
}
env := make(map[string]string)
for _, item := range os.Environ() {
parts := strings.SplitN(item, "=", 2)
if len(parts) != 2 {
continue
}
env[parts[0]] = parts[1]
}
return env
}
func (p *PostProcessor) currentWorkingDir() string {
if p.workingDir != "" {
return p.workingDir
}
workingDir, err := os.Getwd()
if err != nil {
return ""
}
return workingDir
}
func collectUserVariables(env map[string]string) map[string]string {
userVariables := map[string]string{}
keys := make([]string, 0)
for key := range env {
if strings.HasPrefix(key, "PKR_VAR_") {
keys = append(keys, key)
}
}
sort.Strings(keys)
for _, key := range keys {
userVariables[strings.TrimPrefix(key, "PKR_VAR_")] = env[key]
}
return userVariables
}
// redactSensitiveVariables replaces the values of any user variables whose names
// were marked sensitive (packer_sensitive_variables) so that secrets are not
// embedded in the provenance predicate, per SLSA guidance.
func redactSensitiveVariables(userVariables map[string]string, sensitiveKeys []string) {
for _, key := range sensitiveKeys {
if _, ok := userVariables[key]; ok {
userVariables[key] = redactedSensitiveValue
}
}
}
func (p *PostProcessor) outputPaths(source packersdk.Artifact) (outputPaths, error) {
baseDir := p.config.OutputDir
if baseDir == "" && len(source.Files()) > 0 {
baseDir = filepath.Dir(source.Files()[0])
}
if baseDir == "" {
baseDir = "."
}
if err := os.MkdirAll(baseDir, 0755); err != nil {
return outputPaths{}, fmt.Errorf("create output dir %q: %w", baseDir, err)
}
name := p.outputStem(source)
sbomFormat := internalsbom.FormatCycloneDX
if parsed, err := internalsbom.ParseFormatFromArgs(p.config.SBOMFormat); err == nil {
sbomFormat = parsed
}
sbomRaw := filepath.Join(baseDir, name+".sbom.cdx.json")
if sbomFormat == internalsbom.FormatSPDX {
sbomRaw = filepath.Join(baseDir, name+".sbom.spdx.json")
}
return outputPaths{
BaseDir: baseDir,
Stem: name,
ProvenanceStatement: filepath.Join(baseDir, name+".provenance.json"),
SBOMRaw: sbomRaw,
SBOMAttestation: filepath.Join(baseDir, name+".sbom.att.json"),
}, nil
}
// outputStem returns the base filename used for all provenance outputs. When a
// build name is available it is prefixed so that parallel builds writing to a
// shared output directory cannot collide on the same output paths.
func (p *PostProcessor) outputStem(source packersdk.Artifact) string {
base := artifactStem(source)
buildName := sanitizeFilename(strings.TrimSpace(p.config.PackerBuildName))
if buildName == "" || base == buildName || strings.HasPrefix(base, buildName+".") {
return base
}
return buildName + "." + base
}
func artifactStem(source packersdk.Artifact) string {
if files := source.Files(); len(files) > 0 {
return filepath.Base(files[0])
}
return sanitizeFilename(fmt.Sprintf("%s-%s", source.BuilderId(), source.Id()))
}
func sigstoreBundleOutputPath(attestationPath string) string {
if strings.HasSuffix(attestationPath, ".json") {
return strings.TrimSuffix(attestationPath, ".json") + ".sigstore.json"
}
return attestationPath + ".sigstore.json"
}
func sanitizeFilename(value string) string {
return strings.Map(func(r rune) rune {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
return r
case r == '.', r == '-', r == '_':
return r
default:
return '_'
}
}, value)
}
// atomicWriteFile writes data to path atomically by writing to a temporary file
// in the same directory and renaming it into place. This prevents partially
// written or interleaved outputs when builds run in parallel, and ensures a
// crash mid-write cannot leave a corrupt attestation on disk.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
committed := false
defer func() {
if !committed {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
committed = true
return nil
}
@@ -0,0 +1,89 @@
// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT.
package provenance
import (
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/zclconf/go-cty/cty"
)
// FlatConfig is an auto-generated flat version of Config.
// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up.
type FlatConfig struct {
PackerBuildName *string `mapstructure:"packer_build_name" cty:"packer_build_name" hcl:"packer_build_name"`
PackerBuilderType *string `mapstructure:"packer_builder_type" cty:"packer_builder_type" hcl:"packer_builder_type"`
PackerCoreVersion *string `mapstructure:"packer_core_version" cty:"packer_core_version" hcl:"packer_core_version"`
PackerDebug *bool `mapstructure:"packer_debug" cty:"packer_debug" hcl:"packer_debug"`
PackerForce *bool `mapstructure:"packer_force" cty:"packer_force" hcl:"packer_force"`
PackerOnError *string `mapstructure:"packer_on_error" cty:"packer_on_error" hcl:"packer_on_error"`
PackerUserVars map[string]string `mapstructure:"packer_user_variables" cty:"packer_user_variables" hcl:"packer_user_variables"`
PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables" hcl:"packer_sensitive_variables"`
Provenance *bool `mapstructure:"provenance" cty:"provenance" hcl:"provenance"`
BuildType *string `mapstructure:"build_type" cty:"build_type" hcl:"build_type"`
OutputDir *string `mapstructure:"output_dir" cty:"output_dir" hcl:"output_dir"`
TemplatePath *string `mapstructure:"template" cty:"template" hcl:"template"`
OnlyBuilds []string `mapstructure:"only_builds" cty:"only_builds" hcl:"only_builds"`
UserVariables map[string]string `mapstructure:"user_variables" cty:"user_variables" hcl:"user_variables"`
SourceURI *string `mapstructure:"source_uri" cty:"source_uri" hcl:"source_uri"`
SBOM *bool `mapstructure:"sbom" cty:"sbom" hcl:"sbom"`
SBOMFormat *string `mapstructure:"sbom_format" cty:"sbom_format" hcl:"sbom_format"`
SBOMScanPath *string `mapstructure:"sbom_scan_path" cty:"sbom_scan_path" hcl:"sbom_scan_path"`
SBOMScope *string `mapstructure:"sbom_scope" cty:"sbom_scope" hcl:"sbom_scope"`
SBOMExclude []string `mapstructure:"sbom_exclude" cty:"sbom_exclude" hcl:"sbom_exclude"`
SigningMode *string `mapstructure:"signing_mode" cty:"signing_mode" hcl:"signing_mode"`
Signer *string `mapstructure:"signer" cty:"signer" hcl:"signer"`
Key *string `mapstructure:"key" cty:"key" hcl:"key"`
Verifier *string `mapstructure:"verifier" cty:"verifier" hcl:"verifier"`
FulcioURL *string `mapstructure:"fulcio_url" cty:"fulcio_url" hcl:"fulcio_url"`
RekorURL *string `mapstructure:"rekor_url" cty:"rekor_url" hcl:"rekor_url"`
UploadTlog *bool `mapstructure:"upload_tlog" cty:"upload_tlog" hcl:"upload_tlog"`
TrustedRootPath *string `mapstructure:"trusted_root_path" cty:"trusted_root_path" hcl:"trusted_root_path"`
KeylessIdentity *string `mapstructure:"keyless_identity" cty:"keyless_identity" hcl:"keyless_identity"`
KeylessOIDCIssuer *string `mapstructure:"keyless_oidc_issuer" cty:"keyless_oidc_issuer" hcl:"keyless_oidc_issuer"`
}
// FlatMapstructure returns a new FlatConfig.
// FlatConfig is an auto-generated flat version of Config.
// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up.
func (*Config) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } {
return new(FlatConfig)
}
// HCL2Spec returns the hcl spec of a Config.
// This spec is used by HCL to read the fields of Config.
// The decoded values from this spec will then be applied to a FlatConfig.
func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec {
s := map[string]hcldec.Spec{
"packer_build_name": &hcldec.AttrSpec{Name: "packer_build_name", Type: cty.String, Required: false},
"packer_builder_type": &hcldec.AttrSpec{Name: "packer_builder_type", Type: cty.String, Required: false},
"packer_core_version": &hcldec.AttrSpec{Name: "packer_core_version", Type: cty.String, Required: false},
"packer_debug": &hcldec.AttrSpec{Name: "packer_debug", Type: cty.Bool, Required: false},
"packer_force": &hcldec.AttrSpec{Name: "packer_force", Type: cty.Bool, Required: false},
"packer_on_error": &hcldec.AttrSpec{Name: "packer_on_error", Type: cty.String, Required: false},
"packer_user_variables": &hcldec.AttrSpec{Name: "packer_user_variables", Type: cty.Map(cty.String), Required: false},
"packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false},
"provenance": &hcldec.AttrSpec{Name: "provenance", Type: cty.Bool, Required: false},
"build_type": &hcldec.AttrSpec{Name: "build_type", Type: cty.String, Required: false},
"output_dir": &hcldec.AttrSpec{Name: "output_dir", Type: cty.String, Required: false},
"template": &hcldec.AttrSpec{Name: "template", Type: cty.String, Required: false},
"only_builds": &hcldec.AttrSpec{Name: "only_builds", Type: cty.List(cty.String), Required: false},
"user_variables": &hcldec.AttrSpec{Name: "user_variables", Type: cty.Map(cty.String), Required: false},
"source_uri": &hcldec.AttrSpec{Name: "source_uri", Type: cty.String, Required: false},
"sbom": &hcldec.AttrSpec{Name: "sbom", Type: cty.Bool, Required: false},
"sbom_format": &hcldec.AttrSpec{Name: "sbom_format", Type: cty.String, Required: false},
"sbom_scan_path": &hcldec.AttrSpec{Name: "sbom_scan_path", Type: cty.String, Required: false},
"sbom_scope": &hcldec.AttrSpec{Name: "sbom_scope", Type: cty.String, Required: false},
"sbom_exclude": &hcldec.AttrSpec{Name: "sbom_exclude", Type: cty.List(cty.String), Required: false},
"signing_mode": &hcldec.AttrSpec{Name: "signing_mode", Type: cty.String, Required: false},
"signer": &hcldec.AttrSpec{Name: "signer", Type: cty.String, Required: false},
"key": &hcldec.AttrSpec{Name: "key", Type: cty.String, Required: false},
"verifier": &hcldec.AttrSpec{Name: "verifier", Type: cty.String, Required: false},
"fulcio_url": &hcldec.AttrSpec{Name: "fulcio_url", Type: cty.String, Required: false},
"rekor_url": &hcldec.AttrSpec{Name: "rekor_url", Type: cty.String, Required: false},
"upload_tlog": &hcldec.AttrSpec{Name: "upload_tlog", Type: cty.Bool, Required: false},
"trusted_root_path": &hcldec.AttrSpec{Name: "trusted_root_path", Type: cty.String, Required: false},
"keyless_identity": &hcldec.AttrSpec{Name: "keyless_identity", Type: cty.String, Required: false},
"keyless_oidc_issuer": &hcldec.AttrSpec{Name: "keyless_oidc_issuer", Type: cty.String, Required: false},
}
return s
}
@@ -0,0 +1,840 @@
// Copyright IBM Corp. 2024, 2025
// SPDX-License-Identifier: BUSL-1.1
package provenance
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/hashicorp/packer-plugin-sdk/template"
filebuilder "github.com/hashicorp/packer/builder/file"
internalattestation "github.com/hashicorp/packer/internal/attestation"
internalprovenance "github.com/hashicorp/packer/internal/provenance"
internalsbom "github.com/hashicorp/packer/internal/sbom"
)
func TestPostProcessorWritesUnsignedStatementAndPreservesArtifact(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
outputDir := t.TempDir()
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]string{{
"type": "provenance",
"output_dir": outputDir,
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
returnedArtifact, keep, mustKeep, err := postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err != nil {
t.Fatalf("post-process artifact: %v", err)
}
if returnedArtifact != artifact {
t.Fatalf("expected original artifact to be preserved")
}
if !keep || !mustKeep {
t.Fatalf("expected keep and mustKeep to be true")
}
statementPath := filepath.Join(outputDir, "package.txt.provenance.json")
contents, err := os.ReadFile(statementPath)
if err != nil {
t.Fatalf("read provenance statement: %v", err)
}
var statement internalprovenance.Statement
if err := json.Unmarshal(contents, &statement); err != nil {
t.Fatalf("unmarshal statement: %v", err)
}
if got, want := statement.Type, internalprovenance.StatementType; got != want {
t.Fatalf("unexpected statement type %q, want %q", got, want)
}
if got, want := statement.PredicateType, internalprovenance.SLSAProvenanceV1PredicateType; got != want {
t.Fatalf("unexpected predicate type %q, want %q", got, want)
}
if got, want := len(statement.Subject), 1; got != want {
t.Fatalf("unexpected subject count %d, want %d", got, want)
}
}
// TestPostProcessorChainsWithoutModifyingArtifact verifies that the provenance
// post-processor is a transparent pass-through: it returns the same artifact it
// received, unmodified, so a downstream post-processor in the chain observes the
// identical input it would have without provenance in the chain.
func TestPostProcessorChainsWithoutModifyingArtifact(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
// Record the artifact's observable state and file contents before running.
builderIDBefore := artifact.BuilderId()
idBefore := artifact.Id()
stringBefore := artifact.String()
filesBefore := append([]string(nil), artifact.Files()...)
contentsBefore := make(map[string]string, len(filesBefore))
for _, file := range filesBefore {
contentsBefore[file] = readFileString(t, file)
}
outputDir := t.TempDir()
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]string{{
"type": "provenance",
"output_dir": outputDir,
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
returnedArtifact, keep, mustKeep, err := postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err != nil {
t.Fatalf("post-process artifact: %v", err)
}
// The next post-processor in the chain must receive the same artifact.
if returnedArtifact != artifact {
t.Fatalf("expected the same artifact instance to be returned for chaining")
}
if !keep || !mustKeep {
t.Fatalf("expected keep and mustKeep to be true so the artifact survives the chain")
}
// The returned artifact's observable state must be unchanged.
if got := returnedArtifact.BuilderId(); got != builderIDBefore {
t.Fatalf("builder id changed: got %q, want %q", got, builderIDBefore)
}
if got := returnedArtifact.Id(); got != idBefore {
t.Fatalf("artifact id changed: got %q, want %q", got, idBefore)
}
if got := returnedArtifact.String(); got != stringBefore {
t.Fatalf("artifact string changed: got %q, want %q", got, stringBefore)
}
if got := returnedArtifact.Files(); !slices.Equal(got, filesBefore) {
t.Fatalf("artifact files changed: got %v, want %v", got, filesBefore)
}
// A downstream consumer must still see the original, unmodified files.
for _, file := range returnedArtifact.Files() {
if got := readFileString(t, file); got != contentsBefore[file] {
t.Fatalf("artifact file %q contents changed: got %q, want %q", file, got, contentsBefore[file])
}
}
}
func TestPostProcessorEnrichesPredicateFromConfigAndCIEnv(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
outputDir := t.TempDir()
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]interface{}{{
"type": "provenance",
"output_dir": outputDir,
"template": "ubuntu.pkr.hcl",
"only_builds": []string{"qemu.ubuntu"},
"user_variables": map[string]string{"role": "web"},
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
times := []time.Time{
time.Date(2026, time.July, 4, 10, 0, 0, 0, time.UTC),
time.Date(2026, time.July, 4, 10, 12, 0, 0, time.UTC),
}
timeIndex := 0
postProcessor.now = func() time.Time {
current := times[timeIndex]
if timeIndex < len(times)-1 {
timeIndex++
}
return current
}
postProcessor.env = map[string]string{
"GITHUB_REPOSITORY": "acme/images",
"GITHUB_SHA": "deadbeef",
"GITHUB_REF": "refs/heads/main",
"GITHUB_WORKFLOW_REF": "acme/images/.github/workflows/build.yml@refs/heads/main",
"GITHUB_RUN_ID": "run-42",
"PKR_VAR_region": "us-east-1",
}
postProcessor.workingDir = "/workspace/packer"
_, _, _, err = postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err != nil {
t.Fatalf("post-process artifact: %v", err)
}
statementPath := filepath.Join(outputDir, "package.txt.provenance.json")
contents, err := os.ReadFile(statementPath)
if err != nil {
t.Fatalf("read provenance statement: %v", err)
}
var statement struct {
Type string `json:"_type"`
PredicateType string `json:"predicateType"`
Subject []internalprovenance.Subject `json:"subject"`
Predicate internalprovenance.SLSAProvenancePredicate `json:"predicate"`
}
if err := json.Unmarshal(contents, &statement); err != nil {
t.Fatalf("unmarshal statement: %v", err)
}
if got, want := statement.Predicate.RunDetails.Builder.ID, "acme/images/.github/workflows/build.yml@refs/heads/main"; got != want {
t.Fatalf("unexpected builder id %q, want %q", got, want)
}
if got, want := statement.Predicate.RunDetails.Metadata.InvocationID, "run-42"; got != want {
t.Fatalf("unexpected invocation id %q, want %q", got, want)
}
externalParameters := statement.Predicate.BuildDefinition.ExternalParameters
if got, want := externalParameters["template"], "ubuntu.pkr.hcl"; got != want {
t.Fatalf("unexpected template %v, want %q", got, want)
}
if got, want := statement.Predicate.BuildDefinition.ResolvedDependencies[0].URI, "git+https://github.com/acme/images@refs/heads/main"; got != want {
t.Fatalf("unexpected source uri %q, want %q", got, want)
}
if got, want := statement.Predicate.BuildDefinition.ResolvedDependencies[0].Digest["gitCommit"], "deadbeef"; got != want {
t.Fatalf("unexpected source digest %q, want %q", got, want)
}
userVariables, ok := externalParameters["userVariables"].(map[string]interface{})
if !ok {
t.Fatalf("expected userVariables map, got %T", externalParameters["userVariables"])
}
if got, want := userVariables["region"], "us-east-1"; got != want {
t.Fatalf("unexpected env user variable %v, want %q", got, want)
}
if got, want := userVariables["role"], "web"; got != want {
t.Fatalf("unexpected config user variable %v, want %q", got, want)
}
onlyBuilds, ok := externalParameters["onlyBuilds"].([]interface{})
if !ok || len(onlyBuilds) != 1 || onlyBuilds[0] != "qemu.ubuntu" {
t.Fatalf("unexpected onlyBuilds value %#v", externalParameters["onlyBuilds"])
}
}
func TestExternalParametersRedactsSensitiveVariables(t *testing.T) {
var pp PostProcessor
pp.config.UserVariables = map[string]string{"password": "s3cr3t", "region": "us-east-1"}
pp.config.PackerSensitiveVars = []string{"password", "api_token"}
env := map[string]string{"PKR_VAR_api_token": "tok-value"}
external := pp.externalParameters(env)
userVariables, ok := external["userVariables"].(map[string]string)
if !ok {
t.Fatalf("expected userVariables map, got %T", external["userVariables"])
}
if got := userVariables["password"]; got != redactedSensitiveValue {
t.Fatalf("expected sensitive config variable to be redacted, got %q", got)
}
if got := userVariables["api_token"]; got != redactedSensitiveValue {
t.Fatalf("expected sensitive PKR_VAR variable to be redacted, got %q", got)
}
if got, want := userVariables["region"], "us-east-1"; got != want {
t.Fatalf("expected non-sensitive variable to be preserved, got %q want %q", got, want)
}
}
func TestPostProcessorWritesSBOMAttestation(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
outputDir := t.TempDir()
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]interface{}{{
"type": "provenance",
"output_dir": outputDir,
"sbom": true,
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
postProcessor.generateSBOM = func(context.Context, internalsbom.Config) ([]byte, error) {
return []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5"}`), nil
}
_, _, _, err = postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err != nil {
t.Fatalf("post-process artifact: %v", err)
}
if _, err := os.Stat(filepath.Join(outputDir, "package.txt.sbom.cdx.json")); err != nil {
t.Fatalf("expected raw sbom output: %v", err)
}
contents, err := os.ReadFile(filepath.Join(outputDir, "package.txt.sbom.att.json"))
if err != nil {
t.Fatalf("read sbom attestation: %v", err)
}
var statement internalprovenance.Statement
if err := json.Unmarshal(contents, &statement); err != nil {
t.Fatalf("unmarshal sbom attestation: %v", err)
}
if got, want := statement.PredicateType, "https://cyclonedx.org/bom"; got != want {
t.Fatalf("unexpected SBOM predicate type %q, want %q", got, want)
}
}
func TestPostProcessorRegeneratesStaleSBOM(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
outputDir := t.TempDir()
staleSBOM := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","stale":true}`)
if err := os.WriteFile(filepath.Join(outputDir, "package.txt.sbom.cdx.json"), staleSBOM, 0664); err != nil {
t.Fatalf("write stale sbom: %v", err)
}
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]interface{}{{
"type": "provenance",
"output_dir": outputDir,
"sbom": true,
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
freshSBOM := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","fresh":true}`)
generated := false
postProcessor.generateSBOM = func(context.Context, internalsbom.Config) ([]byte, error) {
generated = true
return freshSBOM, nil
}
_, _, _, err = postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err != nil {
t.Fatalf("post-process artifact: %v", err)
}
if !generated {
t.Fatalf("expected SBOM to be regenerated rather than reused")
}
if got, want := readFileString(t, filepath.Join(outputDir, "package.txt.sbom.cdx.json")), string(freshSBOM); got != want {
t.Fatalf("expected stale SBOM to be overwritten with freshly generated contents")
}
contents, err := os.ReadFile(filepath.Join(outputDir, "package.txt.sbom.att.json"))
if err != nil {
t.Fatalf("read sbom attestation: %v", err)
}
var statement internalprovenance.Statement
if err := json.Unmarshal(contents, &statement); err != nil {
t.Fatalf("unmarshal sbom attestation: %v", err)
}
if got, want := statement.PredicateType, "https://cyclonedx.org/bom"; got != want {
t.Fatalf("unexpected SBOM predicate type %q, want %q", got, want)
}
}
func TestPostProcessorSignsAttestationsWithConfiguredVerifier(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
privateKeyPath, publicKeyPath := writeSigningKeypair(t)
outputDir := t.TempDir()
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]interface{}{{
"type": "provenance",
"output_dir": outputDir,
"signing_mode": "key",
"signer": privateKeyPath,
"verifier": publicKeyPath,
"sbom": true,
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
postProcessor.generateSBOM = func(context.Context, internalsbom.Config) ([]byte, error) {
return []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5"}`), nil
}
_, _, _, err = postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err != nil {
t.Fatalf("post-process artifact: %v", err)
}
assertSignedEnvelope(t, filepath.Join(outputDir, "package.txt.provenance.json"))
assertSignedEnvelope(t, filepath.Join(outputDir, "package.txt.sbom.att.json"))
if _, err := os.Stat(filepath.Join(outputDir, "package.txt.sbom.cdx.json")); err != nil {
t.Fatalf("expected raw sbom output: %v", err)
}
}
func TestPostProcessorRejectsMismatchedVerifier(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
privateKeyPath, _ := writeSigningKeypair(t)
_, mismatchedVerifierPath := writeSigningKeypair(t)
outputDir := t.TempDir()
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]interface{}{{
"type": "provenance",
"output_dir": outputDir,
"signing_mode": "key",
"signer": privateKeyPath,
"verifier": mismatchedVerifierPath,
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
_, _, _, err = postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err == nil {
t.Fatalf("expected post-process to fail with mismatched verifier")
}
}
func TestPostProcessorWritesSigstoreBundleForKeylessAttestations(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
outputDir := t.TempDir()
config := mustTemplateJSON(t, map[string]interface{}{
"post-processors": []map[string]interface{}{{
"type": "provenance",
"output_dir": outputDir,
"signing_mode": "keyless",
"upload_tlog": false,
"keyless_identity": "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main",
"keyless_oidc_issuer": "https://token.actions.githubusercontent.com",
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
originalBundleBuilder := buildSigstoreBundleForSigner
buildSigstoreBundleForSigner = func(context.Context, internalattestation.Signer, internalattestation.BackendConfig, string, []byte) (internalattestation.Envelope, []byte, error) {
return internalattestation.Envelope{
PayloadType: internalattestation.InTotoPayloadType,
Payload: "cGF5bG9hZA==",
Signatures: []internalattestation.EnvelopeSignature{{Sig: "c2ln", Cert: "cert"}},
}, []byte(`{"mediaType":"application/vnd.dev.sigstore.bundle+json;version=0.3"}`), nil
}
t.Cleanup(func() { buildSigstoreBundleForSigner = originalBundleBuilder })
var postProcessor PostProcessor
if err := postProcessor.Configure(tpl.PostProcessors[0][0].Config); err != nil {
t.Fatalf("configure post-processor: %v", err)
}
postProcessor.signingResourcesFn = func(context.Context, internalattestation.BackendConfig) (internalattestation.Signer, internalattestation.Verifier, error) {
return fakeEnvelopeSigner{}, fakeEnvelopeVerifier{}, nil
}
_, _, _, err = postProcessor.PostProcess(context.Background(), packersdk.TestUi(t), artifact)
if err != nil {
t.Fatalf("post-process artifact: %v", err)
}
assertSignedEnvelope(t, filepath.Join(outputDir, "package.txt.provenance.json"))
if _, err := os.Stat(filepath.Join(outputDir, "package.txt.provenance.sigstore.json")); err != nil {
t.Fatalf("expected Sigstore bundle output: %v", err)
}
if _, err := os.Stat(filepath.Join(outputDir, "package.txt.sbom.att.sigstore.json")); !os.IsNotExist(err) {
t.Fatalf("expected no SBOM bundle sidecar when sbom is disabled, got %v", err)
}
if got, want := strings.TrimSpace(readFileString(t, filepath.Join(outputDir, "package.txt.provenance.sigstore.json"))), `{"mediaType":"application/vnd.dev.sigstore.bundle+json;version=0.3"}`; got != want {
t.Fatalf("unexpected bundle contents %q, want %q", got, want)
}
}
func TestSigningBackendConfigAcceptsKMSReferences(t *testing.T) {
postProcessor := PostProcessor{}
postProcessor.config.SigningMode = internalattestation.SigningModeKMS
postProcessor.config.Signer = "awskms://alias/example"
postProcessor.config.Verifier = "keys/provenance-signing.pub.pem"
postProcessor.env = map[string]string{"AWS_REGION": "us-east-1"}
backendConfig, err := postProcessor.signingBackendConfig()
if err != nil {
t.Fatalf("build signing backend config: %v", err)
}
if got, want := backendConfig.Mode, internalattestation.SigningModeKMS; got != want {
t.Fatalf("unexpected mode %q, want %q", got, want)
}
if got, want := backendConfig.SignerRef, "awskms://alias/example"; got != want {
t.Fatalf("unexpected signer ref %q, want %q", got, want)
}
if got, want := backendConfig.VerifierRef, "keys/provenance-signing.pub.pem"; got != want {
t.Fatalf("unexpected verifier ref %q, want %q", got, want)
}
if got, want := backendConfig.Env["AWS_REGION"], "us-east-1"; got != want {
t.Fatalf("unexpected copied environment %q, want %q", got, want)
}
}
func TestSigningBackendConfigRejectsUnknownKMSReferences(t *testing.T) {
postProcessor := PostProcessor{}
postProcessor.config.SigningMode = internalattestation.SigningModeKMS
postProcessor.config.Signer = "kms://example"
_, err := postProcessor.signingBackendConfig()
if err == nil {
t.Fatalf("expected unknown KMS reference to be rejected")
}
if !strings.Contains(err.Error(), "recognized KMS or Vault URI") {
t.Fatalf("unexpected KMS validation error: %v", err)
}
}
func TestSigningBackendConfigIncludesKeylessFulcioURL(t *testing.T) {
postProcessor := PostProcessor{}
postProcessor.config.SigningMode = internalattestation.SigningModeKeyless
postProcessor.config.FulcioURL = "https://fulcio.example.test"
postProcessor.config.KeylessIdentity = "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main"
postProcessor.config.KeylessOIDCIssuer = "https://token.actions.githubusercontent.com"
postProcessor.env = map[string]string{"SIGSTORE_ID_TOKEN": "token"}
backendConfig, err := postProcessor.signingBackendConfig()
if err != nil {
t.Fatalf("build signing backend config: %v", err)
}
if got, want := backendConfig.Mode, internalattestation.SigningModeKeyless; got != want {
t.Fatalf("unexpected mode %q, want %q", got, want)
}
if got, want := backendConfig.FulcioURL, "https://fulcio.example.test"; got != want {
t.Fatalf("unexpected Fulcio URL %q, want %q", got, want)
}
if backendConfig.VerifierRef != "" {
t.Fatalf("keyless backend config must not carry a verifier ref, got %q", backendConfig.VerifierRef)
}
if got, want := backendConfig.Env["SIGSTORE_ID_TOKEN"], "token"; got != want {
t.Fatalf("unexpected copied environment %q, want %q", got, want)
}
}
func TestSigningBackendConfigRejectsKeylessVerifierOverride(t *testing.T) {
postProcessor := PostProcessor{}
postProcessor.config.SigningMode = internalattestation.SigningModeKeyless
postProcessor.config.Verifier = "keys/provenance-signing.pub.pem"
postProcessor.config.KeylessIdentity = "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main"
postProcessor.config.KeylessOIDCIssuer = "https://token.actions.githubusercontent.com"
postProcessor.env = map[string]string{"SIGSTORE_ID_TOKEN": "token"}
_, err := postProcessor.signingBackendConfig()
if err == nil {
t.Fatalf("expected keyless config with verifier override to fail")
}
if !strings.Contains(err.Error(), "verifier overrides") {
t.Fatalf("unexpected keyless verifier override error: %v", err)
}
}
func TestSigningBackendConfigRejectsKeylessWithoutIdentityPolicy(t *testing.T) {
postProcessor := PostProcessor{}
postProcessor.config.SigningMode = internalattestation.SigningModeKeyless
postProcessor.config.FulcioURL = "https://fulcio.example.test"
postProcessor.env = map[string]string{"SIGSTORE_ID_TOKEN": "token"}
_, err := postProcessor.signingBackendConfig()
if err == nil {
t.Fatalf("expected keyless config without identity policy to fail")
}
if !strings.Contains(err.Error(), "keyless_identity") {
t.Fatalf("unexpected keyless validation error: %v", err)
}
}
func TestSigningBackendConfigIncludesKeylessPolicy(t *testing.T) {
postProcessor := PostProcessor{}
postProcessor.config.SigningMode = internalattestation.SigningModeKeyless
postProcessor.config.FulcioURL = "https://fulcio.example.test"
postProcessor.config.KeylessIdentity = "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"
postProcessor.config.KeylessOIDCIssuer = "https://token.actions.githubusercontent.com"
postProcessor.config.TrustedRootPath = "testdata/trusted-root.json"
postProcessor.env = map[string]string{"SIGSTORE_ID_TOKEN": "token"}
backendConfig, err := postProcessor.signingBackendConfig()
if err != nil {
t.Fatalf("build keyless signing backend config: %v", err)
}
if got, want := backendConfig.KeylessIdentity, "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"; got != want {
t.Fatalf("unexpected keyless identity %q, want %q", got, want)
}
if got, want := backendConfig.KeylessOIDCIssuer, "https://token.actions.githubusercontent.com"; got != want {
t.Fatalf("unexpected keyless OIDC issuer %q, want %q", got, want)
}
if got, want := backendConfig.TrustedRootPath, "testdata/trusted-root.json"; got != want {
t.Fatalf("unexpected trusted root path %q, want %q", got, want)
}
}
func TestSigningBackendConfigIncludesKeylessRekorSettings(t *testing.T) {
postProcessor := PostProcessor{}
postProcessor.config.SigningMode = internalattestation.SigningModeKeyless
postProcessor.config.FulcioURL = "https://fulcio.example.test"
postProcessor.config.RekorURL = "https://rekor.example.test"
postProcessor.config.UploadTlog = true
postProcessor.config.KeylessIdentity = "https://github.com/hashicorp/packer/.github/workflows/build.yml@refs/heads/main"
postProcessor.config.KeylessOIDCIssuer = "https://token.actions.githubusercontent.com"
postProcessor.env = map[string]string{"SIGSTORE_ID_TOKEN": "token"}
backendConfig, err := postProcessor.signingBackendConfig()
if err != nil {
t.Fatalf("build keyless signing backend config: %v", err)
}
if got, want := backendConfig.RekorURL, "https://rekor.example.test"; got != want {
t.Fatalf("unexpected Rekor URL %q, want %q", got, want)
}
if !backendConfig.UploadTlog {
t.Fatalf("expected upload_tlog to be enabled")
}
}
func assertSignedEnvelope(t *testing.T, path string) {
t.Helper()
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read signed envelope %q: %v", path, err)
}
var envelope internalattestation.Envelope
if err := json.Unmarshal(contents, &envelope); err != nil {
t.Fatalf("unmarshal envelope %q: %v", path, err)
}
if got, want := envelope.PayloadType, internalattestation.InTotoPayloadType; got != want {
t.Fatalf("unexpected payload type %q, want %q", got, want)
}
if len(envelope.Signatures) != 1 {
t.Fatalf("expected exactly one signature in %q", path)
}
}
func writeSigningKeypair(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
}
type fakeEnvelopeSigner struct{}
func (fakeEnvelopeSigner) Sign(context.Context, string, []byte) (internalattestation.Signature, error) {
return internalattestation.Signature{Sig: []byte("sig")}, nil
}
func (fakeEnvelopeSigner) Verifier(context.Context, internalattestation.BackendConfig) (internalattestation.Verifier, error) {
return fakeEnvelopeVerifier{}, nil
}
type fakeEnvelopeVerifier struct{}
func (fakeEnvelopeVerifier) Verify(context.Context, string, []byte, []byte) error {
return nil
}
func (fakeEnvelopeVerifier) KeyID() string {
return ""
}
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)
}
func TestOutputStemDisambiguatesByBuildName(t *testing.T) {
artifact := buildFileArtifact(t)
defer func() { _ = artifact.Destroy() }()
var pp PostProcessor
// Without a build name the stem is just the artifact's base filename.
if got, want := pp.outputStem(artifact), "package.txt"; got != want {
t.Fatalf("without build name: got %q, want %q", got, want)
}
// A build name is prefixed so parallel builds sharing an output_dir do not
// collide on the same output paths.
pp.config.PackerBuildName = "amazon-ebs.linux"
if got, want := pp.outputStem(artifact), "amazon-ebs.linux.package.txt"; got != want {
t.Fatalf("with build name: got %q, want %q", got, want)
}
paths, err := pp.outputPaths(artifact)
if err != nil {
t.Fatalf("output paths: %v", err)
}
if got, want := filepath.Base(paths.ProvenanceStatement), "amazon-ebs.linux.package.txt.provenance.json"; got != want {
t.Fatalf("unexpected provenance path %q, want %q", got, want)
}
}
func TestAtomicWriteFileReplacesExistingWithoutLeftovers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "out.json")
if err := os.WriteFile(path, []byte("stale"), 0644); err != nil {
t.Fatalf("seed file: %v", err)
}
if err := atomicWriteFile(path, []byte("fresh-content"), 0664); err != nil {
t.Fatalf("atomic write: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != "fresh-content" {
t.Fatalf("unexpected contents %q", string(got))
}
// No temporary files should be left behind.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir: %v", err)
}
if len(entries) != 1 || entries[0].Name() != "out.json" {
t.Fatalf("expected only out.json in directory, found %v", entries)
}
}
func buildFileArtifact(t *testing.T) packersdk.Artifact {
t.Helper()
target := filepath.Join(t.TempDir(), "package.txt")
config := mustTemplateJSON(t, map[string]interface{}{
"builders": []map[string]string{{
"type": "file",
"target": target,
"content": "Hello world!",
}},
})
tpl, err := template.Parse(strings.NewReader(config))
if err != nil {
t.Fatalf("parse template: %v", err)
}
var builder filebuilder.Builder
_, warnings, err := builder.Prepare(tpl.Builders["file"].Config)
if err != nil {
t.Fatalf("prepare builder: %v", err)
}
if len(warnings) > 0 {
t.Fatalf("unexpected warnings: %v", warnings)
}
artifact, err := builder.Run(context.Background(), packersdk.TestUi(t), nil)
if err != nil {
t.Fatalf("run builder: %v", err)
}
return artifact
}
func mustTemplateJSON(t *testing.T, value interface{}) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal template config: %v", err)
}
return string(encoded)
}