Skip to content

Commit

Permalink
Merge pull request cli#9421 from cli/eugene/attestation/fetch-oci-bundle
Browse files Browse the repository at this point in the history
Fetch bundle from OCI registry for verify
  • Loading branch information
ejahnGithub authored Aug 22, 2024
2 parents c81ccab + e21d053 commit ef9069a
Show file tree
Hide file tree
Showing 10 changed files with 329 additions and 56 deletions.
7 changes: 7 additions & 0 deletions pkg/cmd/attestation/artifact/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"

"github.com/google/go-containerregistry/pkg/name"

"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
)

Expand All @@ -22,6 +24,7 @@ type DigestedArtifact struct {
URL string
digest string
digestAlg string
nameRef name.Reference
}

func normalizeReference(reference string, pathSeparator rune) (normalized string, artifactType artifactType, err error) {
Expand Down Expand Up @@ -77,3 +80,7 @@ func (a *DigestedArtifact) Algorithm() string {
func (a *DigestedArtifact) DigestWithAlg() string {
return fmt.Sprintf("%s:%s", a.digestAlg, a.digest)
}

func (a *DigestedArtifact) NameRef() name.Reference {
return a.nameRef
}
4 changes: 3 additions & 1 deletion pkg/cmd/attestation/artifact/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ func digestContainerImageArtifact(url string, client oci.Client) (*DigestedArtif
return nil, fmt.Errorf("artifact %s is not a valid registry reference: %v", url, err)
}

digest, err := client.GetImageDigest(named.String())
digest, nameRef, err := client.GetImageDigest(named.String())

if err != nil {
return nil, err
}
Expand All @@ -24,5 +25,6 @@ func digestContainerImageArtifact(url string, client oci.Client) (*DigestedArtif
URL: fmt.Sprintf("oci://%s", named.String()),
digest: digest.Hex,
digestAlg: digest.Algorithm,
nameRef: nameRef,
}, nil
}
105 changes: 97 additions & 8 deletions pkg/cmd/attestation/artifact/oci/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ package oci
import (
"errors"
"fmt"
"io"
"net/http"
"strings"

"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1"
"github.com/sigstore/sigstore-go/pkg/bundle"

"github.com/cli/cli/v2/pkg/cmd/attestation/api"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
)
Expand All @@ -15,7 +21,8 @@ var ErrDenied = errors.New("the provided token was denied access to the requeste
var ErrRegistryAuthz = errors.New("remote registry authorization failed, please authenticate with the registry and try again")

type Client interface {
GetImageDigest(imgName string) (*v1.Hash, error)
GetImageDigest(imgName string) (*v1.Hash, name.Reference, error)
GetAttestations(name name.Reference, digest string) ([]*api.Attestation, error)
}

func checkForUnauthorizedOrDeniedErr(err transport.Error) error {
Expand All @@ -35,13 +42,16 @@ type LiveClient struct {
get func(name.Reference, ...remote.Option) (*remote.Descriptor, error)
}

func (c LiveClient) ParseReference(ref string) (name.Reference, error) {
return c.parseReference(ref)
}

// where name is formed like ghcr.io/github/my-image-repo
func (c LiveClient) GetImageDigest(imgName string) (*v1.Hash, error) {
func (c LiveClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, error) {
name, err := c.parseReference(imgName)
if err != nil {
return nil, fmt.Errorf("failed to create image tag: %v", err)
return nil, nil, fmt.Errorf("failed to create image tag: %v", err)
}

// The user must already be authenticated with the container registry
// The authn.DefaultKeychain argument indicates that Get should checks the
// user's configuration for the registry credentials
Expand All @@ -50,13 +60,92 @@ func (c LiveClient) GetImageDigest(imgName string) (*v1.Hash, error) {
var transportErr *transport.Error
if errors.As(err, &transportErr) {
if accessErr := checkForUnauthorizedOrDeniedErr(*transportErr); accessErr != nil {
return nil, accessErr
return nil, nil, accessErr
}
}
return nil, fmt.Errorf("failed to fetch remote image: %v", err)
return nil, nil, fmt.Errorf("failed to fetch remote image: %v", err)
}

return &desc.Digest, name, nil
}

type noncompliantRegistryTransport struct{}

// RoundTrip will check if a request and associated response fulfill the following:
// 1. The response returns a 406 status code
// 2. The request path contains /referrers/
// If both conditions are met, the response's status code will be overwritten to 404
// This is a temporary solution to handle non compliant registries that return
// an unexpected status code 406 when the go-containerregistry library used
// by this code attempts to make a request to the referrers API.
// The go-containerregistry library can handle 404 response but not a 406 response.
// See the related go-containerregistry issue: https://github.com/google/go-containerregistry/issues/1962
func (a *noncompliantRegistryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
return resp, err
}
if resp.StatusCode == http.StatusNotAcceptable && strings.Contains(req.URL.Path, "/referrers/") {
resp.StatusCode = http.StatusNotFound
}

return resp, err
}

func (c LiveClient) GetAttestations(ref name.Reference, digest string) ([]*api.Attestation, error) {
attestations := make([]*api.Attestation, 0)

transportOpts := []remote.Option{remote.WithTransport(&noncompliantRegistryTransport{}), remote.WithAuthFromKeychain(authn.DefaultKeychain)}
referrers, err := remote.Referrers(ref.Context().Digest(digest), transportOpts...)
if err != nil {
return attestations, fmt.Errorf("error getting referrers: %w", err)
}
refManifest, err := referrers.IndexManifest()
if err != nil {
return attestations, fmt.Errorf("error getting referrers manifest: %w", err)
}

for _, refDesc := range refManifest.Manifests {
if !strings.HasPrefix(refDesc.ArtifactType, "application/vnd.dev.sigstore.bundle") {
continue
}

refImg, err := remote.Image(ref.Context().Digest(refDesc.Digest.String()), remote.WithAuthFromKeychain(authn.DefaultKeychain))
if err != nil {
return attestations, fmt.Errorf("error getting referrer image: %w", err)
}
layers, err := refImg.Layers()
if err != nil {
return attestations, fmt.Errorf("error getting referrer image: %w", err)
}

if len(layers) > 0 {
layer0, err := layers[0].Uncompressed()
if err != nil {
return attestations, fmt.Errorf("error getting referrer image: %w", err)
}
defer layer0.Close()

return &desc.Digest, nil
bundleBytes, err := io.ReadAll(layer0)

if err != nil {
return attestations, fmt.Errorf("error getting referrer image: %w", err)
}

b := &bundle.ProtobufBundle{}
err = b.UnmarshalJSON(bundleBytes)

if err != nil {
return attestations, fmt.Errorf("error unmarshalling bundle: %w", err)
}

a := api.Attestation{Bundle: b}
attestations = append(attestations, &a)
} else {
return attestations, fmt.Errorf("error getting referrer image: no layers found")
}
}
return attestations, nil
}

// Unlike other parts of this command set, we cannot pass a custom HTTP client
Expand Down
14 changes: 9 additions & 5 deletions pkg/cmd/attestation/artifact/oci/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"testing"

"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"

Expand All @@ -30,9 +30,10 @@ func TestGetImageDigest_Success(t *testing.T) {
},
}

digest, err := c.GetImageDigest("test")
digest, nameRef, err := c.GetImageDigest("test")
require.NoError(t, err)
require.Equal(t, &expectedDigest, digest)
require.Equal(t, name.Tag{}, nameRef)
}

func TestGetImageDigest_ReferenceFail(t *testing.T) {
Expand All @@ -45,9 +46,10 @@ func TestGetImageDigest_ReferenceFail(t *testing.T) {
},
}

digest, err := c.GetImageDigest("test")
digest, nameRef, err := c.GetImageDigest("test")
require.Error(t, err)
require.Nil(t, digest)
require.Nil(t, nameRef)
}

