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
9 changes: 3 additions & 6 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,12 @@ jobs:
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.push.outputs.digest }}
COSIGN_EXPERIMENTAL: "1"
run: |
if [ -z "$DIGEST" ]; then
echo "::error::Push step did not produce a digest -- cannot sign image"
exit 1
fi
cosign sign --yes --registry-referrers-mode=oci-1-1 ghcr.io/aureliolo/synthorg-backend@${DIGEST}
cosign sign --yes ghcr.io/aureliolo/synthorg-backend@${DIGEST}

- name: Attest build provenance (SLSA Level 3)
if: github.event_name != 'pull_request'
Expand Down Expand Up @@ -377,13 +376,12 @@ jobs:
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.push.outputs.digest }}
COSIGN_EXPERIMENTAL: "1"
run: |
if [ -z "$DIGEST" ]; then
echo "::error::Push step did not produce a digest -- cannot sign image"
exit 1
fi
cosign sign --yes --registry-referrers-mode=oci-1-1 ghcr.io/aureliolo/synthorg-web@${DIGEST}
cosign sign --yes ghcr.io/aureliolo/synthorg-web@${DIGEST}

- name: Attest build provenance (SLSA Level 3)
if: github.event_name != 'pull_request'
Expand Down Expand Up @@ -545,13 +543,12 @@ jobs:
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.push.outputs.digest }}
COSIGN_EXPERIMENTAL: "1"
run: |
if [ -z "$DIGEST" ]; then
echo "::error::Push step did not produce a digest -- cannot sign image"
exit 1
fi
cosign sign --yes --registry-referrers-mode=oci-1-1 ghcr.io/aureliolo/synthorg-sandbox@${DIGEST}
cosign sign --yes ghcr.io/aureliolo/synthorg-sandbox@${DIGEST}

- name: Attest build provenance (SLSA Level 3)
if: github.event_name != 'pull_request'
Expand Down
102 changes: 83 additions & 19 deletions cli/internal/verify/cosign.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"io"
"strings"

"github.com/google/go-containerregistry/pkg/name"
Expand All @@ -14,25 +15,46 @@ import (
)

const (
// cosignArtifactType is the OCI artifact type for cosign signatures
// stored as OCI referrers (via --registry-referrers-mode=oci-1-1).
cosignArtifactType = "application/vnd.dev.cosign.simplesigning.v1+json"
// cosignV3BundleArtifactType is the OCI artifact type for cosign v3
// signatures stored using the new bundle format (default in cosign v3).
// The bundle is stored as a layer, not in annotations.
cosignV3BundleArtifactType = "application/vnd.dev.sigstore.bundle.v0.3+json"

// cosignBundleAnnotation is the annotation key where cosign stores the
// Sigstore bundle in manifest or layer annotations.
// cosignV2ArtifactType is the legacy OCI artifact type for cosign v2
// signatures stored as simplesigning payloads with bundle in annotations.
cosignV2ArtifactType = "application/vnd.dev.cosign.simplesigning.v1+json"

// cosignBundleAnnotation is the annotation key where cosign v2 stores
// the Sigstore bundle in manifest or layer annotations.
cosignBundleAnnotation = "dev.sigstore.cosign/bundle"

// maxBundleBytes caps the size of a cosign bundle read from a registry
// layer to prevent memory exhaustion from malicious registries.
// Typical Sigstore bundles are ~10KB; 1MB is generous.
maxBundleBytes = 1 << 20
)

// ErrNoCosignSignatures indicates that no cosign signature referrers were
// found for an image. This is distinct from a cryptographic verification
// failure -- it means the image was published before OCI referrer-based
// cosign signing was configured.
// failure -- it means the image was not signed or signatures are not
// discoverable via the OCI referrers API.
var ErrNoCosignSignatures = errors.New("no cosign signatures found")

