mirror of
https://github.com/hashicorp/packer.git
synced 2026-09-18 14:01:39 -04:00
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:
+1
-1
@@ -1 +1 @@
|
||||
1.25.11
|
||||
1.26.3
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬Found) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user