Merge pull request #9421 from cli/eugene/attestation/fetch-oci-bundle

Fetch bundle from OCI registry for verify
This commit is contained in:
Eugene 2024-08-22 09:54:03 -04:00 committed by GitHub
commit ef9069a1b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 329 additions and 56 deletions

View file

@ -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"
)
@ -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) {
@ -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
}

View file

@ -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
}
@ -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
}

View file

@ -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"
)
@ -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 {
@ -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
@ -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, nil
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()
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

View file

@ -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"
@ -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) {
@ -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) {
@ -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) {
@ -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)
}

View file

@ -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")
}

View file

@ -9,6 +9,8 @@ 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"
)
@ -16,12 +18,15 @@ import (
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 {
@ -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)
}
@ -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"`
}

View file

@ -15,27 +15,28 @@ import (
// Options captures the options for the verify command
type Options struct {
ArtifactPath string
BundlePath string
Config func() (gh.Config, error)
TrustedRoot string
DenySelfHostedRunner bool
DigestAlgorithm string
Limit int
NoPublicGood bool
OIDCIssuer string
Owner string
PredicateType string
Repo string
SAN string
SANRegex string
SignerRepo string
SignerWorkflow string
APIClient api.Client
Logger *io.Handler
OCIClient oci.Client
SigstoreVerifier verification.SigstoreVerifier
exporter cmdutil.Exporter
ArtifactPath string
BundlePath string
UseBundleFromRegistry bool
Config func() (gh.Config, error)
TrustedRoot string
DenySelfHostedRunner bool
DigestAlgorithm string
Limit int
NoPublicGood bool
OIDCIssuer string
Owner string
PredicateType string
Repo string
SAN string
SANRegex string
SignerRepo string
SignerWorkflow string
APIClient api.Client
Logger *io.Handler
OCIClient oci.Client
SigstoreVerifier verification.SigstoreVerifier
exporter cmdutil.Exporter
}
// Clean cleans the file path option values
@ -83,6 +84,16 @@ func (opts *Options) AreFlagsValid() error {
return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit)
}
// Check that the bundle-from-oci flag is only used with OCI artifact paths
if opts.UseBundleFromRegistry && !strings.HasPrefix(opts.ArtifactPath, "oci://") {
return fmt.Errorf("bundle-from-oci flag can only be used with OCI artifact paths")
}
// Check that both the bundle-from-oci and bundle-path flags are not used together
if opts.UseBundleFromRegistry && opts.BundlePath != "" {
return fmt.Errorf("bundle-from-oci flag cannot be used with bundle-path flag")
}
return nil
}

View file

@ -116,4 +116,47 @@ func TestSetPolicyFlags(t *testing.T) {
require.Equal(t, "sigstore", opts.Owner)
require.Equal(t, "^https://github/foo", opts.SANRegex)
})
t.Run("returns error when UseBundleFromRegistry is true and ArtifactPath is not an OCI path", func(t *testing.T) {
opts := Options{
ArtifactPath: publicGoodArtifactPath,
DigestAlgorithm: "sha512",
Owner: "sigstore",
UseBundleFromRegistry: true,
Limit: 1,
}
err := opts.AreFlagsValid()
require.Error(t, err)
require.ErrorContains(t, err, "bundle-from-oci flag can only be used with OCI artifact paths")
})
t.Run("does not return error when UseBundleFromRegistry is true and ArtifactPath is an OCI path", func(t *testing.T) {
opts := Options{
ArtifactPath: "oci://sigstore/sigstore-js:2.1.0",
DigestAlgorithm: "sha512",
OIDCIssuer: "some issuer",
Owner: "sigstore",
UseBundleFromRegistry: true,
Limit: 1,
}
err := opts.AreFlagsValid()
require.NoError(t, err)
})
t.Run("returns error when UseBundleFromRegistry is true and BundlePath is provided", func(t *testing.T) {
opts := Options{
ArtifactPath: "oci://sigstore/sigstore-js:2.1.0",
BundlePath: publicGoodBundlePath,
DigestAlgorithm: "sha512",
Owner: "sigstore",
UseBundleFromRegistry: true,
Limit: 1,
}
err := opts.AreFlagsValid()
require.Error(t, err)
require.ErrorContains(t, err, "bundle-from-oci flag cannot be used with bundle-path flag")
})
}

View file

