diff --git a/container/verifier/attestations.go b/container/verifier/attestations.go index 2eb4d9f..975496d 100644 --- a/container/verifier/attestations.go +++ b/container/verifier/attestations.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" containerdigest "github.com/opencontainers/go-digest" "github.com/sigstore/sigstore-go/pkg/bundle" @@ -60,33 +61,33 @@ func bundleFromAttestation(imageRef string, keychain authn.Keychain) ([]sigstore // Loop through all available attestations and extract the bundle for _, refDesc := range refManifest.Manifests { - if !strings.HasPrefix(refDesc.ArtifactType, "application/vnd.dev.sigstore.bundle") { + // Fast path: skip referrers that are clearly not sigstore bundles without + // fetching the manifest. Only do a deep inspection when the artifact type + // is ambiguous (empty or "application/vnd.oci.empty.v1+json"), which + // happens due to a go-containerregistry bug (google/go-containerregistry#1997) + // where the referrers fallback tag doesn't propagate the inner manifest's + // artifactType. + if !hasSigstoreBundlePrefix(refDesc.ArtifactType) && + refDesc.ArtifactType != "application/vnd.oci.empty.v1+json" && + refDesc.ArtifactType != "" { continue } + refImg, err := remote.Image(ref.Context().Digest(refDesc.Digest.String()), opts...) if err != nil { slog.Debug("error getting referrer image", "error", err) continue } - layers, err := refImg.Layers() - if err != nil { - slog.Debug("error getting referrer layers", "error", err) - continue - } - layer0, err := layers[0].Uncompressed() - if err != nil { - slog.Debug("error uncompressing referrer layer", "error", err) - continue - } - bundleBytes, err := io.ReadAll(layer0) - if err != nil { - slog.Debug("error reading referrer layer", "error", err) + + // When the index descriptor's artifactType is ambiguous, inspect the + // actual manifest to determine whether this is a sigstore bundle. + if !hasSigstoreBundlePrefix(refDesc.ArtifactType) && !isSigstoreBundle(refImg) { continue } - b := &bundle.Bundle{} - err = b.UnmarshalJSON(bundleBytes) + + b, err := extractBundleFromImage(refImg) if err != nil { - slog.Debug("error unmarshalling bundle", "error", err) + slog.Debug("error extracting bundle from referrer", "error", err) continue } @@ -101,3 +102,58 @@ func bundleFromAttestation(imageRef string, keychain authn.Keychain) ([]sigstore } return bundles, nil } + +// extractBundleFromImage reads and parses a sigstore bundle from the first layer of an OCI image. +func extractBundleFromImage(img v1.Image) (*bundle.Bundle, error) { + layers, err := img.Layers() + if err != nil { + return nil, fmt.Errorf("error getting referrer layers: %w", err) + } + if len(layers) == 0 { + return nil, fmt.Errorf("referrer has no layers") + } + layer0, err := layers[0].Uncompressed() + if err != nil { + return nil, fmt.Errorf("error uncompressing referrer layer: %w", err) + } + bundleBytes, err := io.ReadAll(layer0) + if err != nil { + return nil, fmt.Errorf("error reading referrer layer: %w", err) + } + b := &bundle.Bundle{} + if err = b.UnmarshalJSON(bundleBytes); err != nil { + return nil, fmt.Errorf("error unmarshalling bundle: %w", err) + } + return b, nil +} + +// isSigstoreBundle inspects the actual manifest of a referrer image to +// determine whether it is a sigstore bundle. This is used as a fallback when +// the referrer index descriptor's artifactType is ambiguous (e.g. GHCR sets it +// to "application/vnd.oci.empty.v1+json" due to google/go-containerregistry#1997). +func isSigstoreBundle(img v1.Image) bool { + mf, err := img.Manifest() + if err != nil { + slog.Debug("error fetching manifest for sigstore bundle check", "error", err) + return false + } + + // Check the config descriptor's artifactType (set by cosign v2+ when using OCI 1.1 referrers) + if hasSigstoreBundlePrefix(mf.Config.ArtifactType) { + return true + } + + // Check layer media types as a final fallback + for _, layer := range mf.Layers { + if hasSigstoreBundlePrefix(string(layer.MediaType)) { + return true + } + } + + return false +} + +// hasSigstoreBundlePrefix checks if a media/artifact type string indicates a sigstore bundle. +func hasSigstoreBundlePrefix(s string) bool { + return strings.HasPrefix(s, "application/vnd.dev.sigstore.bundle") +} diff --git a/container/verifier/attestations_test.go b/container/verifier/attestations_test.go new file mode 100644 index 0000000..64109ec --- /dev/null +++ b/container/verifier/attestations_test.go @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "fmt" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/stretchr/testify/require" +) + +// fakeImage implements v1.Image with a configurable manifest for testing +// isSigstoreBundle. Only Manifest() is used by the code under test; all other +// methods panic if called so that accidental usage is caught immediately. +type fakeImage struct { + manifest *v1.Manifest + err error +} + +func (f *fakeImage) Manifest() (*v1.Manifest, error) { return f.manifest, f.err } + +// Unused interface methods — panic to surface accidental calls. +func (*fakeImage) Layers() ([]v1.Layer, error) { panic("not implemented") } +func (*fakeImage) MediaType() (types.MediaType, error) { panic("not implemented") } +func (*fakeImage) Size() (int64, error) { panic("not implemented") } +func (*fakeImage) ConfigName() (v1.Hash, error) { panic("not implemented") } +func (*fakeImage) ConfigFile() (*v1.ConfigFile, error) { panic("not implemented") } +func (*fakeImage) RawConfigFile() ([]byte, error) { panic("not implemented") } +func (*fakeImage) Digest() (v1.Hash, error) { panic("not implemented") } +func (*fakeImage) RawManifest() ([]byte, error) { panic("not implemented") } +func (*fakeImage) LayerByDigest(v1.Hash) (v1.Layer, error) { panic("not implemented") } +func (*fakeImage) LayerByDiffID(v1.Hash) (v1.Layer, error) { panic("not implemented") } + +func TestHasSigstoreBundlePrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want bool + }{ + { + name: "exact v0.1 bundle type", + input: "application/vnd.dev.sigstore.bundle+json;version=0.1", + want: true, + }, + { + name: "v0.3 bundle type", + input: "application/vnd.dev.sigstore.bundle.v0.3+json", + want: true, + }, + { + name: "bare prefix without version", + input: "application/vnd.dev.sigstore.bundle", + want: true, + }, + { + name: "OCI empty type (ambiguous, not a bundle)", + input: "application/vnd.oci.empty.v1+json", + want: false, + }, + { + name: "cosign simplesigning type", + input: "application/vnd.dev.cosign.simplesigning.v1+json", + want: false, + }, + { + name: "empty string", + input: "", + want: false, + }, + { + name: "unrelated media type", + input: "application/json", + want: false, + }, + { + name: "partial prefix match (missing dev)", + input: "application/vnd.sigstore.bundle", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := hasSigstoreBundlePrefix(tt.input) + require.Equal(t, tt.want, got) + }) + } +} + +func TestIsSigstoreBundle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + img v1.Image + want bool + }{ + { + name: "config artifactType is sigstore bundle v0.3", + img: &fakeImage{manifest: &v1.Manifest{ + Config: v1.Descriptor{ + ArtifactType: "application/vnd.dev.sigstore.bundle.v0.3+json", + }, + }}, + want: true, + }, + { + name: "config artifactType is sigstore bundle v0.1", + img: &fakeImage{manifest: &v1.Manifest{ + Config: v1.Descriptor{ + ArtifactType: "application/vnd.dev.sigstore.bundle+json;version=0.1", + }, + }}, + want: true, + }, + { + name: "layer media type is sigstore bundle", + img: &fakeImage{manifest: &v1.Manifest{ + Config: v1.Descriptor{ + ArtifactType: "application/vnd.oci.empty.v1+json", + }, + Layers: []v1.Descriptor{ + {MediaType: types.MediaType("application/vnd.dev.sigstore.bundle.v0.3+json")}, + }, + }}, + want: true, + }, + { + name: "neither config nor layers match", + img: &fakeImage{manifest: &v1.Manifest{ + Config: v1.Descriptor{ + ArtifactType: "application/vnd.oci.empty.v1+json", + }, + Layers: []v1.Descriptor{ + {MediaType: types.MediaType("application/vnd.oci.image.layer.v1.tar+gzip")}, + }, + }}, + want: false, + }, + { + name: "empty manifest (no config, no layers)", + img: &fakeImage{manifest: &v1.Manifest{}}, + want: false, + }, + { + name: "manifest fetch error returns false", + img: &fakeImage{err: fmt.Errorf("network error")}, + want: false, + }, + { + name: "multiple layers, second is sigstore bundle", + img: &fakeImage{manifest: &v1.Manifest{ + Layers: []v1.Descriptor{ + {MediaType: types.MediaType("application/octet-stream")}, + {MediaType: types.MediaType("application/vnd.dev.sigstore.bundle.v0.3+json")}, + }, + }}, + want: true, + }, + { + name: "cosign simplesigning layer is not a sigstore bundle", + img: &fakeImage{manifest: &v1.Manifest{ + Layers: []v1.Descriptor{ + {MediaType: types.MediaType("application/vnd.dev.cosign.simplesigning.v1+json")}, + }, + }}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := isSigstoreBundle(tt.img) + require.Equal(t, tt.want, got) + }) + } +} + +// TestBundleFromAttestation_FilterLogic validates the fast-path and +// deep-inspection filtering in bundleFromAttestation by documenting the +// expected skip/inspect behavior for different artifactType values in the +// referrers index. We cannot call bundleFromAttestation directly (it hits the +// network), so instead we verify the two helper predicates that drive the +// filtering logic, mirroring the conditions in the loop: +// +// skip = !hasSigstoreBundlePrefix(at) && at != "application/vnd.oci.empty.v1+json" && at != "" +// deepInspect = !hasSigstoreBundlePrefix(at) (only reached when not skipped) +func TestBundleFromAttestation_FilterPredicates(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + artType string + wantSkip bool // true = fast-path skip (no manifest fetch) + wantDeep bool // true = needs deep inspection via isSigstoreBundle + }{ + { + name: "sigstore bundle v0.3 - accepted without deep inspect", + artType: "application/vnd.dev.sigstore.bundle.v0.3+json", + wantSkip: false, + wantDeep: false, + }, + { + name: "OCI empty (go-containerregistry bug) - needs deep inspect", + artType: "application/vnd.oci.empty.v1+json", + wantSkip: false, + wantDeep: true, + }, + { + name: "empty string - needs deep inspect", + artType: "", + wantSkip: false, + wantDeep: true, + }, + { + name: "cosign simplesigning - fast-path skip", + artType: "application/vnd.dev.cosign.simplesigning.v1+json", + wantSkip: true, + wantDeep: false, // never reached + }, + { + name: "arbitrary OCI type - fast-path skip", + artType: "application/vnd.oci.image.manifest.v1+json", + wantSkip: true, + wantDeep: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Replicate the skip condition from bundleFromAttestation + skip := !hasSigstoreBundlePrefix(tt.artType) && + tt.artType != "application/vnd.oci.empty.v1+json" && + tt.artType != "" + require.Equal(t, tt.wantSkip, skip, "skip predicate mismatch") + + if !skip { + deepInspect := !hasSigstoreBundlePrefix(tt.artType) + require.Equal(t, tt.wantDeep, deepInspect, "deep-inspect predicate mismatch") + } + }) + } +} diff --git a/container/verifier/verifier_integration_test.go b/container/verifier/verifier_integration_test.go new file mode 100644 index 0000000..e3f3fcd --- /dev/null +++ b/container/verifier/verifier_integration_test.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/stretchr/testify/require" + + registry "github.com/stacklok/toolhive-core/registry/types" +) + +// ocireg-mcp provenance values shared by all versions. +// These match the catalog entry at registries/toolhive/servers/oci-registry/server.json. +var ociregProvenance = ®istry.Provenance{ + SigstoreURL: "tuf-repo-cdn.sigstore.dev", + RepositoryURI: "https://github.com/StacklokLabs/ocireg-mcp", + SignerIdentity: "/.github/workflows/release.yml", + RunnerEnvironment: "github-hosted", + CertIssuer: "https://token.actions.githubusercontent.com", +} + +// TestVerifyServer_LiveImages tests the full VerifyServer flow against real +// public GHCR images. It covers both the legacy cosign .sig tag format and +// the newer OCI 1.1 referrers format (sigstore bundle v0.3). +// +// These tests hit the network (GHCR + sigstore TUF) and are skipped when +// running with -short. +func TestVerifyServer_LiveImages(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping live image verification test (requires network)") + } + + tests := []struct { + name string + image string + provenance *registry.Provenance + wantErr error + }{ + { + name: "v0.1.0 - legacy cosign .sig tag signature", + image: "ghcr.io/stackloklabs/ocireg-mcp/server:0.1.0", + provenance: ociregProvenance, + }, + { + name: "v0.2.1 - OCI 1.1 referrers with sigstore bundle v0.3", + image: "ghcr.io/stackloklabs/ocireg-mcp/server:0.2.1", + provenance: ociregProvenance, + }, + { + name: "wrong provenance returns ErrProvenanceMismatch", + image: "ghcr.io/stackloklabs/ocireg-mcp/server:0.1.0", + provenance: ®istry.Provenance{ + SigstoreURL: "tuf-repo-cdn.sigstore.dev", + RepositoryURI: "https://github.com/wrong/repo", + SignerIdentity: "/.github/workflows/release.yml", + RunnerEnvironment: "github-hosted", + CertIssuer: "https://token.actions.githubusercontent.com", + }, + wantErr: ErrProvenanceMismatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + v, err := New(tt.provenance, authn.DefaultKeychain) + require.NoError(t, err, "failed to create verifier") + + err = v.VerifyServer(tt.image, tt.provenance) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err, "VerifyServer should succeed for %s", tt.image) + } + }) + } +}