func TestGetImageDigest_AuthFail(t *testing.T) {
Expand All @@ -60,10 +62,11 @@ func TestGetImageDigest_AuthFail(t *testing.T) {
},
}

digest, err := c.GetImageDigest("test")
digest, nameRef, err := c.GetImageDigest("test")
require.Error(t, err)
require.ErrorIs(t, err, ErrRegistryAuthz)
require.Nil(t, digest)
require.Nil(t, nameRef)
}

func TestGetImageDigest_Denied(t *testing.T) {
Expand All @@ -76,8 +79,9 @@ func TestGetImageDigest_Denied(t *testing.T) {
},
}

digest, err := c.GetImageDigest("test")
digest, nameRef, err := c.GetImageDigest("test")
require.Error(t, err)
require.ErrorIs(t, err, ErrDenied)
require.Nil(t, digest)
require.Nil(t, nameRef)
}
69 changes: 60 additions & 9 deletions pkg/cmd/attestation/artifact/oci/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,83 @@ package oci
import (
"fmt"

"github.com/google/go-containerregistry/pkg/v1"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/test/data"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
)

func makeTestAttestation() api.Attestation {
return api.Attestation{Bundle: data.SigstoreBundle(nil)}
}

type MockClient struct{}

func (c MockClient) GetImageDigest(imgName string) (*v1.Hash, error) {
func (c MockClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, error) {
return &v1.Hash{
Hex: "1234567890abcdef",
Algorithm: "sha256",
}, nil
}, nil, nil
}

