Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 98 additions & 143 deletions cli/internal/verify/provenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ package verify

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/sigstore/sigstore-go/pkg/verify"
)

Expand All @@ -23,184 +22,138 @@ const (
// Exported so selfupdate can reuse the same constant.
DSSEPayloadType = "application/vnd.in-toto+json"

// slsaReferrerArtifactType is the OCI artifactType for SLSA provenance referrers.
// actions/attest-build-provenance sets this to the same value as the DSSE payload
// type; the match is intentional but these are semantically distinct OCI fields
// that could diverge in the future.
slsaReferrerArtifactType = "application/vnd.in-toto+json"
// defaultGitHubAPIBase is the base URL for the GitHub REST API.
defaultGitHubAPIBase = "https://api.github.com"

// githubAttestationOwnerRepo is the GitHub owner/repo for attestation
// lookups. This is the canonical GitHub repository path (case-sensitive
// for the API), not derived from the image registry prefix.
githubAttestationOwnerRepo = "Aureliolo/synthorg"

// attestationHTTPTimeout bounds individual HTTP requests to the GitHub API.
attestationHTTPTimeout = 30 * time.Second

// maxAttestationResponseBytes caps the GitHub attestation API response
// to prevent memory exhaustion from malicious or oversized responses.
maxAttestationResponseBytes = 5 << 20 // 5MB
)

// ErrNoProvenanceAttestations indicates that no SLSA provenance attestations
// were found for an image. This is distinct from a cryptographic verification
// failure — it means the image was published before provenance was configured.
// were found for an image via the GitHub attestation API. This is distinct
// from a cryptographic verification failure.
var ErrNoProvenanceAttestations = errors.New("no SLSA provenance attestations found")

// VerifyProvenance fetches SLSA provenance attestations from the OCI registry
// (pushed as referrers by actions/attest-build-provenance) and verifies the
// DSSE envelope against the Sigstore transparency log and expected identity.
// githubAPIBase is the effective base URL for the GitHub REST API.
// Defaults to the production URL; tests override via setGitHubAPIBase.
var githubAPIBase = defaultGitHubAPIBase //nolint:gochecknoglobals // test override

// attestationHTTPClient is a dedicated client for GitHub attestation API
// requests, isolated from http.DefaultClient to avoid side effects from
// other packages modifying global state.
var attestationHTTPClient = &http.Client{} //nolint:gochecknoglobals // package-scoped client

// setGitHubAPIBase overrides the GitHub API base URL (for tests only).
func setGitHubAPIBase(base string) { githubAPIBase = base }