// isCosignSignatureArtifact returns true if the descriptor's artifact type
// matches a known cosign signature format (v3 bundle or v2 simplesigning).
func isCosignSignatureArtifact(desc v1.Descriptor) bool {
return desc.ArtifactType == cosignV3BundleArtifactType || desc.ArtifactType == cosignV2ArtifactType
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// VerifyCosignSignature fetches cosign keyless signatures for the given image
// via the OCI referrers API and verifies them against the Sigstore public
// transparency log. The image ref must have a resolved Digest.
// The provided verifier and identity policy are reused across images.
// via the OCI referrers API (with tag-based fallback) and verifies them
// against the Sigstore public transparency log.
//
// Supports both cosign v3 (bundle as layer) and cosign v2 (bundle in
// annotations) signature formats.
//
// The image ref must have a resolved Digest. The provided verifier and
// identity policy are reused across images.
func VerifyCosignSignature(ctx context.Context, ref ImageRef, sev *verify.Verifier, certID verify.CertificateIdentity) error {
if ref.Digest == "" {
return fmt.Errorf("image digest not resolved")
Expand All @@ -55,8 +77,9 @@ func VerifyCosignSignature(ctx context.Context, ref ImageRef, sev *verify.Verifi
return fmt.Errorf("no valid cosign signature for %s: %w", ref, errors.Join(errs...))
}

// findCosignSignatures queries OCI referrers and returns descriptors for
// cosign signature artifacts associated with the given image.
// findCosignSignatures queries OCI referrers (with tag-based fallback) and
// returns descriptors for cosign signature artifacts associated with the
// given image.
func findCosignSignatures(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)
Expand All @@ -76,7 +99,7 @@ func findCosignSignatures(ctx context.Context, ref ImageRef) ([]v1.Descriptor, e

var descs []v1.Descriptor
for _, desc := range manifest.Manifests {
if desc.ArtifactType == cosignArtifactType {
if isCosignSignatureArtifact(desc) {
descs = append(descs, desc)
}
}
Expand All @@ -87,7 +110,8 @@ func findCosignSignatures(ctx context.Context, ref ImageRef) ([]v1.Descriptor, e
}

// verifyCosignReferrer fetches a single cosign signature referrer image,
// extracts the Sigstore bundle from annotations, and verifies it.
// extracts the Sigstore bundle, and verifies it. Supports both v3 (bundle
// as layer content) and v2 (bundle in annotations) formats.
func verifyCosignReferrer(ctx context.Context, ref ImageRef, desc v1.Descriptor, sev *verify.Verifier, certID verify.CertificateIdentity) error {
sigRef := fmt.Sprintf("%s/%s@%s", ref.Registry, ref.Repository, desc.Digest.String())
parsed, err := name.NewDigest(sigRef)
Expand All @@ -100,17 +124,57 @@ func verifyCosignReferrer(ctx context.Context, ref ImageRef, desc v1.Descriptor,
return fmt.Errorf("fetching cosign signature image: %w", err)
}

// Try v3 bundle format first (bundle is raw layer content).
if desc.ArtifactType == cosignV3BundleArtifactType {
return verifyCosignV3Bundle(img, ref.Digest, sev, certID)
}

// Fall back to v2 format (bundle in annotations).
return verifyCosignV2Bundle(img, ref.Digest, sev, certID)
}
Comment on lines +127 to +134
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.

🧹 Nitpick | 🔵 Trivial

Verify that the v3/v2 dispatch logic is correct given filtered descriptors.

The function assumes descriptors are pre-filtered by isCosignSignatureArtifact, so only v3 or v2 types reach here. If ArtifactType is not v3, it falls through to v2 verification. This is correct given the current filtering in findCosignSignatures, but consider adding a defensive check or comment to clarify this invariant.

🛡️ Optional: Add defensive comment
 	// Try v3 bundle format first (bundle is raw layer content).
 	if desc.ArtifactType == cosignV3BundleArtifactType {
 		return verifyCosignV3Bundle(img, ref.Digest, sev, certID)
 	}
 
-	// Fall back to v2 format (bundle in annotations).
+	// Fall back to v2 format (bundle in annotations).
+	// Note: Only v2 or v3 artifact types reach here due to filtering in findCosignSignatures.
 	return verifyCosignV2Bundle(img, ref.Digest, sev, certID)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Try v3 bundle format first (bundle is raw layer content).
if desc.ArtifactType == cosignV3BundleArtifactType {
return verifyCosignV3Bundle(img, ref.Digest, sev, certID)
}
// Fall back to v2 format (bundle in annotations).
return verifyCosignV2Bundle(img, ref.Digest, sev, certID)
}
// Try v3 bundle format first (bundle is raw layer content).
if desc.ArtifactType == cosignV3BundleArtifactType {
return verifyCosignV3Bundle(img, ref.Digest, sev, certID)
}
// Fall back to v2 format (bundle in annotations).
// Note: Only v2 or v3 artifact types reach here due to filtering in findCosignSignatures.
return verifyCosignV2Bundle(img, ref.Digest, sev, certID)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/internal/verify/cosign.go` around lines 129 - 136, The dispatch currently
assumes descriptors reaching this block are filtered by
isCosignSignatureArtifact in findCosignSignatures so it treats any non-v3
ArtifactType as v2; add a brief defensive comment above the dispatch clarifying
that invariant and either return an explicit error if desc.ArtifactType is
neither cosignV3BundleArtifactType nor the expected v2 type or assert the
invariant (e.g., panic/log and return error) before calling
verifyCosignV3Bundle/verifyCosignV2Bundle; reference desc.ArtifactType,
cosignV3BundleArtifactType, verifyCosignV3Bundle, verifyCosignV2Bundle,
isCosignSignatureArtifact, and findCosignSignatures when making the change.


// verifyCosignV3Bundle extracts and verifies a cosign v3 Sigstore bundle
// stored as the first layer of the referrer image.
func verifyCosignV3Bundle(img v1.Image, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error {
layers, err := img.Layers()
if err != nil {
return fmt.Errorf("reading signature layers: %w", err)
}
if len(layers) == 0 {
return fmt.Errorf("cosign v3 signature has no layers")
}

// The bundle is the raw content of the first layer.
reader, err := layers[0].Uncompressed()
if err != nil {
return fmt.Errorf("reading bundle layer: %w", err)
}
defer func() { _ = reader.Close() }()

bundleJSON, err := io.ReadAll(io.LimitReader(reader, maxBundleBytes+1))
if err != nil {
return fmt.Errorf("reading bundle content: %w", err)
}
if int64(len(bundleJSON)) > maxBundleBytes {
return fmt.Errorf("cosign bundle too large (>%d bytes)", maxBundleBytes)
}

return verifyCosignBundleWith(bundleJSON, digest, sev, certID)
}

// verifyCosignV2Bundle extracts and verifies a cosign v2 Sigstore bundle
// stored in manifest or layer annotations.
func verifyCosignV2Bundle(img v1.Image, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error {
sigManifest, err := img.Manifest()
if err != nil {
return fmt.Errorf("reading cosign signature manifest: %w", err)
}

// Check manifest-level annotations first, then layer annotations.
// Accumulate errors so callers can diagnose verification failures.
var bundleErrs []error

if bundleJSON, ok := sigManifest.Annotations[cosignBundleAnnotation]; ok {
if err := verifyCosignBundleWith([]byte(bundleJSON), ref.Digest, sev, certID); err != nil {
if err := verifyCosignBundleWith([]byte(bundleJSON), digest, sev, certID); err != nil {
bundleErrs = append(bundleErrs, fmt.Errorf("manifest bundle: %w", err))
} else {
return nil
Expand All @@ -119,7 +183,7 @@ func verifyCosignReferrer(ctx context.Context, ref ImageRef, desc v1.Descriptor,

for i := range sigManifest.Layers {
if bundleJSON, ok := sigManifest.Layers[i].Annotations[cosignBundleAnnotation]; ok {
if err := verifyCosignBundleWith([]byte(bundleJSON), ref.Digest, sev, certID); err != nil {
if err := verifyCosignBundleWith([]byte(bundleJSON), digest, sev, certID); err != nil {
bundleErrs = append(bundleErrs, fmt.Errorf("layer[%d] bundle: %w", i, err))
} else {
return nil
Expand All @@ -128,9 +192,9 @@ func verifyCosignReferrer(ctx context.Context, ref ImageRef, desc v1.Descriptor,
}

if len(bundleErrs) > 0 {
return fmt.Errorf("cosign bundle verification failed in referrer %s: %w", desc.Digest, errors.Join(bundleErrs...))
return fmt.Errorf("cosign v2 bundle verification failed: %w", errors.Join(bundleErrs...))
}
return fmt.Errorf("no cosign bundle annotation in signature referrer %s", desc.Digest)
return fmt.Errorf("no cosign bundle annotation found in signature manifest")
}

// verifyCosignBundleWith verifies a cosign Sigstore bundle against the expected
Expand Down
115 changes: 113 additions & 2 deletions cli/internal/verify/cosign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func TestVerifyCosignSignatureInvalidBundle(t *testing.T) {
},
Layers: []ociLayerDescriptor{
{
MediaType: cosignArtifactType,
MediaType: cosignV2ArtifactType,
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001",
Size: len(layerContent),
Annotations: map[string]string{
Expand All @@ -168,7 +168,7 @@ func TestVerifyCosignSignatureInvalidBundle(t *testing.T) {
MediaType: "application/vnd.oci.image.manifest.v1+json",
Digest: v1.Hash{Algorithm: "sha256", Hex: strings.TrimPrefix(sigDigest, "sha256:")},
Size: int64(len(sigManifestJSON)),
ArtifactType: cosignArtifactType,
ArtifactType: cosignV2ArtifactType,
},
},
}
Expand Down Expand Up @@ -217,6 +217,117 @@ func TestVerifyCosignSignatureInvalidBundle(t *testing.T) {
}
}

func TestIsCosignSignatureArtifact(t *testing.T) {
tests := []struct {
artifactType string
want bool
}{
{cosignV3BundleArtifactType, true},
{cosignV2ArtifactType, true},
{"application/vnd.oci.empty.v1+json", false},
{"application/vnd.in-toto+json", false},
{"", false},
}
for _, tt := range tests {
desc := v1.Descriptor{ArtifactType: tt.artifactType}
if got := isCosignSignatureArtifact(desc); got != tt.want {
t.Errorf("isCosignSignatureArtifact(%q) = %v, want %v", tt.artifactType, got, tt.want)
}
}
}

func TestVerifyCosignSignatureV3InvalidBundle(t *testing.T) {
// Mock registry that returns a cosign v3 signature (bundle as layer content).
repo := "test/image"
sigDigest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"

configJSON := `{}`
invalidBundle := `{"invalid": "v3 bundle"}`

sigManifest := ociManifest{
SchemaVersion: 2,
MediaType: "application/vnd.oci.image.manifest.v1+json",
Config: ociDescriptor{
MediaType: "application/vnd.oci.empty.v1+json",
Digest: "sha256:44136fa355b311bfa616a15e4e5e6d84e4f455ce82fb1ed83b0a7f9e2c3d4a5b",
Size: len(configJSON),
},
Layers: []ociLayerDescriptor{
{
MediaType: cosignV3BundleArtifactType,
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002",
Size: len(invalidBundle),
},
},
Annotations: map[string]string{
"dev.sigstore.bundle.content": "dsse-envelope",
"dev.sigstore.bundle.predicateType": "https://sigstore.dev/cosign/sign/v1",
},
}

sigManifestJSON, err := json.Marshal(sigManifest)
if err != nil {
t.Fatalf("marshaling v3 signature manifest: %v", err)
}

referrerIdx := v1.IndexManifest{
SchemaVersion: 2,
MediaType: "application/vnd.oci.image.index.v1+json",
Manifests: []v1.Descriptor{
{
MediaType: "application/vnd.oci.image.manifest.v1+json",
Digest: v1.Hash{Algorithm: "sha256", Hex: strings.TrimPrefix(sigDigest, "sha256:")},
Size: int64(len(sigManifestJSON)),
ArtifactType: cosignV3BundleArtifactType,
},
},
}
referrerIdxJSON, err := json.Marshal(referrerIdx)
if err != nil {
t.Fatalf("marshaling referrer index: %v", err)
}

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v2/":
w.WriteHeader(http.StatusOK)
case strings.Contains(r.URL.Path, "/referrers/"):
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
_, _ = w.Write(referrerIdxJSON)
case r.URL.Path == fmt.Sprintf("/v2/%s/manifests/%s", repo, sigDigest):
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", sigDigest)
_, _ = w.Write(sigManifestJSON)
case strings.Contains(r.URL.Path, "/blobs/"):
if strings.Contains(r.URL.Path, "44136fa") {
_, _ = w.Write([]byte(configJSON))
} else {
// Return invalid bundle as layer content (v3 format).
_, _ = w.Write([]byte(invalidBundle))
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()

host := strings.TrimPrefix(srv.URL, "http://")
ref := ImageRef{
Registry: host,
Repository: repo,
Tag: "1.0.0",
Digest: testDigest,
}

err = VerifyCosignSignature(context.Background(), ref, nil, sigverify.CertificateIdentity{})
if err == nil {
t.Fatal("expected error for invalid v3 bundle")
}
if !strings.Contains(err.Error(), "cosign signature") {
t.Errorf("expected cosign signature error, got: %v", err)
}
}

func TestErrNoCosignSignaturesIs(t *testing.T) {
wrapped := fmt.Errorf("%w for ghcr.io/test:1.0", ErrNoCosignSignatures)
if !errors.Is(wrapped, ErrNoCosignSignatures) {
Expand Down
Loading