func (c MockClient) GetAttestations(name name.Reference, digest string) ([]*api.Attestation, error) {
att1 := makeTestAttestation()
att2 := makeTestAttestation()
return []*api.Attestation{&att1, &att2}, nil
}

type ReferenceFailClient struct{}

func (c ReferenceFailClient) GetImageDigest(imgName string) (*v1.Hash, error) {
return nil, fmt.Errorf("failed to parse reference")
func (c ReferenceFailClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, error) {
return nil, nil, fmt.Errorf("failed to parse reference")
}

func (c ReferenceFailClient) GetAttestations(name name.Reference, digest string) ([]*api.Attestation, error) {
return nil, nil
}

type AuthFailClient struct{}

func (c AuthFailClient) GetImageDigest(imgName string) (*v1.Hash, error) {
return nil, ErrRegistryAuthz
func (c AuthFailClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, error) {
return nil, nil, ErrRegistryAuthz
}

func (c AuthFailClient) GetAttestations(name name.Reference, digest string) ([]*api.Attestation, error) {
return nil, nil
}

type DeniedClient struct{}

func (c DeniedClient) GetImageDigest(imgName string) (*v1.Hash, error) {
return nil, ErrDenied
func (c DeniedClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, error) {
return nil, nil, ErrDenied
}

func (c DeniedClient) GetAttestations(name name.Reference, digest string) ([]*api.Attestation, error) {
return nil, nil
}

type NoAttestationsClient struct{}

func (c NoAttestationsClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, error) {
return &v1.Hash{
Hex: "1234567890abcdef",
Algorithm: "sha256",
}, nil, nil
}

func (c NoAttestationsClient) GetAttestations(name name.Reference, digest string) ([]*api.Attestation, error) {
return nil, nil
}

type FailedToFetchAttestationsClient struct{}

func (c FailedToFetchAttestationsClient) GetImageDigest(imgName string) (*v1.Hash, name.Reference, error) {
return &v1.Hash{
Hex: "1234567890abcdef",
Algorithm: "sha256",
}, nil, nil
}

func (c FailedToFetchAttestationsClient) GetAttestations(name name.Reference, digest string) ([]*api.Attestation, error) {
return nil, fmt.Errorf("failed to fetch attestations")
}
33 changes: 27 additions & 6 deletions pkg/cmd/attestation/verification/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,24 @@ import (
"path/filepath"

"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
"github.com/google/go-containerregistry/pkg/name"
protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
"github.com/sigstore/sigstore-go/pkg/bundle"
)

var ErrUnrecognisedBundleExtension = errors.New("bundle file extension not supported, must be json or jsonl")

type FetchAttestationsConfig struct {
APIClient api.Client
BundlePath string
Digest string
Limit int
Owner string
Repo string
APIClient api.Client
BundlePath string
Digest string
Limit int
Owner string
Repo string
OCIClient oci.Client
UseBundleFromRegistry bool
NameRef name.Reference
}

func (c *FetchAttestationsConfig) IsBundleProvided() bool {
Expand All @@ -32,6 +37,11 @@ func GetAttestations(c FetchAttestationsConfig) ([]*api.Attestation, error) {
if c.IsBundleProvided() {
return GetLocalAttestations(c.BundlePath)
}

if c.UseBundleFromRegistry {
return GetOCIAttestations(c)
}

return GetRemoteAttestations(c)
}

Expand Down Expand Up @@ -115,6 +125,17 @@ func GetRemoteAttestations(c FetchAttestationsConfig) ([]*api.Attestation, error
return nil, fmt.Errorf("owner or repo must be provided")
}

func GetOCIAttestations(c FetchAttestationsConfig) ([]*api.Attestation, error) {
attestations, err := c.OCIClient.GetAttestations(c.NameRef, c.Digest)
if err != nil {
return nil, fmt.Errorf("failed to fetch OCI attestations: %w", err)
}
if len(attestations) == 0 {
return nil, fmt.Errorf("no attestations found in the OCI registry. Retry the command without the --bundle-from-oci flag to check GitHub for the attestation")
}
return attestations, nil
}

type IntotoStatement struct {
PredicateType string `json:"predicateType"`
}
Expand Down
Loading

0 comments on commit ef9069a

Please sign in to comment.