diff --git a/src/pkg/images/common.go b/src/pkg/images/common.go index 0782a066d0..c2608691d7 100644 --- a/src/pkg/images/common.go +++ b/src/pkg/images/common.go @@ -23,7 +23,9 @@ import ( "github.com/zarf-dev/zarf/src/pkg/logger" "github.com/zarf-dev/zarf/src/pkg/state" "oras.land/oras-go/v2/content" + orasRemote "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" "oras.land/oras-go/v2/registry/remote/retry" ) @@ -187,6 +189,48 @@ func saveIndexToOCILayout(dir string, idx ocispec.Index) error { return nil } +// NewAuthClientFromDocker creates an ORAS auth client from the default Docker credentials store. +func NewAuthClientFromDocker(ctx context.Context, insecureSkipTLSVerify bool, responseHeaderTimeout time.Duration, preAuthHosts map[string]struct{}) (_ *auth.Client, err error) { + credStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %w", err) + } + + transport, err := orasTransport(insecureSkipTLSVerify, responseHeaderTimeout) + if err != nil { + return nil, err + } + + client := &auth.Client{ + Client: &http.Client{ + Transport: transport, + }, + Cache: auth.NewCache(), + Credential: credentials.Credential(credStore), + } + + logger.From(ctx).Debug("gathering credentials from default Docker config file", "credentialsConfigured", credStore.IsAuthConfigured()) + + // We ping registries to pre-authenticate as some auth mechanisms open up a browser. + // When this happens concurrently a browser tab is opened for each image from that host and authenticating to one tab will not propagate creds. + // Instead we auth synchronously with ping so the auth is cached before concurrent fetch. + if credStore.IsAuthConfigured() { + for host := range preAuthHosts { + if host == "" { + continue + } + registry, err := orasRemote.NewRegistry(host) + if err != nil { + return nil, fmt.Errorf("failed to create registry: %w", err) + } + registry.Client = client + _ = registry.Ping(ctx) //nolint: errcheck + } + } + + return client, nil +} + func orasTransport(insecureSkipTLSVerify bool, responseHeaderTimeout time.Duration) (*retry.Transport, error) { transport, ok := http.DefaultTransport.(*http.Transport) if !ok { diff --git a/src/pkg/images/pull.go b/src/pkg/images/pull.go index c656f81ff5..9ba6fbdcf9 100644 --- a/src/pkg/images/pull.go +++ b/src/pkg/images/pull.go @@ -8,7 +8,6 @@ import ( "context" "errors" "fmt" - "net/http" "os" "path/filepath" "strings" @@ -40,7 +39,6 @@ import ( "github.com/zarf-dev/zarf/src/pkg/utils" orasRemote "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" - "oras.land/oras-go/v2/registry/remote/credentials" ) // PullOptions is the configuration for pulling images. @@ -115,42 +113,16 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory imageFetchStart := time.Now() l.Info("fetching info for images", "count", imageCount, "destination", destinationDirectory) - storeOpts := credentials.StoreOptions{} - credStore, err := credentials.NewStoreFromDocker(storeOpts) - if err != nil { - return nil, fmt.Errorf("failed to get credentials: %w", err) - } - transport, err := orasTransport(opts.InsecureSkipTLSVerify, opts.ResponseHeaderTimeout) - if err != nil { - return nil, err - } - client := &auth.Client{ - Client: &http.Client{ - Transport: transport, - }, - Cache: auth.NewCache(), - Credential: credentials.Credential(credStore), - } + uniqueHosts := map[string]struct{}{} for _, v := range imagesWithOverride { uniqueHosts[v.overridden.Host] = struct{}{} } - // We ping registries to pre-authenticate as some auth mechanisms open up a browser. - // When this happens concurrently a browser tab is opened for each image from that host and authenticating to one tab will not propagate creds - // Instead we auth synchronously with ping so the auth is cached before concurrent fetch. - if credStore.IsAuthConfigured() { - for host := range uniqueHosts { - registry, err := orasRemote.NewRegistry(host) - if err != nil { - return nil, fmt.Errorf("failed to create registry: %w", err) - } - registry.Client = client - // we can't error here because there may be a faked registry used for the docker fallback mechanism - _ = registry.Ping(ctx) //nolint: errcheck - } + client, err := NewAuthClientFromDocker(ctx, opts.InsecureSkipTLSVerify, opts.ResponseHeaderTimeout, uniqueHosts) + if err != nil { + return nil, err } - l.Debug("gathering credentials from default Docker config file", "credentialsConfigured", credStore.IsAuthConfigured()) platform := &ocispec.Platform{ Architecture: opts.Arch, // TODO: in the future we could support Windows images diff --git a/src/pkg/packager/find_images.go b/src/pkg/packager/find_images.go index 57f33611aa..b26d84b567 100644 --- a/src/pkg/packager/find_images.go +++ b/src/pkg/packager/find_images.go @@ -400,6 +400,30 @@ func findImages(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath strin "duration", time.Since(imgCompStart)) if !opts.SkipCosign { + cosignHosts := map[string]struct{}{} + addCosignHosts := func(images []string) error { + for _, image := range images { + parsed, parseErr := transform.ParseImageRef(image) + if parseErr != nil { + return fmt.Errorf("could not parse image reference for cosign pre-auth %s: %w", image, parseErr) + } + cosignHosts[parsed.Host] = struct{}{} + } + + return nil + } + if err := addCosignHosts(scan.Matches); err != nil { + return nil, err + } + if err := addCosignHosts(scan.PotentialMatches); err != nil { + return nil, err + } + + cosignClient, err := images.NewAuthClientFromDocker(ctx, opts.InsecureSkipTLSVerify, 0, cosignHosts) + if err != nil { + return nil, err + } + // Handle cosign artifact lookups if len(scan.Matches) > 0 || len(scan.PotentialMatches) > 0 { imgStart := time.Now() @@ -407,7 +431,7 @@ func findImages(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath strin for _, image := range scan.Matches { l.Debug("looking up cosign artifacts for image", "name", image) - cosignArtifacts, err := utils.GetCosignArtifacts(image) + cosignArtifacts, err := utils.GetCosignArtifacts(ctx, image, cosignClient) if err != nil { return nil, fmt.Errorf("could not lookup the cosign artifacts for image %s: %w", image, err) } @@ -416,7 +440,7 @@ func findImages(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath strin for _, image := range scan.PotentialMatches { l.Debug("looking up cosign artifacts for image", "name", image) - cosignArtifacts, err := utils.GetCosignArtifacts(image) + cosignArtifacts, err := utils.GetCosignArtifacts(ctx, image, cosignClient) if err != nil { return nil, fmt.Errorf("could not lookup the cosign artifacts for image %s: %w", image, err) } diff --git a/src/pkg/utils/oci_artifacts.go b/src/pkg/utils/oci_artifacts.go index d392d5e318..3512ec9aea 100644 --- a/src/pkg/utils/oci_artifacts.go +++ b/src/pkg/utils/oci_artifacts.go @@ -5,52 +5,117 @@ package utils import ( + "context" + "errors" + "fmt" + "github.com/google/go-containerregistry/pkg/name" ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" + "github.com/zarf-dev/zarf/src/pkg/logger" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/errdef" + "oras.land/oras-go/v2/registry" + orasRemote "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" ) // GetCosignArtifacts returns signatures and attestations for the given image. -func GetCosignArtifacts(image string) ([]string, error) { +func GetCosignArtifacts(ctx context.Context, image string, client *auth.Client) ([]string, error) { + l := logger.From(ctx) + var nameOpts []name.Option + if client == nil { + return nil, fmt.Errorf("auth client is required") + } ref, err := name.ParseReference(image, nameOpts...) if err != nil { return nil, err } - var remoteOpts []ociremote.Option - simg, _ := ociremote.SignedEntity(ref, remoteOpts...) //nolint:errcheck - if simg == nil { + // We get the digest reference for the image specifically so that we can short circuit the + // `crane` lookup that would otherwise happen in ociremote.SignatureTag and ociremote.AttestationTag + digestRef, err := imageDigestRef(ctx, image, ref, client) + if err != nil { + // If we can't get the digest reference, we can't get the cosign artifacts so log the error and skip it + l.Debug("could not get digest reference for image", "image", image, "error", err) return nil, nil } - sigRef, _ := ociremote.SignatureTag(ref, remoteOpts...) //nolint:errcheck - attRef, _ := ociremote.AttestationTag(ref, remoteOpts...) //nolint:errcheck - - ss, err := simg.Signatures() + sigTag, err := ociremote.SignatureTag(digestRef) if err != nil { return nil, err } - ssLayers, err := ss.Layers() + attTag, err := ociremote.AttestationTag(digestRef) if err != nil { return nil, err } - var cosignArtifactList = make([]string, 0) - if 0 < len(ssLayers) { - cosignArtifactList = append(cosignArtifactList, sigRef.String()) - } + var cosignArtifactList = make([]string, 0, 2) - atts, err := simg.Attestations() + sigExists, err := existsInRemote(ctx, sigTag.String(), client) if err != nil { return nil, err } - aLayers, err := atts.Layers() + if sigExists { + cosignArtifactList = append(cosignArtifactList, sigTag.String()) + } + + attExists, err := existsInRemote(ctx, attTag.String(), client) if err != nil { return nil, err } - if 0 < len(aLayers) { - cosignArtifactList = append(cosignArtifactList, attRef.String()) + if attExists { + cosignArtifactList = append(cosignArtifactList, attTag.String()) } + return cosignArtifactList, nil } + +func imageDigestRef(ctx context.Context, reference string, parsedRef name.Reference, client *auth.Client) (name.Digest, error) { + if digestRef, ok := parsedRef.(name.Digest); ok { + return digestRef, nil + } + + repo := &orasRemote.Repository{} + orasRef, err := registry.ParseReference(reference) + if err != nil { + return name.Digest{}, err + } + repo.Reference = orasRef + repo.Client = client + + desc, err := oras.Resolve(ctx, repo, reference, oras.DefaultResolveOptions) + if err != nil { + return name.Digest{}, err + } + + digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", parsedRef.Context().Name(), desc.Digest.String())) + if err != nil { + return name.Digest{}, err + } + + return digestRef, nil +} + +func existsInRemote(ctx context.Context, reference string, client *auth.Client) (bool, error) { + repo := &orasRemote.Repository{} + + ref, err := registry.ParseReference(reference) + if err != nil { + return false, err + } + repo.Reference = ref + repo.Client = client + + _, err = oras.Resolve(ctx, repo, reference, oras.DefaultResolveOptions) + if err != nil { + if errors.Is(err, errdef.ErrNotFound) { + return false, nil + } + + return false, err + } + + return true, nil +}