@ -150,6 +150,7 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
// general flags
verifyCmd.Flags().StringVarP(&opts.BundlePath, "bundle", "b", "", "Path to bundle on disk, either a single bundle in a JSON file or a JSON lines file with multiple bundles")
cmdutil.DisableAuthCheckFlag(verifyCmd.Flags().Lookup("bundle"))
verifyCmd.Flags().BoolVarP(&opts.UseBundleFromRegistry, "bundle-from-oci", "", false, "When verifying an OCI image, fetch the attestation bundle from the OCI registry instead of from GitHub")
cmdutil.StringEnumFlag(verifyCmd, &opts.DigestAlgorithm, "digest-alg", "d", "sha256", []string{"sha256", "sha512"}, "The algorithm used to compute a digest of the artifact")
verifyCmd.Flags().StringVarP(&opts.Owner, "owner", "o", "", "GitHub organization to scope attestation lookup by")
verifyCmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository name in the format <owner>/<repo>")
@ -182,12 +183,15 @@ func runVerify(opts *Options) error {
opts.Logger.Printf("Loaded digest %s for %s\n", artifact.DigestWithAlg(), artifact.URL)
c := verification.FetchAttestationsConfig{
APIClient: opts.APIClient,
BundlePath: opts.BundlePath,
Digest: artifact.DigestWithAlg(),
Limit: opts.Limit,
Owner: opts.Owner,
Repo: opts.Repo,
APIClient: opts.APIClient,
BundlePath: opts.BundlePath,
Digest: artifact.DigestWithAlg(),
Limit: opts.Limit,
Owner: opts.Owner,
Repo: opts.Repo,
OCIClient: opts.OCIClient,
UseBundleFromRegistry: opts.UseBundleFromRegistry,
NameRef: artifact.NameRef(),
}
attestations, err := verification.GetAttestations(c)
if err != nil {
@ -198,6 +202,8 @@ func runVerify(opts *Options) error {
if c.IsBundleProvided() {
opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Loading attestations from %s failed\n"), artifact.URL)
} else if c.UseBundleFromRegistry {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Loading attestations from OCI registry failed"))
} else {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Loading attestations from GitHub API failed"))
}
@ -207,6 +213,8 @@ func runVerify(opts *Options) error {
pluralAttestation := text.Pluralize(len(attestations), "attestation")
if c.IsBundleProvided() {
opts.Logger.Printf("Loaded %s from %s\n", pluralAttestation, opts.BundlePath)
} else if c.UseBundleFromRegistry {
opts.Logger.Printf("Loaded %s from %s\n", pluralAttestation, opts.ArtifactPath)
} else {
opts.Logger.Printf("Loaded %s from GitHub API\n", pluralAttestation)
}

View file

@ -463,4 +463,41 @@ func TestRunVerify(t *testing.T) {
customOpts.BundlePath = ""
require.Error(t, runVerify(&customOpts))
})
t.Run("with valid OCI artifact", func(t *testing.T) {
customOpts := publicGoodOpts
customOpts.ArtifactPath = "oci://ghcr.io/github/test"
customOpts.BundlePath = ""
require.Nil(t, runVerify(&customOpts))
})
t.Run("with valid OCI artifact with UseBundleFromRegistry flag", func(t *testing.T) {
customOpts := publicGoodOpts
customOpts.ArtifactPath = "oci://ghcr.io/github/test"
customOpts.BundlePath = ""
customOpts.UseBundleFromRegistry = true
require.Nil(t, runVerify(&customOpts))
})
t.Run("with valid OCI artifact with UseBundleFromRegistry flag but no bundle return from registry", func(t *testing.T) {
customOpts := publicGoodOpts
customOpts.ArtifactPath = "oci://ghcr.io/github/test"
customOpts.BundlePath = ""
customOpts.UseBundleFromRegistry = true
customOpts.OCIClient = oci.NoAttestationsClient{}
require.ErrorContains(t, runVerify(&customOpts), "no attestations found in the OCI registry. Retry the command without the --bundle-from-oci flag to check GitHub for the attestation")
})
t.Run("with valid OCI artifact with UseBundleFromRegistry flag but fail on fetching bundle from registry", func(t *testing.T) {
customOpts := publicGoodOpts
customOpts.ArtifactPath = "oci://ghcr.io/github/test"
customOpts.BundlePath = ""
customOpts.UseBundleFromRegistry = true
customOpts.OCIClient = oci.NoAttestationsClient{}
require.ErrorContains(t, runVerify(&customOpts), "no attestations found in the OCI registry. Retry the command without the --bundle-from-oci flag to check GitHub for the attestation")
})
}