// VerifyProvenance fetches SLSA provenance attestations from the GitHub
// attestation API and verifies the Sigstore bundle against the public
// transparency log and expected identity.
//
// The image ref must have a resolved Digest.
func VerifyProvenance(ctx context.Context, ref ImageRef, sev *verify.Verifier, certID verify.CertificateIdentity) error {
if ref.Digest == "" {
return fmt.Errorf("image digest not resolved")
}

attestationDescs, err := findAttestations(ctx, ref)
bundles, err := fetchGitHubAttestations(ctx, ref.Digest)
if err != nil {
return err
}

// Try each attestation — verify the first one that passes.
// Try each attestation bundle -- first successful verification wins.
var errs []error
for i, desc := range attestationDescs {
if err := verifyAttestation(ctx, ref, desc, sev, certID); err != nil {
for i, bundleJSON := range bundles {
if err := verifyProvenanceBundle(bundleJSON, ref.Digest, sev, certID); err != nil {
errs = append(errs, fmt.Errorf("attestation[%d]: %w", i, err))
continue
}
return nil // verified successfully
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return fmt.Errorf("no valid SLSA provenance attestation for %s: %w", ref, errors.Join(errs...))
}

// findAttestations queries OCI referrers and returns descriptors for in-toto
// attestation artifacts associated with the given image.
func findAttestations(ctx context.Context, ref ImageRef) ([]v1.Descriptor, error) {
digestRef := fmt.Sprintf("%s/%s@%s", ref.Registry, ref.Repository, ref.Digest)
parsed, err := name.NewDigest(digestRef)
if err != nil {
return nil, fmt.Errorf("parsing digest reference %q: %w", digestRef, err)
}

referrerIdx, err := remote.Referrers(parsed, remote.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("querying referrers for %s: %w", ref, err)
}

manifest, err := referrerIdx.IndexManifest()
if err != nil {
return nil, fmt.Errorf("reading referrer index manifest: %w", err)
}

var descs []v1.Descriptor
for _, desc := range manifest.Manifests {
if desc.ArtifactType == slsaReferrerArtifactType {
descs = append(descs, desc)
}
}
if len(descs) == 0 {
return nil, fmt.Errorf("%w for %s", ErrNoProvenanceAttestations, ref)
}
return descs, nil
// githubAttestation represents a single attestation entry in the GitHub API response.
type githubAttestation struct {
Bundle json.RawMessage `json:"bundle"`
}

// verifyAttestation fetches and verifies a single attestation manifest.
func verifyAttestation(ctx context.Context, ref ImageRef, desc v1.Descriptor, sev *verify.Verifier, certID verify.CertificateIdentity) error {
img, err := fetchAttestationImage(ctx, ref, desc)
if err != nil {
return err
}

envelope, err := extractDSSEEnvelope(img)
if err != nil {
return err
}

if err := validateSLSAPredicate(envelope); err != nil {
return err
}

return verifyAttestationBundle(img, ref.Digest, sev, certID)
// githubAttestationResponse is the structure returned by the GitHub
// attestation API (GET /repos/OWNER/REPO/attestations/SUBJECT_DIGEST).
type githubAttestationResponse struct {
Attestations []githubAttestation `json:"attestations"`
}
Comment on lines +92 to 94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better readability and maintainability, consider defining a named struct for the items in the Attestations slice instead of using an anonymous struct. This makes the code easier to understand and reuse if needed.

For example:

type githubAttestation struct {
	Bundle json.RawMessage `json:"bundle"`
}

type githubAttestationResponse struct {
	Attestations []githubAttestation `json:"attestations"`
}


// fetchAttestationImage fetches the attestation image by its descriptor digest.
func fetchAttestationImage(ctx context.Context, ref ImageRef, desc v1.Descriptor) (v1.Image, error) {
attestRef := fmt.Sprintf("%s/%s@%s", ref.Registry, ref.Repository, desc.Digest.String())
parsed, err := name.NewDigest(attestRef)
if err != nil {
return nil, fmt.Errorf("parsing attestation reference: %w", err)
}
// fetchGitHubAttestations queries the GitHub attestation API for Sigstore
// bundles associated with the given image digest.
func fetchGitHubAttestations(ctx context.Context, digest string) ([]json.RawMessage, error) {
url := fmt.Sprintf("%s/repos/%s/attestations/%s", githubAPIBase, githubAttestationOwnerRepo, digest)

img, err := remote.Image(parsed, remote.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("fetching attestation image: %w", err)
}
return img, nil
}
reqCtx, cancel := context.WithTimeout(ctx, attestationHTTPTimeout)
defer cancel()

// extractDSSEEnvelope reads and parses the DSSE envelope from the first layer
// of an attestation image.
func extractDSSEEnvelope(img v1.Image) (dsseEnvelope, error) {
layers, err := img.Layers()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
if err != nil {
return dsseEnvelope{}, fmt.Errorf("reading attestation layers: %w", err)
}
if len(layers) == 0 {
return dsseEnvelope{}, fmt.Errorf("attestation has no layers")
return nil, fmt.Errorf("creating attestation request: %w", err)
}
req.Header.Set("Accept", "application/json")

layerReader, err := layers[0].Uncompressed()
resp, err := attestationHTTPClient.Do(req)
if err != nil {
return dsseEnvelope{}, fmt.Errorf("reading attestation layer: %w", err)
return nil, fmt.Errorf("fetching attestations from GitHub API: %w", err)
}
defer func() { _ = layerReader.Close() }()
defer func() { _ = resp.Body.Close() }()

var envelope dsseEnvelope
if err := json.NewDecoder(layerReader).Decode(&envelope); err != nil {
return dsseEnvelope{}, fmt.Errorf("decoding DSSE envelope: %w", err)
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("%w via GitHub API for digest %s", ErrNoProvenanceAttestations, digest)
}
return envelope, nil
}

// validateSLSAPredicate checks that a DSSE envelope contains a SLSA provenance
// predicate type.
func validateSLSAPredicate(envelope dsseEnvelope) error {
if envelope.PayloadType != DSSEPayloadType {
return fmt.Errorf("unexpected DSSE payload type %q, want %q", envelope.PayloadType, DSSEPayloadType)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub attestation API returned HTTP %d for digest %s", resp.StatusCode, digest)
}

payloadBytes, err := base64.StdEncoding.DecodeString(envelope.Payload)
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAttestationResponseBytes+1))
if err != nil {
return fmt.Errorf("decoding DSSE payload: %w", err)
}

var statement inTotoStatement
if err := json.Unmarshal(payloadBytes, &statement); err != nil {
return fmt.Errorf("parsing in-toto statement: %w", err)
return nil, fmt.Errorf("reading attestation response: %w", err)
}
if !strings.HasPrefix(statement.PredicateType, SLSAProvenancePredicatePrefix) {
return fmt.Errorf("unexpected predicate type %q, want prefix %q", statement.PredicateType, SLSAProvenancePredicatePrefix)
if int64(len(body)) > maxAttestationResponseBytes {
return nil, fmt.Errorf("attestation response too large (>%d bytes)", maxAttestationResponseBytes)
}
return nil
}

// verifyAttestationBundle looks for a Sigstore bundle in the attestation
// manifest or layer annotations and verifies it. Returns an error if no
// bundle is found — structural validation alone is insufficient.
func verifyAttestationBundle(img v1.Image, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error {
attestManifest, err := img.Manifest()
if err != nil {
return fmt.Errorf("reading attestation manifest: %w", err)
var apiResp githubAttestationResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("parsing attestation response: %w", err)
}

// Look for Sigstore bundle in manifest annotations.
if bundleJSON, ok := attestManifest.Annotations[cosignBundleAnnotation]; ok {
return verifyProvenanceBundleWith([]byte(bundleJSON), digest, sev, certID)
if len(apiResp.Attestations) == 0 {
return nil, fmt.Errorf("%w via GitHub API for digest %s", ErrNoProvenanceAttestations, digest)
}

// Also check layer annotations — iterate all layers, not just the first,
// since cosign may store the bundle in any layer's annotations.
for i := range attestManifest.Layers {
if bundleJSON, ok := attestManifest.Layers[i].Annotations[cosignBundleAnnotation]; ok {
return verifyProvenanceBundleWith([]byte(bundleJSON), digest, sev, certID)
bundles := make([]json.RawMessage, 0, len(apiResp.Attestations))
for _, a := range apiResp.Attestations {
if len(a.Bundle) > 0 {
bundles = append(bundles, a.Bundle)
}
}

return fmt.Errorf("no sigstore bundle found in attestation — cannot cryptographically verify provenance")
if len(bundles) == 0 {
return nil, fmt.Errorf("%w (no bundles in response) for digest %s", ErrNoProvenanceAttestations, digest)
}
return bundles, nil
}

// verifyProvenanceBundleWith verifies a Sigstore bundle from an attestation
// using the provided verifier and identity policy.
func verifyProvenanceBundleWith(bundleJSON []byte, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error {
// verifyProvenanceBundle parses and verifies a single Sigstore bundle from
// the GitHub attestation API against the expected identity and image digest.
// After cryptographic verification, it also checks that the in-toto statement
// has a SLSA provenance predicate type (not an SBOM or other attestation).
func verifyProvenanceBundle(bundleJSON json.RawMessage, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error {
b, err := loadBundle(bundleJSON)
if err != nil {
return fmt.Errorf("parsing provenance bundle: %w", err)
Expand All @@ -211,24 +164,26 @@ func verifyProvenanceBundleWith(bundleJSON []byte, digest string, sev *verify.Ve
return err
}

_, err = sev.Verify(b, verify.NewPolicy(
// Verify the bundle cryptographically: check the signature, certificate
// identity (must be our docker.yml workflow), and artifact digest.
result, err := sev.Verify(b, verify.NewPolicy(
verify.WithArtifactDigest(digestAlgo, digestHex),
verify.WithCertificateIdentity(certID),
))
if err != nil {
return fmt.Errorf("provenance bundle verification failed: %w", err)
}

return nil
}

// dsseEnvelope is a minimal DSSE (Dead Simple Signing Envelope) structure.
type dsseEnvelope struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
}
// sigstore-go does not validate the in-toto predicate type -- we must
// check it ourselves. Without this, an SBOM or other attestation signed
// by the same workflow identity would incorrectly pass as SLSA provenance.
if result == nil || result.Statement == nil {
return fmt.Errorf("verification succeeded but returned no statement for predicate check")
}
pt := result.Statement.PredicateType
if !strings.HasPrefix(pt, SLSAProvenancePredicatePrefix) {
return fmt.Errorf("unexpected predicate type %q, want prefix %q", pt, SLSAProvenancePredicatePrefix)
}

// inTotoStatement is a minimal in-toto statement for predicate type extraction.
type inTotoStatement struct {
PredicateType string `json:"predicateType"`
return nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading