diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index f2d180ce3..50295c192 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -203,7 +203,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, azureTenantIdFlag = "Azure tenant ID." azureSubscriptionIdFlag = "Azure subscription ID." azureResourceGroupNameFlag = "Azure resource group name." - azureDigestsSourceFlag = "[defaulted] Where to get the digests from. Valid values are 'acr' and 'logs'." + azureDigestsSourceFlag = "[defaulted] Where to get the digests from. Valid values are 'acr' and 'logs'. With 'acr', Azure credentials are only sent to Azure Container Registry login servers; an app whose image comes from any other registry is read without credentials, so a private third-party registry needs 'logs'." githubTokenFlag = "Github token." githubOrgFlag = "Github organization. (defaulted if you are running in GitHub Actions: https://docs.kosli.com/integrations/ci_cd )." githubBaseURLFlag = "[optional] GitHub base URL (only needed for GitHub Enterprise installations)." diff --git a/cmd/kosli/snapshotAzureApps.go b/cmd/kosli/snapshotAzureApps.go index bd0b12582..8ee90ac3e 100644 --- a/cmd/kosli/snapshotAzureApps.go +++ b/cmd/kosli/snapshotAzureApps.go @@ -24,6 +24,13 @@ will not match. See https://learn.microsoft.com/en-us/azure/azure-functions/functions-app-settings#website_run_from_package For zip-deployed apps, the fingerprint respects a ^.kosli_ignore^ file at the root of the deployed package. + +With ^--digests-source acr^, the registry is taken from each app's own container configuration. Azure +credentials are only ever sent to an Azure Container Registry login server. An app whose image comes +from any other registry is read without credentials, which works for a public image but not a private +one; report those apps with ^--digests-source logs^ instead. + +^--dry-run^ suppresses only the request to Kosli. Azure discovery and registry lookups still run. ` + kosliIgnoreDesc + azureAuthDesc const snapshotAzureAppsExample = ` diff --git a/go.mod b/go.mod index a5fd06f2b..4113c9966 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/aws/smithy-go v1.28.1 github.com/containerd/errdefs v1.0.0 github.com/containers/image/v5 v5.36.2 + github.com/distribution/reference v0.6.0 github.com/go-git/go-billy/v5 v5.9.1 github.com/go-git/go-git/v5 v5.19.2 github.com/go-playground/validator/v10 v10.30.3 @@ -33,6 +34,7 @@ require ( github.com/moby/moby/api v1.55.0 github.com/moby/moby/client v0.5.1 github.com/open-policy-agent/opa v1.20.1 + github.com/opencontainers/go-digest v1.0.0 github.com/otiai10/copy v1.14.1 github.com/owenrumney/go-sarif/v2 v2.3.3 github.com/pkg/errors v0.9.1 @@ -106,7 +108,6 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect - github.com/distribution/reference v0.6.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.3.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect @@ -193,7 +194,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/ginkgo/v2 v2.32.0 // indirect github.com/onsi/gomega v1.40.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/otiai10/mint v1.6.3 // indirect diff --git a/internal/azure/azure_apps.go b/internal/azure/azure_apps.go index 76a7b0215..7658228bf 100644 --- a/internal/azure/azure_apps.go +++ b/internal/azure/azure_apps.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -21,6 +22,8 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry" armappservice "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" smithyTime "github.com/aws/smithy-go/time" + "github.com/distribution/reference" + "github.com/kosli-dev/cli/internal/digest" "github.com/kosli-dev/cli/internal/logger" "github.com/kosli-dev/cli/internal/server" ) @@ -38,6 +41,9 @@ type AzureStaticCredentials struct { type AzureClient struct { Credentials AzureStaticCredentials AppServiceFactory *armappservice.ClientFactory + // acrClientOptions is nil in production. Tests set it so the ACR arm can be + // driven against a fake registry without package-level state. + acrClientOptions *azcontainerregistry.ClientOptions } // AppData represents the harvested Azure service app and function app data @@ -99,6 +105,9 @@ func (staticCreds *AzureStaticCredentials) GetAzureAppsData(logger *logger.Logge data, err := azureClient.NewAppData(app, logger) if err != nil { + // One app's error cancels the run, so say which app it was. Wrapped + // here rather than at each return so every path is covered once. + err = fmt.Errorf("app [%s]: %w", *app.Name, err) select { case errs <- err: default: @@ -265,7 +274,7 @@ func (azureClient *AzureClient) fingerprintZipService(app *armappservice.Site, l destDir := filepath.Join(tmpDir, "extracted") err = unzip(packagePath, destDir, logger) if err != nil { - return AppData{}, fmt.Errorf("failed to unzip downloaded package for app [%s]: %v", *app.Name, err) + return AppData{}, fmt.Errorf("failed to unzip the downloaded package: %v", err) } // fingerprint the downloaded and unzipped package @@ -377,7 +386,7 @@ func (azureClient *AzureClient) fingerprintDockerService(app *armappservice.Site if azureClient.Credentials.DigestsSource == "acr" { fingerprintSource = "acr" - fingerprint, err = azureClient.GetImageFingerprintFromRegistry(imageName, logger) + fingerprint, err = azureClient.GetImageFingerprint(imageName, logger) // Handle exception when image is not found in the registry but is found in the environment if err != nil { return AppData{}, err @@ -399,63 +408,181 @@ func (azureClient *AzureClient) fingerprintDockerService(app *armappservice.Site return AppData{*app.Name, *app.Kind, fingerprintSource, map[string]string{imageName: fingerprint}, startedAt}, nil } -func (azureClient *AzureClient) GetImageFingerprintFromRegistry(imageName string, logger *logger.Logger) (fingerprint string, err error) { - registryUrl, repoName, tag := parseImageName(imageName) +// acrLoginServerSuffixes are the Azure Container Registry login-server suffixes +// for the public, China and US Government clouds. The Azure SDK publishes only +// the token audience per cloud, not the login-server suffix. +var acrLoginServerSuffixes = []string{".azurecr.io", ".azurecr.cn", ".azurecr.us"} + +// isACRLoginServer reports whether domain is an Azure Container Registry login +// server, matching on a whole label so that "azurecr.io.example.com" is not one. +func isACRLoginServer(domain string) bool { + h := strings.ToLower(domain) + if hostWithoutPort, _, err := net.SplitHostPort(h); err == nil { + h = hostWithoutPort + } + for _, suffix := range acrLoginServerSuffixes { + if len(h) > len(suffix) && strings.HasSuffix(h, suffix) { + return true + } + } + return false +} + +// fingerprintPlan is how one image reference will be resolved. It is decided +// before anything is contacted, so a test can assert every value that crosses +// the boundary rather than only which resolver ran. +type fingerprintPlan struct { + // domain is the registry the reference names, as the parser reports it. + domain string + // reference is the canonical form handed to a resolver. Classification and + // resolution use this same value, so they cannot disagree about the host. + reference string + // repoPath and tagOrDigest address the manifest on the ACR arm. + repoPath string + tagOrDigest string + // pinnedFingerprint is the sha256 hex a digest-pinned reference claims, or + // empty when the reference is not pinned. + pinnedFingerprint string +} + +// planImageFingerprint decides how an App Service image reference is resolved. +// +// The reference is parsed with the same normalising parser the registry clients +// use rather than being split by hand, because a hand-rolled split can be talked +// into disagreeing with the client about which host it named: +// "reg.azurecr.io:443@attacker.example/repo:tag" passes a suffix check on the +// registry component but resolves to attacker.example as a URL. The parser +// rejects it. +// +// Only an Azure Container Registry login server gets the Azure credential, so +// the domain this reports is what keeps that credential away from a registry +// named in an app's own configuration. +func planImageFingerprint(imageName string) (fingerprintPlan, error) { + named, err := reference.ParseNormalizedNamed(imageName) + if err != nil { + return fingerprintPlan{}, fmt.Errorf("failed to parse the image name [%s]: %w", imageName, err) + } + + var plan fingerprintPlan + + if digested, ok := named.(reference.Digested); ok { + // A reference pinned to an algorithm Kosli cannot fingerprint can never + // match, so reject it here rather than after a pointless round trip. + plan.pinnedFingerprint, err = digest.Sha256Fingerprint(digested.Digest()) + if err != nil { + return fingerprintPlan{}, fmt.Errorf("image [%s] is pinned to a digest Kosli cannot use: %w", imageName, err) + } + // A digest is authoritative when a reference carries both, and + // containers/image refuses a reference holding a tag and a digest + // together, so drop the tag. + named, err = reference.WithDigest(reference.TrimNamed(named), digested.Digest()) + if err != nil { + return fingerprintPlan{}, fmt.Errorf("failed to normalise the image name [%s]: %w", imageName, err) + } + plan.tagOrDigest = digested.Digest().String() + } else { + named = reference.TagNameOnly(named) + tagged, ok := named.(reference.Tagged) + if !ok { + return fingerprintPlan{}, fmt.Errorf("image [%s] names neither a tag nor a digest", imageName) + } + plan.tagOrDigest = tagged.Tag() + } + + plan.domain = reference.Domain(named) + plan.repoPath = reference.Path(named) + plan.reference = named.String() + + return plan, nil +} + +// GetImageFingerprint resolves the fingerprint of a container image referenced +// by a Web App. The registry comes from the app's own configuration, which +// anyone with write access to that app controls, so the Azure credential is +// attached only for an Azure Container Registry login server. +func (azureClient *AzureClient) GetImageFingerprint(imageName string, logger *logger.Logger) (string, error) { + plan, err := planImageFingerprint(imageName) + if err != nil { + return "", err + } + var fingerprint string + if isACRLoginServer(plan.domain) { + fingerprint, err = azureClient.acrImageFingerprint(plan, azureClient.acrClientOptions, logger) + } else { + fingerprint, err = anonymousImageFingerprint(plan, logger) + } + if err != nil { + return "", err + } + + // A pinned reference is a claim about which image is deployed, and neither + // resolver checks the digest it is given against the one it gets back, so + // hold the registry to it here. + if plan.pinnedFingerprint != "" && fingerprint != plan.pinnedFingerprint { + return "", fmt.Errorf("image [%s] is pinned to digest sha256:%s but [%s] reported sha256:%s", plan.reference, plan.pinnedFingerprint, plan.domain, fingerprint) + } + + return fingerprint, nil +} + +// acrImageFingerprint reads a fingerprint from Azure Container Registry using +// the Azure credential supplied to Kosli. +// clientOptions is nil in production; tests pass options carrying a transport +// pointed at a fake registry, so the arm is exercised without package-level state. +func (azureClient *AzureClient) acrImageFingerprint(plan fingerprintPlan, clientOptions *azcontainerregistry.ClientOptions, logger *logger.Logger) (string, error) { credentials, err := azidentity.NewClientSecretCredential(azureClient.Credentials.TenantId, azureClient.Credentials.ClientId, azureClient.Credentials.ClientSecret, nil) if err != nil { return "", err } - AcrClient, err := azcontainerregistry.NewClient(registryUrl, credentials, nil) + acrClient, err := azcontainerregistry.NewClient("https://"+plan.domain, credentials, clientOptions) if err != nil { return "", err } - manifestRes, err := AcrClient.GetManifest(context.TODO(), repoName, tag, + manifestRes, err := acrClient.GetManifest(context.TODO(), plan.repoPath, plan.tagOrDigest, &azcontainerregistry.ClientGetManifestOptions{Accept: to.Ptr("application/vnd.docker.distribution.manifest.v2+json")}) if err != nil { return "", err } + if manifestRes.ManifestData != nil { + defer func() { + if err := manifestRes.ManifestData.Close(); err != nil { + logger.Warn("failed to close the manifest response for image %s: %v", plan.reference, err) + } + }() + } + if manifestRes.DockerContentDigest == nil { + return "", fmt.Errorf("no digest returned for image [%s]", plan.reference) + } - manifestPropsRes, err := AcrClient.GetManifestProperties(context.TODO(), repoName, *manifestRes.DockerContentDigest, nil) + fingerprint, err := digest.Sha256FingerprintFromDigest(*manifestRes.DockerContentDigest) if err != nil { - return "", err + return "", fmt.Errorf("registry reported a digest Kosli cannot use for image [%s]: %w", plan.reference, err) } - fingerprint = strings.TrimPrefix(*manifestPropsRes.Manifest.Digest, "sha256:") - - logger.Debug("For image '%s' got fingerprint '%s' from ACR", imageName, fingerprint) + logger.Debug("For image '%s' got fingerprint '%s' from ACR", plan.reference, fingerprint) return fingerprint, nil } -func parseImageName(imageName string) (registryUrl, repoName, tag string) { - // Parse the image name to extract the repository name and tag - // Example: tookyregistry.azurecr.io/tooky/sha256:latest - splitFullImageName := strings.SplitN(imageName, "/", 2) - if len(splitFullImageName) != 2 { - return "", "", "" - } - - registryUrl = fmt.Sprintf("https://%s", splitFullImageName[0]) - - if strings.Contains(splitFullImageName[1], "@sha256:") { - // Example: tookyregistry.azurecr.io/tooky@sha256:cb29a6..7 - imageNameAndTag := strings.SplitN(splitFullImageName[1], "@", 2) - repoName = imageNameAndTag[0] - tag = imageNameAndTag[1] - } else if strings.Contains(splitFullImageName[1], ":") { - imageNameAndTag := strings.SplitN(splitFullImageName[1], ":", 2) - repoName = imageNameAndTag[0] - tag = imageNameAndTag[1] - } else { - repoName = splitFullImageName[1] - tag = "latest" +// anonymousFingerprint resolves a fingerprint with no credential presented. It +// is a variable so tests can assert the reference the resolver is handed. +var anonymousFingerprint = digest.OciSha256Anonymous + +// anonymousImageFingerprint reads a fingerprint from a registry outside Azure +// Container Registry, presenting no credential. +func anonymousImageFingerprint(plan fingerprintPlan, logger *logger.Logger) (string, error) { + fingerprint, err := anonymousFingerprint(plan.reference) + if err != nil { + return "", fmt.Errorf("failed to get the fingerprint of image [%s] from [%s]: %w. Azure credentials are only sent to Azure Container Registry; use --digests-source logs for this app", plan.reference, plan.domain, err) } - return registryUrl, repoName, tag + logger.Debug("For image '%s' got fingerprint '%s' from '%s' with no credentials", plan.reference, fingerprint, plan.domain) + + return fingerprint, nil } func (app *AppData) IsEmpty() bool { diff --git a/internal/azure/image_fingerprint_test.go b/internal/azure/image_fingerprint_test.go new file mode 100644 index 000000000..0ba525733 --- /dev/null +++ b/internal/azure/image_fingerprint_test.go @@ -0,0 +1,516 @@ +package azure + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry" + armappservice "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" + + "github.com/kosli-dev/cli/internal/digest" + "github.com/kosli-dev/cli/internal/logger" + "github.com/stretchr/testify/require" +) + +func TestPlanImageFingerprint(t *testing.T) { + sha := strings.Repeat("a", 64) + + for _, tc := range []struct { + name string + imageName string + want fingerprintPlan + wantErrText string + }{ + { + name: "acr image with a tag authenticates to acr", + imageName: "myregistry.azurecr.io/myrepo/myapp:1.0", + want: fingerprintPlan{ + domain: "myregistry.azurecr.io", + reference: "myregistry.azurecr.io/myrepo/myapp:1.0", + repoPath: "myrepo/myapp", tagOrDigest: "1.0", + }, + }, + { + // Regression: the digest must keep its algorithm prefix, or ACR reads + // the bare hex as a tag and returns MANIFEST_UNKNOWN. + name: "acr image pinned to a digest keeps the sha256 prefix", + imageName: "myregistry.azurecr.io/myapp@sha256:" + sha, + want: fingerprintPlan{ + domain: "myregistry.azurecr.io", + reference: "myregistry.azurecr.io/myapp@sha256:" + sha, + repoPath: "myapp", tagOrDigest: "sha256:" + sha, + pinnedFingerprint: sha, + }, + }, + { + // containers/image refuses a reference holding both, so the tag is + // dropped and the digest wins. + name: "tag and digest together drops the tag", + imageName: "ghcr.io/owner/app:v1@sha256:" + sha, + want: fingerprintPlan{ + domain: "ghcr.io", + reference: "ghcr.io/owner/app@sha256:" + sha, + repoPath: "owner/app", tagOrDigest: "sha256:" + sha, + pinnedFingerprint: sha, + }, + }, + { + name: "image without a tag defaults to latest", + imageName: "myregistry.azurecr.io/myapp", + want: fingerprintPlan{ + domain: "myregistry.azurecr.io", + reference: "myregistry.azurecr.io/myapp:latest", + repoPath: "myapp", tagOrDigest: "latest", + }, + }, + { + name: "acr host with a port authenticates to acr", + imageName: "myregistry.azurecr.io:443/myapp:v1", + want: fingerprintPlan{ + domain: "myregistry.azurecr.io:443", + reference: "myregistry.azurecr.io:443/myapp:v1", + repoPath: "myapp", tagOrDigest: "v1", + }, + }, + { + name: "third party registry resolves anonymously", + imageName: "ghcr.io/owner/app:v2", + want: fingerprintPlan{ + domain: "ghcr.io", + reference: "ghcr.io/owner/app:v2", repoPath: "owner/app", tagOrDigest: "v2", + }, + }, + { + name: "attacker controlled host resolves anonymously", + imageName: "attacker.example/repo:latest", + want: fingerprintPlan{ + domain: "attacker.example", + reference: "attacker.example/repo:latest", repoPath: "repo", tagOrDigest: "latest", + }, + }, + { + name: "acr lookalike host resolves anonymously", + imageName: "azurecr.io.attacker.example/repo:latest", + want: fingerprintPlan{ + domain: "azurecr.io.attacker.example", + reference: "azurecr.io.attacker.example/repo:latest", repoPath: "repo", tagOrDigest: "latest", + }, + }, + { + name: "docker hub short form is normalised", + imageName: "nginx:latest", + want: fingerprintPlan{ + domain: "docker.io", + reference: "docker.io/library/nginx:latest", repoPath: "library/nginx", tagOrDigest: "latest", + }, + }, + { + name: "docker hub user image is normalised", + imageName: "myuser/myimage:tag", + want: fingerprintPlan{ + domain: "docker.io", + reference: "docker.io/myuser/myimage:tag", repoPath: "myuser/myimage", tagOrDigest: "tag", + }, + }, + // Regression: a registry component a suffix check reads as ACR but a URL + // parser resolves elsewhere must be rejected, or the Azure credential is + // handed to that other host. + { + name: "acr host smuggled into userinfo with a port is rejected", + imageName: "myregistry.azurecr.io:443@attacker.example/repo:tag", + wantErrText: "failed to parse the image name", + }, + { + name: "acr host smuggled into userinfo without a numeric port is rejected", + imageName: "myregistry.azurecr.io:x@attacker.example/repo:tag", + wantErrText: "failed to parse the image name", + }, + { + name: "acr host smuggled into userinfo with no port is rejected", + imageName: "myregistry.azurecr.io@attacker.example/repo:tag", + wantErrText: "failed to parse the image name", + }, + { + name: "trailing dot fqdn is rejected by the parser", + imageName: "myregistry.azurecr.io./myapp:v1", + wantErrText: "failed to parse the image name", + }, + { + name: "a non sha256 pin cannot produce a kosli fingerprint", + imageName: "ghcr.io/owner/app@sha512:" + strings.Repeat("c", 128), + wantErrText: "pinned to a digest Kosli cannot use", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := planImageFingerprint(tc.imageName) + if tc.wantErrText != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrText) + require.Equal(t, fingerprintPlan{}, got) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestIsACRLoginServer(t *testing.T) { + for _, tc := range []struct { + host string + want bool + }{ + {host: "myregistry.azurecr.io", want: true}, + {host: "MyRegistry.AzureCR.IO", want: true}, + {host: "myregistry.azurecr.cn", want: true}, + {host: "myregistry.azurecr.us", want: true}, + {host: "myregistry.azurecr.io:443", want: true}, + // A suffix on its own names no registry. + {host: "azurecr.io", want: false}, + {host: ".azurecr.io", want: false}, + // The suffix must end the host, not sit inside it. + {host: "azurecr.io.attacker.example", want: false}, + {host: "myregistry.azurecr.io.attacker.example", want: false}, + {host: "notazurecr.io", want: false}, + {host: "ghcr.io", want: false}, + {host: "registry-1.docker.io", want: false}, + {host: "mcr.microsoft.com", want: false}, + {host: "169.254.169.254", want: false}, + {host: "attacker.example:8443", want: false}, + // Domains reference.Domain can produce for a local registry. + {host: "localhost:5000", want: false}, + {host: "[::1]:5000", want: false}, + {host: "[2001:db8::1]", want: false}, + {host: "", want: false}, + } { + t.Run(tc.host, func(t *testing.T) { + require.Equal(t, tc.want, isACRLoginServer(tc.host)) + }) + } +} + +// stubAnonymousFingerprint replaces the anonymous resolver for one test and +// records the reference it was handed. +func stubAnonymousFingerprint(t *testing.T, fingerprint string, err error) *string { + t.Helper() + original := anonymousFingerprint + var got string + anonymousFingerprint = func(imageName string) (string, error) { + got = imageName + return fingerprint, err + } + t.Cleanup(func() { anonymousFingerprint = original }) + return &got +} + +// TestGetImageFingerprintRoutesNonACRHostsAnonymously covers the dispatch and +// asserts the exact reference handed to the resolver, which a wiring bug would +// break. +func TestGetImageFingerprintRoutesNonACRHostsAnonymously(t *testing.T) { + client := &AzureClient{Credentials: AzureStaticCredentials{ + TenantId: "00000000-0000-0000-0000-000000000000", DigestsSource: "acr", + }} + sha := strings.Repeat("a", 64) + + for _, tc := range []struct{ name, imageName, wantRef string }{ + {"third party registry", "ghcr.io/owner/app:v2", "ghcr.io/owner/app:v2"}, + {"attacker controlled host", "attacker.example/repo:latest", "attacker.example/repo:latest"}, + {"docker hub short form", "nginx:latest", "docker.io/library/nginx:latest"}, + {"docker hub user image", "myuser/myimage:tag", "docker.io/myuser/myimage:tag"}, + {"pinned on a non acr host", "ghcr.io/owner/app@sha256:" + sha, "ghcr.io/owner/app@sha256:" + sha}, + {"tag and digest drops the tag", "ghcr.io/owner/app:v1@sha256:" + sha, "ghcr.io/owner/app@sha256:" + sha}, + } { + t.Run(tc.name, func(t *testing.T) { + got := stubAnonymousFingerprint(t, sha, nil) + + fingerprint, err := client.GetImageFingerprint(tc.imageName, logger.NewStandardLogger()) + require.NoError(t, err) + require.Equal(t, sha, fingerprint) + require.Equal(t, tc.wantRef, *got, "the canonical reference must reach the resolver") + }) + } +} + +// TestGetImageFingerprintUsesACRForACRHost asserts the other arm. An empty +// tenant id makes the Azure credential fail to construct, so the ACR arm returns +// before any network request. +func TestGetImageFingerprintUsesACRForACRHost(t *testing.T) { + client := &AzureClient{Credentials: AzureStaticCredentials{TenantId: "", DigestsSource: "acr"}} + + got := stubAnonymousFingerprint(t, strings.Repeat("a", 64), nil) + + _, err := client.GetImageFingerprint("myregistry.azurecr.io/app:v1", logger.NewStandardLogger()) + require.Error(t, err) + require.Empty(t, *got, "an ACR host must not be resolved anonymously") + require.NotContains(t, err.Error(), "--digests-source logs", + "the error must come from the ACR arm, not the anonymous one") +} + +func TestGetImageFingerprintRejectsSmuggledACRHost(t *testing.T) { + client := &AzureClient{Credentials: AzureStaticCredentials{ + TenantId: "00000000-0000-0000-0000-000000000000", DigestsSource: "acr", + }} + + for _, imageName := range []string{ + "myregistry.azurecr.io:443@attacker.example/repo:tag", + "myregistry.azurecr.io:x@attacker.example/repo:tag", + "myregistry.azurecr.io@attacker.example/repo:tag", + } { + t.Run(imageName, func(t *testing.T) { + got := stubAnonymousFingerprint(t, strings.Repeat("a", 64), nil) + + _, err := client.GetImageFingerprint(imageName, logger.NewStandardLogger()) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse the image name") + require.Empty(t, *got, "must not resolve at all") + }) + } +} + +// TestGetImageFingerprintHoldsTheRegistryToAPinnedDigest stops a registry +// reporting a digest other than the one the reference pinned. Neither resolver +// checks this, so it is checked here. +func TestGetImageFingerprintHoldsTheRegistryToAPinnedDigest(t *testing.T) { + client := &AzureClient{Credentials: AzureStaticCredentials{DigestsSource: "acr"}} + pinned := strings.Repeat("a", 64) + other := strings.Repeat("b", 64) + + stubAnonymousFingerprint(t, other, nil) + _, err := client.GetImageFingerprint("ghcr.io/owner/app@sha256:"+pinned, logger.NewStandardLogger()) + require.Error(t, err) + require.Contains(t, err.Error(), "is pinned to digest sha256:"+pinned) + + // A near miss, so a comparison of only part of the digest cannot pass. + stubAnonymousFingerprint(t, strings.Repeat("a", 63)+"b", nil) + _, err = client.GetImageFingerprint("ghcr.io/owner/app@sha256:"+pinned, logger.NewStandardLogger()) + require.Error(t, err) + require.Contains(t, err.Error(), "is pinned to digest sha256:"+pinned) + + stubAnonymousFingerprint(t, pinned, nil) + fingerprint, err := client.GetImageFingerprint("ghcr.io/owner/app@sha256:"+pinned, logger.NewStandardLogger()) + require.NoError(t, err) + require.Equal(t, pinned, fingerprint) +} + +func TestAnonymousImageFingerprintWrapsTheUnderlyingError(t *testing.T) { + sentinel := errors.New("registry unreachable") + stubAnonymousFingerprint(t, "", sentinel) + + plan := fingerprintPlan{reference: "ghcr.io/owner/app:v2", domain: "ghcr.io"} + _, err := anonymousImageFingerprint(plan, logger.NewStandardLogger()) + require.Error(t, err) + require.ErrorIs(t, err, sentinel) + require.Contains(t, err.Error(), "--digests-source logs") +} + +// hostRewritingTransport sends every request to addr regardless of the host in +// the URL, so a reference naming a real ACR login server can be resolved against +// a fake registry. +// It records the host it was asked for first, so a test can still assert which +// registry the client was pointed at even though the request is redirected. +type hostRewritingTransport struct { + addr string + inner http.RoundTripper + hostsAsked *[]string +} + +func (t hostRewritingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + *t.hostsAsked = append(*t.hostsAsked, req.URL.Host) + rewritten := req.Clone(req.Context()) + rewritten.URL.Host = t.addr + return t.inner.RoundTrip(rewritten) +} + +// fakeACR stands up a registry that answers the manifest request with the given +// digest header and status, and records the paths it was asked for. +func fakeACR(t *testing.T, contentDigest string, status int) (*azcontainerregistry.ClientOptions, *[]string, *[]string) { + t.Helper() + var paths []string + var hostsAsked []string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // EscapedPath is what actually went on the wire; URL.Path is decoded. + paths = append(paths, r.URL.EscapedPath()) + if contentDigest != "" { + w.Header().Set("Docker-Content-Digest", contentDigest) + } + w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"schemaVersion":2}`)) + })) + t.Cleanup(srv.Close) + + parsed, err := url.Parse(srv.URL) + require.NoError(t, err) + options := &azcontainerregistry.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: &http.Client{Transport: hostRewritingTransport{ + addr: parsed.Host, inner: srv.Client().Transport, hostsAsked: &hostsAsked, + }}, + }, + } + return options, &paths, &hostsAsked +} + +func acrTestClient(t *testing.T, options *azcontainerregistry.ClientOptions) *AzureClient { + t.Helper() + return &AzureClient{ + Credentials: AzureStaticCredentials{ + TenantId: "00000000-0000-0000-0000-000000000000", + ClientId: "00000000-0000-0000-0000-000000000000", + ClientSecret: "not-a-real-secret", + }, + acrClientOptions: options, + } +} + +// TestGetImageFingerprintDrivesTheACRArmEndToEnd resolves an ACR reference all +// the way through GetImageFingerprint, which is what proves the parsed domain, +// repo path and tag actually reach the registry client. Without it, replacing +// plan.domain inside the arm with a hand-rolled split of the image name goes +// unnoticed. +func TestGetImageFingerprintDrivesTheACRArmEndToEnd(t *testing.T) { + want := strings.Repeat("a", 64) + options, paths, hostsAsked := fakeACR(t, "sha256:"+want, http.StatusOK) + client := acrTestClient(t, options) + + fingerprint, err := client.GetImageFingerprint("myregistry.azurecr.io/team/app:v1", logger.NewStandardLogger()) + + require.NoError(t, err) + require.Equal(t, want, fingerprint) + require.Contains(t, *paths, "/v2/team%2Fapp/manifests/v1", + "the parsed repo path and tag must reach the registry, in that order") + require.Contains(t, *hostsAsked, "myregistry.azurecr.io", + "the client must be pointed at the domain the parser reported") +} + +// TestGetImageFingerprintACRArmRequestsThePinnedDigest is the same for a pinned +// reference: the digest must reach the registry with its algorithm prefix. +func TestGetImageFingerprintACRArmRequestsThePinnedDigest(t *testing.T) { + pinned := strings.Repeat("a", 64) + options, paths, hostsAsked := fakeACR(t, "sha256:"+pinned, http.StatusOK) + client := acrTestClient(t, options) + + fingerprint, err := client.GetImageFingerprint("myregistry.azurecr.io/app@sha256:"+pinned, logger.NewStandardLogger()) + + require.NoError(t, err) + require.Equal(t, pinned, fingerprint) + require.Contains(t, *paths, "/v2/app/manifests/sha256:"+pinned, + "a bare hex digest would be read as a tag by the registry") + require.Contains(t, *hostsAsked, "myregistry.azurecr.io", + "the client must be pointed at the domain the parser reported") +} + +// TestGetImageFingerprintACRArmHoldsTheRegistryToAPinnedDigest exercises the +// cross-check on the credential-bearing arm, and with a digest differing in one +// character so a partial comparison cannot pass. +func TestGetImageFingerprintACRArmHoldsTheRegistryToAPinnedDigest(t *testing.T) { + pinned := strings.Repeat("a", 64) + nearMiss := strings.Repeat("a", 63) + "b" + options, _, _ := fakeACR(t, "sha256:"+nearMiss, http.StatusOK) + client := acrTestClient(t, options) + + _, err := client.GetImageFingerprint("myregistry.azurecr.io/app@sha256:"+pinned, logger.NewStandardLogger()) + + require.Error(t, err) + require.Contains(t, err.Error(), "is pinned to digest sha256:"+pinned) + require.Contains(t, err.Error(), "reported sha256:"+nearMiss) +} + +// TestGetImageFingerprintACRArmReportsRegistryErrors keeps the registry's own +// failure rather than degrading to the missing-header message. +func TestGetImageFingerprintACRArmReportsRegistryErrors(t *testing.T) { + options, _, _ := fakeACR(t, "", http.StatusNotFound) + client := acrTestClient(t, options) + + _, err := client.GetImageFingerprint("myregistry.azurecr.io/app:v1", logger.NewStandardLogger()) + + require.Error(t, err) + require.NotContains(t, err.Error(), "no digest returned", + "a 404 must surface as the registry error, not as a missing digest header") +} + +// TestGetImageFingerprintACRArmRejectsUnusableDigests covers the ACR arm's own +// error branches through the full path, which need a registry and so have no +// coverage otherwise. +func TestGetImageFingerprintACRArmRejectsUnusableDigests(t *testing.T) { + for _, tc := range []struct { + name string + contentDigest string + wantErrText string + }{ + {name: "sha512 digest", contentDigest: "sha512:" + strings.Repeat("c", 128), wantErrText: "algorithm is sha512"}, + {name: "sha384 digest", contentDigest: "sha384:" + strings.Repeat("b", 96), wantErrText: "algorithm is sha384"}, + {name: "unparseable digest", contentDigest: "not-a-digest", wantErrText: "unparseable digest"}, + {name: "missing digest header", contentDigest: "", wantErrText: "no digest returned"}, + } { + t.Run(tc.name, func(t *testing.T) { + options, _, _ := fakeACR(t, tc.contentDigest, http.StatusOK) + client := acrTestClient(t, options) + + fingerprint, err := client.GetImageFingerprint("myregistry.azurecr.io/app:v1", logger.NewStandardLogger()) + + require.Error(t, err) + require.Empty(t, fingerprint) + require.Contains(t, err.Error(), tc.wantErrText) + }) + } +} + +// TestAnonymousFingerprintIsTheCredentialFreeResolver pins what the variable +// points at in production. Every other test replaces it, so swapping it for the +// credential-discovering OciSha256 would otherwise go unnoticed. +func TestAnonymousFingerprintIsTheCredentialFreeResolver(t *testing.T) { + require.Equal(t, + reflect.ValueOf(digest.OciSha256Anonymous).Pointer(), + reflect.ValueOf(anonymousFingerprint).Pointer(), + "anonymousFingerprint must be digest.OciSha256Anonymous, not a resolver that discovers host credentials") +} + +// TestFingerprintDockerServiceUsesTheACRSource covers the only production caller +// of GetImageFingerprint. Without it, inverting the digests-source condition, or +// replacing the resolver call with a constant, goes unnoticed. +func TestFingerprintDockerServiceUsesTheACRSource(t *testing.T) { + want := strings.Repeat("a", 64) + options, paths, _ := fakeACR(t, "sha256:"+want, http.StatusOK) + client := acrTestClient(t, options) + client.Credentials.DigestsSource = "acr" + + appName, appKind := "payments-api", "app" + imageName := "myregistry.azurecr.io/team/app:v1" + + appData, err := client.fingerprintDockerService( + &armappservice.Site{Name: &appName, Kind: &appKind}, logger.NewStandardLogger(), imageName) + + require.NoError(t, err) + require.Equal(t, AppData{ + AppName: appName, + AppKind: appKind, + DigestsSource: "acr", + Digests: map[string]string{imageName: want}, + StartedAt: 0, + }, appData) + require.NotEmpty(t, *paths, "the acr source must actually contact the registry") +} + +// TestFingerprintDockerServicePropagatesResolverErrors keeps the resolver's error +// rather than reporting an app with no fingerprint. +func TestFingerprintDockerServicePropagatesResolverErrors(t *testing.T) { + options, _, _ := fakeACR(t, "sha512:"+strings.Repeat("c", 128), http.StatusOK) + client := acrTestClient(t, options) + client.Credentials.DigestsSource = "acr" + + appName, appKind := "payments-api", "app" + + _, err := client.fingerprintDockerService( + &armappservice.Site{Name: &appName, Kind: &appKind}, logger.NewStandardLogger(), + "myregistry.azurecr.io/team/app:v1") + + require.Error(t, err) + require.Contains(t, err.Error(), "algorithm is sha512") +} diff --git a/internal/digest/digest.go b/internal/digest/digest.go index 2eea9efe7..e0e2a1932 100644 --- a/internal/digest/digest.go +++ b/internal/digest/digest.go @@ -22,6 +22,7 @@ import ( "github.com/kosli-dev/cli/internal/requests" "github.com/kosli-dev/cli/internal/utils" "github.com/moby/moby/client" + godigest "github.com/opencontainers/go-digest" "github.com/yargevad/filepathx" ) @@ -82,22 +83,62 @@ func DirSha256(dirPath string, excludePaths []string, logger *logger.Logger) (st return FileSha256(digestsFile.Name(), logger) } -// OciSha256 gets the digest of a docker/OCI image from its registry -func OciSha256(artifactName string, registryUsername string, registryPassword string) (string, error) { - imageName := fmt.Sprintf("//%s", artifactName) - ctx := context.Background() - sysCtx := &types.SystemContext{} - // Only set explicit credentials when provided. When DockerAuthConfig is nil, - // the containers/image library falls back to credential discovery from auth - // files (~/.docker/config.json, ~/.config/containers/auth.json) and credential - // helpers (e.g. docker-credential-ecr-login), which is needed when Docker is +// credentialSource says which credentials a registry lookup may present. +// +// It is a typed choice rather than a caller-supplied SystemContext so that a +// lookup cannot be handed the wrong credential policy by mistake: containers/image +// falls back to credential discovery whenever DockerAuthConfig is nil, and that +// fallback must not reach a registry named by an untrusted source. +type credentialSource int + +const ( + // noCredentials presents nothing. It is the zero value deliberately, so a + // lookup that forgets to say what it wants does not present host credentials. + noCredentials credentialSource = iota + // callerOrHostCredentials presents the caller's credentials when it supplied + // any, and otherwise lets containers/image discover them from auth files + // (~/.docker/config.json, ~/.config/containers/auth.json) and credential + // helpers such as docker-credential-ecr-login, which is needed when Docker is // not installed or when using Podman with a private registry like ECR. - if registryUsername != "" || registryPassword != "" { - sysCtx.DockerAuthConfig = &types.DockerAuthConfig{ - Username: registryUsername, - Password: registryPassword, + callerOrHostCredentials +) + +// credentialContext builds the containers/image context for a credential source. +func credentialContext(source credentialSource, registryUsername, registryPassword string) *types.SystemContext { + sysCtx := &types.SystemContext{} + switch source { + case noCredentials: + // A non-nil but empty config is what stops the discovery fallback. A nil + // one silently enables it. + sysCtx.DockerAuthConfig = &types.DockerAuthConfig{} + case callerOrHostCredentials: + if registryUsername != "" || registryPassword != "" { + sysCtx.DockerAuthConfig = &types.DockerAuthConfig{ + Username: registryUsername, + Password: registryPassword, + } } } + return sysCtx +} + +// OciSha256 gets the digest of a docker/OCI image from its registry, presenting +// the given credentials, or those the host holds when none are given. +func OciSha256(artifactName string, registryUsername string, registryPassword string) (string, error) { + return ociSha256(artifactName, callerOrHostCredentials, registryUsername, registryPassword) +} + +// OciSha256Anonymous gets the digest of a docker/OCI image from its registry +// without presenting any credential, so no credential the host happens to hold +// is offered to a registry the caller does not control. +func OciSha256Anonymous(artifactName string) (string, error) { + return ociSha256(artifactName, noCredentials, "", "") +} + +func ociSha256(artifactName string, source credentialSource, registryUsername, registryPassword string) (string, error) { + imageName := fmt.Sprintf("//%s", artifactName) + ctx := context.Background() + sysCtx := credentialContext(source, registryUsername, registryPassword) // Parse image reference ref, err := docker.ParseReference(imageName) @@ -109,11 +150,47 @@ func OciSha256(artifactName string, registryUsername string, registryPassword st } // Compute digest - digest, err := docker.GetDigest(ctx, sysCtx, ref) + remoteDigest, err := docker.GetDigest(ctx, sysCtx, ref) if err != nil { return "", fmt.Errorf("failed to get digest for %s: %w", imageName, err) } - return strings.Split(digest.String(), "sha256:")[1], nil + + fingerprint, err := Sha256Fingerprint(remoteDigest) + if err != nil { + return "", fmt.Errorf("registry reported a digest Kosli cannot use for %s: %w", imageName, err) + } + return fingerprint, nil +} + +// Sha256FingerprintFromDigest turns a registry-supplied digest string into the +// hex fingerprint Kosli uses, rejecting any algorithm other than sha256, because +// a registry chooses the algorithm it answers with. +// +// This is the rule for the OCI and Azure Container Registry lookups. The older +// DockerImageSha256 and RemoteDockerImageSha256 paths still parse digests by +// hand and are not covered by it. +func Sha256FingerprintFromDigest(digestString string) (string, error) { + parsed, err := godigest.Parse(digestString) + if err != nil { + return "", fmt.Errorf("unparseable digest %q: %w", digestString, err) + } + return Sha256Fingerprint(parsed) +} + +// Sha256Fingerprint is the same rule for a digest that is already parsed, so a +// typed value does not have to be turned back into a string to be checked. +func Sha256Fingerprint(parsed godigest.Digest) (string, error) { + // godigest.Digest is a string type, so a caller can hand over an unvalidated + // one and Algorithm()/Encoded() would just split it on the colon. + if err := parsed.Validate(); err != nil { + return "", fmt.Errorf("invalid digest %q: %w", parsed.String(), err) + } + if parsed.Algorithm() != godigest.SHA256 { + return "", fmt.Errorf("digest algorithm is %s, but Kosli fingerprints are sha256", parsed.Algorithm()) + } + // Validate above has checked the charset and length, so the encoded portion + // is exactly 64 lowercase hex characters here. + return parsed.Encoded(), nil } // calculateDirContentSha256 calculates a sha256 digest for a directory content diff --git a/internal/digest/oci_anonymous_test.go b/internal/digest/oci_anonymous_test.go new file mode 100644 index 000000000..38f1e05f1 --- /dev/null +++ b/internal/digest/oci_anonymous_test.go @@ -0,0 +1,215 @@ +package digest + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/containers/image/v5/docker" + + "github.com/containers/image/v5/types" + godigest "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/require" +) + +// fakeRegistry answers the manifest HEAD with a chosen Docker-Content-Digest. +func fakeRegistry(t *testing.T, contentDigest string) string { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/" { + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("Docker-Content-Digest", contentDigest) + w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + parsed, err := url.Parse(srv.URL) + require.NoError(t, err) + return parsed.Host +} + +// insecureAnonymousLookup mirrors OciSha256Anonymous against a fake TLS +// registry. Production does not skip TLS verification, so the flag is set here +// rather than in credentialContext. +func insecureAnonymousLookup(artifactName string) (string, error) { + sysCtx := credentialContext(noCredentials, "", "") + sysCtx.DockerInsecureSkipTLSVerify = types.OptionalBoolTrue + + ref, err := docker.ParseReference("//" + artifactName) + if err != nil { + return "", err + } + remoteDigest, err := docker.GetDigest(context.Background(), sysCtx, ref) + if err != nil { + return "", fmt.Errorf("failed to get digest: %w", err) + } + return Sha256Fingerprint(remoteDigest) +} + +// TestOciSha256RejectsNonSha256RegistryDigest covers a registry answering with +// an algorithm other than sha256. go-digest accepts sha384 and sha512, so +// without an explicit check the digest string cannot be split as assumed. +func TestOciSha256RejectsNonSha256RegistryDigest(t *testing.T) { + for _, tc := range []struct { + algorithm string + contentDigest string + }{ + {algorithm: "sha384", contentDigest: "sha384:" + strings.Repeat("b", 96)}, + {algorithm: "sha512", contentDigest: "sha512:" + strings.Repeat("c", 128)}, + } { + t.Run(tc.algorithm, func(t *testing.T) { + host := fakeRegistry(t, tc.contentDigest) + + fingerprint, err := insecureAnonymousLookup(host + "/repo:tag") + + require.Error(t, err) + require.Empty(t, fingerprint) + require.Contains(t, err.Error(), "Kosli fingerprints are sha256") + require.Contains(t, err.Error(), tc.algorithm) + }) + } +} + +func TestOciSha256ReturnsTheSha256Fingerprint(t *testing.T) { + want := strings.Repeat("a", 64) + host := fakeRegistry(t, "sha256:"+want) + + fingerprint, err := insecureAnonymousLookup(host + "/repo:tag") + + require.NoError(t, err) + require.Equal(t, want, fingerprint) +} + +// TestCredentialContext pins the credential decision itself, which is the +// load-bearing property of the anonymous lookup: containers/image falls back to +// credential discovery from auth files and helpers whenever DockerAuthConfig is +// nil, so the anonymous source must produce a non-nil empty one. +func TestCredentialContext(t *testing.T) { + for _, tc := range []struct { + name string + source credentialSource + username string + password string + wantAuthConfig bool + wantUsername string + wantPassword string + }{ + { + name: "no credentials presents an empty config, not discovery", + source: noCredentials, + wantAuthConfig: true, + }, + { + name: "no credentials ignores any credentials passed alongside it", + source: noCredentials, + username: "user", + password: "pass", + wantAuthConfig: true, + }, + { + name: "caller credentials are presented", + source: callerOrHostCredentials, + username: "user", + password: "pass", + wantAuthConfig: true, + wantUsername: "user", + wantPassword: "pass", + }, + { + name: "caller credentials, password only", + source: callerOrHostCredentials, + password: "pass", + wantAuthConfig: true, + wantPassword: "pass", + }, + { + // A nil config is what enables discovery, and this path wants it. + name: "no caller credentials leaves discovery enabled", + source: callerOrHostCredentials, + wantAuthConfig: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + sysCtx := credentialContext(tc.source, tc.username, tc.password) + + if !tc.wantAuthConfig { + require.Nil(t, sysCtx.DockerAuthConfig, "a nil config enables credential discovery") + return + } + require.NotNil(t, sysCtx.DockerAuthConfig, "a nil config would enable credential discovery") + require.Equal(t, tc.wantUsername, sysCtx.DockerAuthConfig.Username) + require.Equal(t, tc.wantPassword, sysCtx.DockerAuthConfig.Password) + require.Empty(t, sysCtx.DockerAuthConfig.IdentityToken) + }) + } +} + +// TestNoCredentialsIsTheZeroValue means a lookup that fails to state its +// credential source presents nothing rather than the host's credentials. +func TestNoCredentialsIsTheZeroValue(t *testing.T) { + var unset credentialSource + require.Equal(t, noCredentials, unset) + require.NotNil(t, credentialContext(unset, "", "").DockerAuthConfig) +} + +func TestSha256FingerprintFromDigest(t *testing.T) { + validHex := strings.Repeat("a", 64) + + for _, tc := range []struct { + name string + digest string + want string + wantErrText string + }{ + {name: "sha256 digest yields its hex", digest: "sha256:" + validHex, want: validHex}, + {name: "sha384 is rejected", digest: "sha384:" + strings.Repeat("b", 96), wantErrText: "algorithm is sha384"}, + {name: "sha512 is rejected", digest: "sha512:" + strings.Repeat("c", 128), wantErrText: "algorithm is sha512"}, + {name: "empty is rejected", digest: "", wantErrText: "unparseable digest"}, + {name: "bare hex without an algorithm is rejected", digest: validHex, wantErrText: "unparseable digest"}, + {name: "non hex is rejected", digest: "sha256:" + strings.Repeat("z", 64), wantErrText: "unparseable digest"}, + {name: "uppercase hex is rejected", digest: "sha256:" + strings.Repeat("A", 64), wantErrText: "unparseable digest"}, + {name: "wrong length is rejected", digest: "sha256:" + strings.Repeat("a", 63), wantErrText: "unparseable digest"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := Sha256FingerprintFromDigest(tc.digest) + if tc.wantErrText != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrText) + require.Empty(t, got) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// TestSha256FingerprintValidatesWhatItIsHanded covers the exported typed entry +// point. godigest.Digest is a string type, so an unvalidated value must not be +// split into a fingerprint. +func TestSha256FingerprintValidatesWhatItIsHanded(t *testing.T) { + for _, tc := range []struct{ name, digest string }{ + {name: "non hex encoded portion", digest: "sha256:hello"}, + {name: "empty encoded portion", digest: "sha256:"}, + {name: "uppercase encoded portion", digest: "sha256:" + strings.Repeat("A", 64)}, + {name: "wrong length", digest: "sha256:abc"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := Sha256Fingerprint(godigest.Digest(tc.digest)) + require.Error(t, err) + require.Empty(t, got) + require.Contains(t, err.Error(), "invalid digest") + }) + } + + valid := strings.Repeat("a", 64) + got, err := Sha256Fingerprint(godigest.Digest("sha256:" + valid)) + require.NoError(t, err) + require.Equal(t, valid, got) +}