From 3355f7a671ad882d75e904cb69b16ed932105172 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 13 Aug 2026 10:06:26 +0100 Subject: [PATCH] Read host Docker credentials for private registry update checks Container update detection only ever negotiated anonymous pull tokens, so containers from registries that reject anonymous digest HEADs pinned a permanent "authentication required" badge (#1706). The agent already runs on the Docker host, so the checker now resolves the same credential store docker pull uses - config.json auths entries, credsStore/credHelpers credential helpers (docker-credential- get), and Podman's auth.json - and presents the stored login: Basic auth on Bearer token negotiation and on the hardcoded Docker Hub / ghcr.io token endpoints, direct answers to Basic challenges, and the refresh-token grant for identity-token logins such as Azure ACR. Credentials never leave the host: they are only presented to the registry or its token endpoint, helper output stays out of reported check errors, and lookups are cached in memory for five minutes. Helper names are validated before exec, and a stale login falls back to the anonymous path so checks that used to work keep working. Set PULSE_DISABLE_REGISTRY_CREDENTIALS=true (--disable-registry-credentials) to keep detection anonymous-only. The agent-lifecycle and security-privacy subsystem contracts pin the host-local credential boundary. --- cmd/pulse-agent/main.go | 143 ++--- cmd/pulse-agent/main_test.go | 36 ++ docs/DOCKER.md | 6 +- docs/UNIFIED_AGENT.md | 1 + .../v6/internal/subsystems/agent-lifecycle.md | 23 + .../internal/subsystems/security-privacy.md | 18 + frontend-modern/public/docs/DOCKER.md | 6 +- frontend-modern/public/docs/UNIFIED_AGENT.md | 1 + internal/dockeragent/agent.go | 39 +- internal/dockeragent/agent_internal_test.go | 13 + internal/dockeragent/registry.go | 236 +++++-- internal/dockeragent/registry_credentials.go | 332 ++++++++++ .../dockeragent/registry_credentials_test.go | 576 ++++++++++++++++++ 13 files changed, 1279 insertions(+), 151 deletions(-) create mode 100644 internal/dockeragent/registry_credentials.go create mode 100644 internal/dockeragent/registry_credentials_test.go diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index b215f9e3a..a403f6eb1 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -449,29 +449,30 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { var dockerAgent RunnableCloser if cfg.EnableDocker { dockerCfg := dockeragent.Config{ - PulseURL: cfg.PulseURL, - APIToken: cfg.APIToken, - Interval: cfg.Interval, - HostnameOverride: cfg.HostnameOverride, - AgentID: cfg.AgentID, - AgentType: "unified", - AgentVersion: Version, - InsecureSkipVerify: cfg.InsecureSkipVerify, - CACertPath: cfg.CACertPath, - ServerFingerprint: cfg.ServerFingerprint, - DisableAutoUpdate: cfg.DisableAutoUpdate, - DisableUpdateChecks: cfg.DisableDockerUpdateChecks, - Runtime: cfg.DockerRuntime, - LogLevel: cfg.LogLevel, - Logger: &logger, - SwarmScope: "node", - IncludeContainers: true, - IncludeServices: true, - IncludeTasks: true, - CollectDiskMetrics: false, - DiskExclude: cfg.DiskExclude, - DiskInclude: cfg.DiskInclude, - Targets: dockerReportTargets(cfg), + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + Interval: cfg.Interval, + HostnameOverride: cfg.HostnameOverride, + AgentID: cfg.AgentID, + AgentType: "unified", + AgentVersion: Version, + InsecureSkipVerify: cfg.InsecureSkipVerify, + CACertPath: cfg.CACertPath, + ServerFingerprint: cfg.ServerFingerprint, + DisableAutoUpdate: cfg.DisableAutoUpdate, + DisableUpdateChecks: cfg.DisableDockerUpdateChecks, + DisableRegistryCredentials: cfg.DisableRegistryCredentials, + Runtime: cfg.DockerRuntime, + LogLevel: cfg.LogLevel, + Logger: &logger, + SwarmScope: "node", + IncludeContainers: true, + IncludeServices: true, + IncludeTasks: true, + CollectDiskMetrics: false, + DiskExclude: cfg.DiskExclude, + DiskInclude: cfg.DiskInclude, + Targets: dockerReportTargets(cfg), } dockerAgent, err = newDockerAgent(dockerCfg) @@ -842,9 +843,10 @@ type Config struct { ProxmoxType string // "pve", "pbs", or "" for auto-detect // Auto-update - DisableAutoUpdate bool - DisableDockerUpdateChecks bool // Disable Docker image update detection - DockerRuntime string // Force Docker / Podman runtime: docker, podman, or auto + DisableAutoUpdate bool + DisableDockerUpdateChecks bool // Disable Docker image update detection + DisableRegistryCredentials bool // Do not read host Docker credentials for registry update checks + DockerRuntime string // Force Docker / Podman runtime: docker, podman, or auto // Security EnableCommands bool // Enable Pulse command execution for Patrol actions and Proxmox LXC Docker inventory (disabled by default) @@ -974,6 +976,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { envProxmoxType := strings.TrimSpace(getenv("PULSE_PROXMOX_TYPE")) envDisableAutoUpdate := strings.TrimSpace(getenv("PULSE_DISABLE_AUTO_UPDATE")) envDisableDockerUpdateChecks := strings.TrimSpace(getenv("PULSE_DISABLE_DOCKER_UPDATE_CHECKS")) + envDisableRegistryCredentials := strings.TrimSpace(getenv("PULSE_DISABLE_REGISTRY_CREDENTIALS")) envDockerRuntime := strings.TrimSpace(getenv("PULSE_DOCKER_RUNTIME")) envEnableCommands := strings.TrimSpace(getenv("PULSE_ENABLE_COMMANDS")) envDisableCommands := strings.TrimSpace(getenv("PULSE_DISABLE_COMMANDS")) // deprecated @@ -1062,6 +1065,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { proxmoxTypeFlag := fs.String("proxmox-type", envProxmoxType, "Proxmox type: pve or pbs (auto-detected if not specified)") disableAutoUpdateFlag := fs.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates") disableDockerUpdateChecksFlag := fs.Bool("disable-docker-update-checks", utils.ParseBool(envDisableDockerUpdateChecks), "Disable Docker image update detection (avoids Docker Hub rate limits)") + disableRegistryCredentialsFlag := fs.Bool("disable-registry-credentials", utils.ParseBool(envDisableRegistryCredentials), "Do not read host Docker credentials (config.json / credential helpers) for registry update checks") dockerRuntimeFlag := fs.String("docker-runtime", envDockerRuntime, "Docker / Podman runtime: auto, docker, or podman (default: auto)") enableCommandsFlag := fs.Bool("enable-commands", utils.ParseBool(envEnableCommands), "Enable Pulse command execution for Patrol actions and Proxmox LXC Docker inventory (disabled by default)") disableCommandsFlag := fs.Bool("disable-commands", false, "[DEPRECATED] Commands are now disabled by default; use --enable-commands to enable") @@ -1190,49 +1194,50 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { } return Config{ - PulseURL: pulseURL, - APIToken: token, - Interval: interval, - HostnameOverride: strings.TrimSpace(*hostnameFlag), - AgentID: strings.TrimSpace(*agentIDFlag), - AgentIDFile: agentIDFile, - Tags: tags, - InsecureSkipVerify: *insecureFlag, - AllowPlaintextHTTP: *allowPlaintextHTTPFlag, - CACertPath: strings.TrimSpace(*caCertFlag), - ServerFingerprint: strings.TrimSpace(*serverFingerprintFlag), - ObserversFile: strings.TrimSpace(*observersFileFlag), - Observers: observers, - CustomSensorsFile: strings.TrimSpace(*customSensorsFileFlag), - DeploySSHUser: deploySSHUser, - LogLevel: logLevel, - LogFile: strings.TrimSpace(*logFileFlag), - EnableHost: *enableHostFlag, - EnableDocker: *enableDockerFlag, - DockerConfigured: dockerConfigured, - DockerExplicitlyDisabled: dockerExplicitlyDisabled, - EnableKubernetes: *enableKubernetesFlag, - EnableProxmox: *enableProxmoxFlag, - ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag), - DisableAutoUpdate: *disableAutoUpdateFlag, - DisableDockerUpdateChecks: *disableDockerUpdateChecksFlag, - DockerRuntime: dockerRuntime, - EnableCommands: resolveEnableCommands(*enableCommandsFlag, *disableCommandsFlag, envEnableCommands, envDisableCommands), - Enroll: *enrollFlag, - HealthAddr: strings.TrimSpace(*healthAddrFlag), - KubeconfigPath: strings.TrimSpace(*kubeconfigFlag), - KubeContext: strings.TrimSpace(*kubeContextFlag), - KubeIncludeNamespaces: kubeIncludeNamespaces, - KubeExcludeNamespaces: kubeExcludeNamespaces, - KubeIncludeAllPods: *kubeIncludeAllPodsFlag, - KubeIncludeAllDeployments: *kubeIncludeAllDeploymentsFlag, - KubeMaxPods: kubeMaxPods, - StateDir: stateDir, - DiskExclude: diskExclude, - DiskInclude: diskInclude, - ReportIP: strings.TrimSpace(*reportIPFlag), - DisableCeph: *disableCephFlag, - SelfTest: *selfTest, + PulseURL: pulseURL, + APIToken: token, + Interval: interval, + HostnameOverride: strings.TrimSpace(*hostnameFlag), + AgentID: strings.TrimSpace(*agentIDFlag), + AgentIDFile: agentIDFile, + Tags: tags, + InsecureSkipVerify: *insecureFlag, + AllowPlaintextHTTP: *allowPlaintextHTTPFlag, + CACertPath: strings.TrimSpace(*caCertFlag), + ServerFingerprint: strings.TrimSpace(*serverFingerprintFlag), + ObserversFile: strings.TrimSpace(*observersFileFlag), + Observers: observers, + CustomSensorsFile: strings.TrimSpace(*customSensorsFileFlag), + DeploySSHUser: deploySSHUser, + LogLevel: logLevel, + LogFile: strings.TrimSpace(*logFileFlag), + EnableHost: *enableHostFlag, + EnableDocker: *enableDockerFlag, + DockerConfigured: dockerConfigured, + DockerExplicitlyDisabled: dockerExplicitlyDisabled, + EnableKubernetes: *enableKubernetesFlag, + EnableProxmox: *enableProxmoxFlag, + ProxmoxType: strings.TrimSpace(*proxmoxTypeFlag), + DisableAutoUpdate: *disableAutoUpdateFlag, + DisableDockerUpdateChecks: *disableDockerUpdateChecksFlag, + DisableRegistryCredentials: *disableRegistryCredentialsFlag, + DockerRuntime: dockerRuntime, + EnableCommands: resolveEnableCommands(*enableCommandsFlag, *disableCommandsFlag, envEnableCommands, envDisableCommands), + Enroll: *enrollFlag, + HealthAddr: strings.TrimSpace(*healthAddrFlag), + KubeconfigPath: strings.TrimSpace(*kubeconfigFlag), + KubeContext: strings.TrimSpace(*kubeContextFlag), + KubeIncludeNamespaces: kubeIncludeNamespaces, + KubeExcludeNamespaces: kubeExcludeNamespaces, + KubeIncludeAllPods: *kubeIncludeAllPodsFlag, + KubeIncludeAllDeployments: *kubeIncludeAllDeploymentsFlag, + KubeMaxPods: kubeMaxPods, + StateDir: stateDir, + DiskExclude: diskExclude, + DiskInclude: diskInclude, + ReportIP: strings.TrimSpace(*reportIPFlag), + DisableCeph: *disableCephFlag, + SelfTest: *selfTest, }, nil } diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 3bb89c006..663f6c375 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -2454,3 +2454,39 @@ func TestWireUpdaterHooksNudgesUpdaterOnNewerAckVersion(t *testing.T) { cancel() <-done } + +func TestLoadConfigRegistryCredentialOptOut(t *testing.T) { + t.Run("default keeps host credential reads enabled", func(t *testing.T) { + cfg, err := loadConfig([]string{"-token", "test-token"}, func(string) string { return "" }) + if err != nil { + t.Fatal(err) + } + if cfg.DisableRegistryCredentials { + t.Error("expected registry credential reads enabled by default") + } + }) + + t.Run("env opt-out", func(t *testing.T) { + env := map[string]string{ + "PULSE_TOKEN": "test-token", + "PULSE_DISABLE_REGISTRY_CREDENTIALS": "true", + } + cfg, err := loadConfig([]string{}, func(s string) string { return env[s] }) + if err != nil { + t.Fatal(err) + } + if !cfg.DisableRegistryCredentials { + t.Error("expected PULSE_DISABLE_REGISTRY_CREDENTIALS to disable credential reads") + } + }) + + t.Run("flag opt-out", func(t *testing.T) { + cfg, err := loadConfig([]string{"-token", "test-token", "-disable-registry-credentials"}, func(string) string { return "" }) + if err != nil { + t.Fatal(err) + } + if !cfg.DisableRegistryCredentials { + t.Error("expected --disable-registry-credentials to disable credential reads") + } + }) +} diff --git a/docs/DOCKER.md b/docs/DOCKER.md index ed021be09..ed97be315 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -176,13 +176,15 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd ### Private Registries -For private registries, ensure your Docker daemon has credentials configured: +For private registries, log in with Docker on the container host **as the user the agent runs as** (the installer's systemd service runs the agent as root, so use `sudo docker login`): ```bash docker login registry.example.com ``` -The agent uses the Docker daemon's credentials for both pulling images and checking for updates. +Pulling updates goes through the Docker daemon, which reads these credentials natively. Update **detection** reads the same credential store: `config.json` `auths` entries, configured `credsStore`/`credHelpers` credential helpers, and Podman's `auth.json` (`REGISTRY_AUTH_FILE` and `DOCKER_CONFIG` overrides are honored). The agent presents the stored login to the registry when it rejects anonymous digest checks, so private images get real update detection instead of a permanent failed check. Credentials never leave the host — they are only sent to the registry itself and are never reported to the Pulse server. + +To keep update detection anonymous-only (no credential store reads, no credential helper execution), set `PULSE_DISABLE_REGISTRY_CREDENTIALS=true` (or pass `--disable-registry-credentials`) on the agent. Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than the public `rcourtman/pulse` image. Open , diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 258c3f2dd..42ed53b55 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -315,6 +315,7 @@ sudo chmod 0700 /usr/local/libexec/pulse-queue-depth | `--kube-max-pods` | `PULSE_KUBE_MAX_PODS` | Max pods per report | `200` | | `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` | | `--disable-docker-update-checks` | `PULSE_DISABLE_DOCKER_UPDATE_CHECKS` | Disable Docker image update detection | `false` | +| `--disable-registry-credentials` | `PULSE_DISABLE_REGISTRY_CREDENTIALS` | Do not read host Docker credentials (config.json / credential helpers) for registry update checks | `false` | | `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` | | `--allow-plaintext-http` | `PULSE_AGENT_ALLOW_PLAINTEXT_HTTP` | Allow plain HTTP to a Pulse server that does not look local (private IP, single-label, `.local`/`.lan`/`.home`/`.home.arpa`/`.internal`, or resolves to private addresses). Sends the API token in cleartext; only for networks you fully control, e.g. internal networks numbered from public IP space | `false` | | `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* | diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 9dc7d6b49..a58617a58 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -2796,6 +2796,29 @@ feeds the enclosing report back into command execution. The API and monitoring contracts continue to own host command admission, command TTL expiry, and UI in-flight projection. +### Registry update checks may use host Docker credentials, host-locally only + +Automatic and manual Docker / Podman update detection may authenticate to a +registry with the host's own Docker credential store — `config.json` `auths` +entries, configured `credsStore`/`credHelpers` credential helpers, and +Podman's `auth.json`, honoring the `REGISTRY_AUTH_FILE` and `DOCKER_CONFIG` +overrides — so containers from private registries get real digest checks +instead of a permanent "authentication required" failure. Resolved +credentials are presented only to the registry itself or the token endpoint +it names: Basic auth on Bearer token negotiation (including the hardcoded +Docker Hub and ghcr.io endpoints), a direct Basic answer to a Basic +challenge, and the OAuth refresh-token grant for identity-token logins. +Credentials must never be reported to the Pulse server, logged, or embedded +in check errors, and credential helper output must stay out of returned +error surfaces. Helper names are validated against a strict pattern before +the agent executes `docker-credential- get`, helper execution is +time-bounded with capped output, and lookups are cached in memory for five +minutes. A stale or rejected login falls back to the anonymous path so +checks that used to work anonymously keep working, and +`--disable-registry-credentials` / `PULSE_DISABLE_REGISTRY_CREDENTIALS` +keeps detection anonymous-only without reading the store or executing +helpers. + ### Governed action readiness remains outside agent lifecycle authority The canonical Actions lifecycle may ask an executor-owned diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 2fe3ead73..b1e531fb3 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -525,6 +525,24 @@ the `white_label` branding entitlement. not expose redo-stack internals, provider reasoning, raw tool output, model-only handoff text, approval payload internals, environment data, or command-bearing fix details. +12. Change host registry-credential use for Docker / Podman update detection + through `cmd/pulse-agent/main.go`, + `internal/dockeragent/registry_credentials.go`, `docs/UNIFIED_AGENT.md`, + and `docs/DOCKER.md` together. Update detection may read the host's own + Docker / Podman credential store (`config.json` auths entries, + `credsStore`/`credHelpers` credential helpers, and Podman's `auth.json`) + to authenticate digest checks against private registries, and that read + is an explicit local operator boundary: resolved credentials are + presented only to the registry or the token endpoint it names, must + never be sent to the Pulse server, logged, or embedded in reported check + errors, and credential helper output must stay out of returned error + surfaces. Credential helper names must be validated before the agent + executes `docker-credential- get`, helper execution stays + time-bounded with capped output, and `--disable-registry-credentials` / + `PULSE_DISABLE_REGISTRY_CREDENTIALS` must keep detection anonymous-only + without reading the store or executing helpers. Remote profile + configuration has no key for this boundary and must not gain one that + can re-enable credential reads a local operator disabled. ## Forbidden Paths diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index ed021be09..ed97be315 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -176,13 +176,15 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd ### Private Registries -For private registries, ensure your Docker daemon has credentials configured: +For private registries, log in with Docker on the container host **as the user the agent runs as** (the installer's systemd service runs the agent as root, so use `sudo docker login`): ```bash docker login registry.example.com ``` -The agent uses the Docker daemon's credentials for both pulling images and checking for updates. +Pulling updates goes through the Docker daemon, which reads these credentials natively. Update **detection** reads the same credential store: `config.json` `auths` entries, configured `credsStore`/`credHelpers` credential helpers, and Podman's `auth.json` (`REGISTRY_AUTH_FILE` and `DOCKER_CONFIG` overrides are honored). The agent presents the stored login to the registry when it rejects anonymous digest checks, so private images get real update detection instead of a permanent failed check. Credentials never leave the host — they are only sent to the registry itself and are never reported to the Pulse server. + +To keep update detection anonymous-only (no credential store reads, no credential helper execution), set `PULSE_DISABLE_REGISTRY_CREDENTIALS=true` (or pass `--disable-registry-credentials`) on the agent. Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than the public `rcourtman/pulse` image. Open , diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index 258c3f2dd..42ed53b55 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -315,6 +315,7 @@ sudo chmod 0700 /usr/local/libexec/pulse-queue-depth | `--kube-max-pods` | `PULSE_KUBE_MAX_PODS` | Max pods per report | `200` | | `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` | | `--disable-docker-update-checks` | `PULSE_DISABLE_DOCKER_UPDATE_CHECKS` | Disable Docker image update detection | `false` | +| `--disable-registry-credentials` | `PULSE_DISABLE_REGISTRY_CREDENTIALS` | Do not read host Docker credentials (config.json / credential helpers) for registry update checks | `false` | | `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` | | `--allow-plaintext-http` | `PULSE_AGENT_ALLOW_PLAINTEXT_HTTP` | Allow plain HTTP to a Pulse server that does not look local (private IP, single-label, `.local`/`.lan`/`.home`/`.home.arpa`/`.internal`, or resolves to private addresses). Sends the API token in cleartext; only for networks you fully control, e.g. internal networks numbered from public IP space | `false` | | `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* | diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index 623ff8de9..56f2166b0 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -48,18 +48,22 @@ type Config struct { ServerFingerprint string DisableAutoUpdate bool DisableUpdateChecks bool // Disable Docker image update detection (registry checks) - Targets []TargetConfig - ContainerStates []string - SwarmScope string - Runtime string - IncludeServices bool - IncludeTasks bool - IncludeContainers bool - CollectDiskMetrics bool - DiskExclude []string // Mount points or path prefixes to exclude from disk monitoring - DiskInclude []string // Devices or mount points to opt into monitoring despite automatic filtering - LogLevel zerolog.Level - Logger *zerolog.Logger + // DisableRegistryCredentials keeps update checks from reading the host's + // Docker credential store (config.json auths and credential helpers), so + // private-registry checks fall back to anonymous-only behavior. + DisableRegistryCredentials bool + Targets []TargetConfig + ContainerStates []string + SwarmScope string + Runtime string + IncludeServices bool + IncludeTasks bool + IncludeContainers bool + CollectDiskMetrics bool + DiskExclude []string // Mount points or path prefixes to exclude from disk monitoring + DiskInclude []string // Devices or mount points to opt into monitoring despite automatic filtering + LogLevel zerolog.Level + Logger *zerolog.Logger } var allowedContainerStates = map[string]string{ @@ -364,6 +368,8 @@ func New(cfg Config) (*Agent, error) { registryChecker: newRegistryCheckerWithConfig(*logger, !cfg.DisableUpdateChecks), } + agent.registryChecker.credentials = registryCredentialSourceForConfig(cfg, *logger) + for _, state := range stateFilters { agent.allowedStates[state] = struct{}{} } @@ -373,6 +379,15 @@ func New(cfg Config) (*Agent, error) { return agent, nil } +// registryCredentialSourceForConfig returns the host credential source update +// checks should use, or nil when the operator disabled credential reads. +func registryCredentialSourceForConfig(cfg Config, logger zerolog.Logger) registryCredentialSource { + if cfg.DisableRegistryCredentials { + return nil + } + return newDockerConfigCredentials(logger) +} + func normalizeTargets(raw []TargetConfig) ([]TargetConfig, error) { if len(raw) == 0 { return nil, nil diff --git a/internal/dockeragent/agent_internal_test.go b/internal/dockeragent/agent_internal_test.go index 6c67f0ebf..2f49bd51d 100644 --- a/internal/dockeragent/agent_internal_test.go +++ b/internal/dockeragent/agent_internal_test.go @@ -1712,3 +1712,16 @@ func TestBuildReportForwardsExplicitDiskIncludesAndExcludes(t *testing.T) { t.Fatalf("disk includes = %q, want /mnt/containers", got) } } + +func TestRegistryCredentialSourceForConfig(t *testing.T) { + logger := zerolog.Nop() + + source := registryCredentialSourceForConfig(Config{}, logger) + if _, ok := source.(*dockerConfigCredentials); !ok { + t.Fatalf("expected host Docker credential source by default, got %T", source) + } + + if disabled := registryCredentialSourceForConfig(Config{DisableRegistryCredentials: true}, logger); disabled != nil { + t.Fatalf("expected nil credential source when disabled, got %T", disabled) + } +} diff --git a/internal/dockeragent/registry.go b/internal/dockeragent/registry.go index cf8a4619f..3867d8753 100644 --- a/internal/dockeragent/registry.go +++ b/internal/dockeragent/registry.go @@ -20,8 +20,11 @@ import ( type RegistryChecker struct { httpClient *http.Client cache *digestCache - logger zerolog.Logger - mu sync.RWMutex + // credentials optionally resolves host Docker credentials so private + // registries can be checked; nil keeps every lookup anonymous. + credentials registryCredentialSource + logger zerolog.Logger + mu sync.RWMutex // Configuration enabled bool @@ -293,34 +296,39 @@ func (r *RegistryChecker) digestsDiffer(current, latest string) bool { // fetchDigest retrieves the digest for an image from the registry. // Returns the resolved platform-specific digest AND the raw HEAD digest (which might be a manifest list). func (r *RegistryChecker) fetchDigest(ctx context.Context, registry, repository, tag, arch, goos, variant string) (string, string, error) { + creds := r.lookupCredentials(ctx, registry) + // Get auth token if needed - token, err := r.getAuthToken(ctx, registry, repository) + token, tokenUsedCreds, err := r.getScopedAuthToken(ctx, registry, repository, creds) if err != nil { return "", "", fmt.Errorf("auth: %w", err) } + authorization := "" + if token != "" { + authorization = "Bearer " + token + } // Construct the manifest URL manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag) - resp, err := r.headManifest(ctx, manifestURL, token) + resp, err := r.headManifest(ctx, manifestURL, authorization) if err != nil { return "", "", err } - if resp.StatusCode == http.StatusUnauthorized && token == "" { + if resp.StatusCode == http.StatusUnauthorized { // Registries without a hardcoded token endpoint (lscr.io, quay.io, ...) - // advertise it in the WWW-Authenticate Bearer challenge. Negotiate a - // pull token and retry once. + // advertise it in the WWW-Authenticate challenge. Negotiate a pull + // token (with host credentials when the store has them) or answer a + // Basic challenge directly, and retry once. challenge := resp.Header.Get("Www-Authenticate") - resp.Body.Close() - negotiated, negErr := r.tokenFromChallenge(ctx, challenge, repository) - if negErr != nil { - return "", "", fmt.Errorf("authentication required") - } - token = negotiated - resp, err = r.headManifest(ctx, manifestURL, token) - if err != nil { - return "", "", err + if retryAuthorization, ok := r.retryAuthorization(ctx, challenge, repository, token, tokenUsedCreds, creds); ok { + resp.Body.Close() + authorization = retryAuthorization + resp, err = r.headManifest(ctx, manifestURL, authorization) + if err != nil { + return "", "", err + } } } defer resp.Body.Close() @@ -356,7 +364,7 @@ func (r *RegistryChecker) fetchDigest(ctx context.Context, registry, repository, if closeErr := resp.Body.Close(); closeErr != nil { r.logger.Debug().Err(closeErr).Msg("Failed to close registry HEAD response") } - manifestDigest, manifestContentType, manifestBody, err := r.fetchManifest(ctx, manifestURL, token) + manifestDigest, manifestContentType, manifestBody, err := r.fetchManifest(ctx, manifestURL, authorization) if err != nil { return "", "", err } @@ -383,7 +391,7 @@ func (r *RegistryChecker) fetchDigest(ctx context.Context, registry, repository, // identify an index on HEAD but omit its digest need the GET fallback above // to preserve both values. if isManifestList && arch != "" && goos != "" { - resolved, err := r.resolveManifestList(ctx, registry, repository, tag, arch, goos, variant, token) + resolved, err := r.resolveManifestList(ctx, registry, repository, tag, arch, goos, variant, authorization) return resolved, digest, err } @@ -393,12 +401,12 @@ func (r *RegistryChecker) fetchDigest(ctx context.Context, registry, repository, // fetchManifest retrieves a manifest body when a registry accepts HEAD but // omits the digest headers. The digest of the exact response body is a valid // fallback under the registry content-addressing contract. -func (r *RegistryChecker) fetchManifest(ctx context.Context, manifestURL, token string) (string, string, []byte, error) { +func (r *RegistryChecker) fetchManifest(ctx context.Context, manifestURL, authorization string) (string, string, []byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) if err != nil { return "", "", nil, fmt.Errorf("create manifest request: %w", err) } - r.setManifestRequestHeaders(req, token) + r.setManifestRequestHeaders(req, authorization) resp, err := r.httpClient.Do(req) if err != nil { @@ -431,13 +439,13 @@ func (r *RegistryChecker) fetchManifest(ctx context.Context, manifestURL, token } // headManifest issues a manifest HEAD request with the multi-arch Accept set. -func (r *RegistryChecker) headManifest(ctx context.Context, manifestURL, token string) (*http.Response, error) { +func (r *RegistryChecker) headManifest(ctx context.Context, manifestURL, authorization string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } - r.setManifestRequestHeaders(req, token) + r.setManifestRequestHeaders(req, authorization) resp, err := r.httpClient.Do(req) if err != nil { @@ -446,7 +454,9 @@ func (r *RegistryChecker) headManifest(ctx context.Context, manifestURL, token s return resp, nil } -func (r *RegistryChecker) setManifestRequestHeaders(req *http.Request, token string) { +// setManifestRequestHeaders applies the multi-arch Accept set and an optional +// full Authorization header value ("Bearer ..." or "Basic ..."). +func (r *RegistryChecker) setManifestRequestHeaders(req *http.Request, authorization string) { req.Header.Set("Accept", strings.Join([]string{ "application/vnd.docker.distribution.manifest.list.v2+json", "application/vnd.docker.distribution.manifest.v2+json", @@ -454,35 +464,55 @@ func (r *RegistryChecker) setManifestRequestHeaders(req *http.Request, token str "application/vnd.oci.image.index.v1+json", }, ", ")) - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) + if authorization != "" { + req.Header.Set("Authorization", authorization) } } -// tokenFromChallenge negotiates an anonymous pull token from the token -// endpoint named in a registry's WWW-Authenticate Bearer challenge -// (Docker registry v2 token auth). -func (r *RegistryChecker) tokenFromChallenge(ctx context.Context, challenge, repository string) (string, error) { +// retryAuthorization derives the Authorization value for the single retry +// after an unauthorized manifest HEAD. Bearer challenges are negotiated at +// the advertised token endpoint (anonymously, as before, or with host +// credentials when the store has them); Basic challenges are answered +// directly from host credentials. It returns false when no retry could do +// better than the attempt that already failed. +func (r *RegistryChecker) retryAuthorization(ctx context.Context, challenge, repository, token string, tokenUsedCreds bool, creds *registryCredential) (string, bool) { + scheme := strings.ToLower(strings.TrimSpace(challenge)) + switch { + case strings.HasPrefix(scheme, "basic"): + if creds == nil || creds.IdentityToken { + return "", false + } + return creds.authorizationHeader(), true + case strings.HasPrefix(scheme, "bearer"): + if token != "" && (creds == nil || tokenUsedCreds) { + // The failed attempt already carried our best token; negotiating + // the same one again cannot succeed. + return "", false + } + negotiated, err := r.tokenFromChallenge(ctx, challenge, repository, creds) + if err != nil { + return "", false + } + return "Bearer " + negotiated, true + default: + return "", false + } +} + +// tokenFromChallenge negotiates a pull token from the token endpoint named in +// a registry's WWW-Authenticate Bearer challenge (Docker registry v2 token +// auth), anonymously or with host credentials. +func (r *RegistryChecker) tokenFromChallenge(ctx context.Context, challenge, repository string, creds *registryCredential) (string, error) { params := parseBearerChallenge(challenge) realm := params["realm"] if realm == "" { return "", fmt.Errorf("no bearer challenge") } - realmURL, err := url.Parse(realm) - if err != nil || realmURL.Scheme != "https" { - return "", fmt.Errorf("invalid token realm") - } - query := realmURL.Query() - if service := params["service"]; service != "" { - query.Set("service", service) - } scope := params["scope"] if scope == "" { scope = fmt.Sprintf("repository:%s:pull", repository) } - query.Set("scope", scope) - realmURL.RawQuery = query.Encode() - return r.fetchAuthToken(ctx, realmURL.String()) + return r.authorizeToken(ctx, realm, params["service"], scope, creds) } // parseBearerChallenge extracts the key="value" parameters from a @@ -504,7 +534,7 @@ func parseBearerChallenge(header string) map[string]string { } // resolveManifestList fetches the manifest list and finds the matching digest for the architecture. -func (r *RegistryChecker) resolveManifestList(ctx context.Context, registry, repository, tag, arch, goos, variant, token string) (string, error) { +func (r *RegistryChecker) resolveManifestList(ctx context.Context, registry, repository, tag, arch, goos, variant, authorization string) (string, error) { manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag) req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) @@ -512,16 +542,7 @@ func (r *RegistryChecker) resolveManifestList(ctx context.Context, registry, rep return "", fmt.Errorf("create list request: %w", err) } - req.Header.Set("Accept", strings.Join([]string{ - "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.docker.distribution.manifest.v2+json", - "application/vnd.oci.image.manifest.v1+json", - "application/vnd.oci.image.index.v1+json", - }, ", ")) - - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } + r.setManifestRequestHeaders(req, authorization) resp, err := r.httpClient.Do(req) if err != nil { @@ -576,29 +597,112 @@ type manifestPlatform struct { // getAuthToken retrieves an auth token for the registry. func (r *RegistryChecker) getAuthToken(ctx context.Context, registry, repository string) (string, error) { - // Docker Hub requires auth token even for public images - if registry == "registry-1.docker.io" { - tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repository) - return r.fetchAuthToken(ctx, tokenURL) - } - - // GitHub Container Registry (ghcr.io) requires auth token for public images - if registry == "ghcr.io" { - tokenURL := fmt.Sprintf("https://ghcr.io/token?service=ghcr.io&scope=repository:%s:pull", repository) - return r.fetchAuthToken(ctx, tokenURL) - } - - // For other registries, try anonymous access first - return "", nil + token, _, err := r.getScopedAuthToken(ctx, registry, repository, r.lookupCredentials(ctx, registry)) + return token, err } -// fetchAuthToken fetches an auth token from a token endpoint. +// getScopedAuthToken negotiates a pull token for registries with hardcoded +// token endpoints. It also reports whether the returned token actually +// carried the supplied credentials, so the 401 retry can tell a fresh +// credentialed attempt apart from replaying one that already failed. +func (r *RegistryChecker) getScopedAuthToken(ctx context.Context, registry, repository string, creds *registryCredential) (string, bool, error) { + var realm, service string + switch registry { + // Docker Hub and ghcr.io require a token even for public images. + case "registry-1.docker.io": + realm, service = "https://auth.docker.io/token", "registry.docker.io" + case "ghcr.io": + realm, service = "https://ghcr.io/token", "ghcr.io" + default: + // For other registries, try anonymous access first. + return "", false, nil + } + + if creds != nil { + token, err := r.authorizeToken(ctx, realm, service, fmt.Sprintf("repository:%s:pull", repository), creds) + if err == nil { + return token, true, nil + } + // A stale login must not break checks that used to work anonymously. + r.logger.Debug().Str("registry", registry).Err(err).Msg("Credentialed token negotiation failed; retrying anonymously") + } + + tokenURL := fmt.Sprintf("%s?service=%s&scope=repository:%s:pull", realm, service, repository) + token, err := r.fetchAuthToken(ctx, tokenURL) + return token, false, err +} + +// authorizeToken requests a pull token from a token endpoint, attaching Basic +// credentials or exchanging an identity token when host credentials exist. +func (r *RegistryChecker) authorizeToken(ctx context.Context, realm, service, scope string, creds *registryCredential) (string, error) { + realmURL, err := url.Parse(realm) + if err != nil || realmURL.Scheme != "https" { + return "", fmt.Errorf("invalid token realm") + } + + if creds != nil && creds.IdentityToken { + return r.exchangeIdentityToken(ctx, realmURL.String(), service, scope, creds) + } + + query := realmURL.Query() + if service != "" { + query.Set("service", service) + } + query.Set("scope", scope) + realmURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, realmURL.String(), nil) + if err != nil { + return "", fmt.Errorf("create token request: %w", err) + } + if creds != nil { + req.SetBasicAuth(creds.Username, creds.Secret) + } + return r.doTokenRequest(req) +} + +// exchangeIdentityToken swaps an OAuth identity token (docker-credential +// helper logins that return Username "", for example Azure ACR) for a +// pull token via the refresh-token grant of the registry token endpoint. +func (r *RegistryChecker) exchangeIdentityToken(ctx context.Context, realm, service, scope string, creds *registryCredential) (string, error) { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", creds.Secret) + form.Set("client_id", "pulse-agent") + if service != "" { + form.Set("service", service) + } + form.Set("scope", scope) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, realm, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("create token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return r.doTokenRequest(req) +} + +// lookupCredentials resolves host Docker credentials for a registry. It +// returns nil when no source is wired, the registry has no stored login, or +// resolution fails; the check then proceeds anonymously as before. +func (r *RegistryChecker) lookupCredentials(ctx context.Context, registry string) *registryCredential { + if r.credentials == nil { + return nil + } + return r.credentials.Lookup(ctx, registry) +} + +// fetchAuthToken fetches an auth token anonymously from a token endpoint. func (r *RegistryChecker) fetchAuthToken(ctx context.Context, tokenURL string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) if err != nil { return "", fmt.Errorf("create token request: %w", err) } + return r.doTokenRequest(req) +} +// doTokenRequest executes a token-endpoint request and decodes the token. +func (r *RegistryChecker) doTokenRequest(req *http.Request) (string, error) { resp, err := r.httpClient.Do(req) if err != nil { return "", fmt.Errorf("send token request: %w", err) diff --git a/internal/dockeragent/registry_credentials.go b/internal/dockeragent/registry_credentials.go new file mode 100644 index 000000000..83d0ca989 --- /dev/null +++ b/internal/dockeragent/registry_credentials.go @@ -0,0 +1,332 @@ +package dockeragent + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +// registryCredential is one registry login resolved from the host's Docker or +// Podman credential store. IdentityToken marks OAuth identity-token logins +// (for example Azure ACR), whose secret must be exchanged through a +// refresh-token grant instead of Basic auth. +type registryCredential struct { + Username string + Secret string + IdentityToken bool +} + +// authorizationHeader renders the credential as a Basic Authorization value. +func (c *registryCredential) authorizationHeader() string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(c.Username+":"+c.Secret)) +} + +// registryCredentialSource resolves pull credentials for a registry host. +// Implementations must be safe for concurrent use. Resolved credentials are +// only ever presented to the registry (or its token endpoint) itself; they +// must never be reported to the Pulse server or appear in check errors. +type registryCredentialSource interface { + Lookup(ctx context.Context, registry string) *registryCredential +} + +const ( + // credentialCacheTTL bounds how long a resolved (or missing) credential is + // reused before the config files and helpers are consulted again. Short + // enough to pick up rotated logins, long enough that one full container + // sweep does not exec a credential helper per image. + credentialCacheTTL = 5 * time.Minute + // credentialHelperTimeout caps a docker-credential- execution. + credentialHelperTimeout = 10 * time.Second + // maxCredentialHelperOutputBytes caps helper stdout accepted by the agent. + maxCredentialHelperOutputBytes = 1 * 1024 * 1024 + // dockerHubConfigKey is the legacy index URL Docker stores Hub logins under. + dockerHubConfigKey = "https://index.docker.io/v1/" +) + +// credentialHelperNamePattern restricts which helper suffixes may be executed. +// The helper name comes from config.json; anything outside this set (path +// separators, shell metacharacters) is rejected rather than exec'd. +var credentialHelperNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// errCredentialsNotFound reports a helper miss ("credentials not found"), +// which is an anonymous fallthrough rather than an error surface. +var errCredentialsNotFound = errors.New("credentials not found") + +// dockerConfigCredentials reads the host's Docker/Podman credential store: +// config.json "auths" entries plus credsStore/credHelpers helper binaries, +// exactly the sources `docker pull` consults. Lookups are cached briefly so a +// container sweep does not re-read files or re-exec helpers per image. +type dockerConfigCredentials struct { + logger zerolog.Logger + + mu sync.Mutex + cache map[string]credentialCacheEntry + + // Seams for tests. + getenv func(string) string + homeDir func() (string, error) + readFile func(string) ([]byte, error) + runHelper func(ctx context.Context, helper, serverURL string) ([]byte, error) + now func() time.Time +} + +type credentialCacheEntry struct { + cred *registryCredential + expiresAt time.Time +} + +// newDockerConfigCredentials creates the default host credential source. +func newDockerConfigCredentials(logger zerolog.Logger) *dockerConfigCredentials { + c := &dockerConfigCredentials{ + logger: logger, + cache: map[string]credentialCacheEntry{}, + getenv: os.Getenv, + homeDir: os.UserHomeDir, + readFile: os.ReadFile, + now: time.Now, + } + c.runHelper = c.execCredentialHelper + return c +} + +// Lookup resolves credentials for a registry host, consulting the cache +// first. A nil return means no usable credential; the caller proceeds +// anonymously as before. +func (c *dockerConfigCredentials) Lookup(ctx context.Context, registry string) *registryCredential { + host := normalizeRegistryHost(registry) + if host == "" { + return nil + } + + c.mu.Lock() + if entry, ok := c.cache[host]; ok && c.now().Before(entry.expiresAt) { + c.mu.Unlock() + return entry.cred + } + c.mu.Unlock() + + cred := c.resolve(ctx, host) + + c.mu.Lock() + c.cache[host] = credentialCacheEntry{cred: cred, expiresAt: c.now().Add(credentialCacheTTL)} + c.mu.Unlock() + + return cred +} + +// resolve walks the candidate config files in precedence order and returns +// the first credential any of them yields for the host. +func (c *dockerConfigCredentials) resolve(ctx context.Context, host string) *registryCredential { + for _, path := range c.configFilePaths() { + data, err := c.readFile(path) + if err != nil { + continue + } + cred, err := c.credentialFromConfig(ctx, data, host) + if err != nil { + c.logger.Debug().Str("path", path).Err(err).Msg("Failed to resolve registry credentials from Docker config") + continue + } + if cred != nil { + c.logger.Debug().Str("registry", host).Str("path", path).Msg("Using host registry credentials for update check") + return cred + } + } + return nil +} + +// configFilePaths lists candidate credential files in precedence order: +// the Podman override, the Docker config (env override then home), and the +// Podman runtime auth file. +func (c *dockerConfigCredentials) configFilePaths() []string { + var paths []string + if authFile := strings.TrimSpace(c.getenv("REGISTRY_AUTH_FILE")); authFile != "" { + paths = append(paths, authFile) + } + if dir := strings.TrimSpace(c.getenv("DOCKER_CONFIG")); dir != "" { + paths = append(paths, filepath.Join(dir, "config.json")) + } + if home, err := c.homeDir(); err == nil && home != "" { + paths = append(paths, filepath.Join(home, ".docker", "config.json")) + } + if runtimeDir := strings.TrimSpace(c.getenv("XDG_RUNTIME_DIR")); runtimeDir != "" { + paths = append(paths, filepath.Join(runtimeDir, "containers", "auth.json")) + } + return paths +} + +type dockerConfigFile struct { + Auths map[string]dockerConfigAuth `json:"auths"` + CredsStore string `json:"credsStore"` + CredHelpers map[string]string `json:"credHelpers"` +} + +type dockerConfigAuth struct { + Auth string `json:"auth"` + Username string `json:"username"` + Password string `json:"password"` + IdentityToken string `json:"identitytoken"` +} + +// credentialFromConfig resolves the host's credential from one parsed config +// file, honoring Docker's precedence: per-registry credHelpers, then the +// global credsStore, then the static auths entry. +func (c *dockerConfigCredentials) credentialFromConfig(ctx context.Context, data []byte, host string) (*registryCredential, error) { + var cfg dockerConfigFile + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + + if helper := configHelperForHost(cfg.CredHelpers, host); helper != "" { + return c.credentialFromHelper(ctx, helper, host) + } + if cfg.CredsStore != "" { + return c.credentialFromHelper(ctx, cfg.CredsStore, host) + } + + entry, ok := configAuthForHost(cfg.Auths, host) + if !ok { + return nil, nil + } + username, secret := entry.Username, entry.Password + if entry.Auth != "" { + decoded, err := decodeBase64Auth(entry.Auth) + if err != nil { + return nil, fmt.Errorf("decode auth entry: %w", err) + } + user, pass, found := strings.Cut(decoded, ":") + if !found { + return nil, fmt.Errorf("malformed auth entry") + } + username, secret = user, pass + } + if entry.IdentityToken != "" { + return ®istryCredential{Username: username, Secret: entry.IdentityToken, IdentityToken: true}, nil + } + if username == "" || secret == "" { + return nil, nil + } + return ®istryCredential{Username: username, Secret: secret}, nil +} + +// decodeBase64Auth accepts both padded and unpadded base64 auth entries. +func decodeBase64Auth(auth string) (string, error) { + if decoded, err := base64.StdEncoding.DecodeString(auth); err == nil { + return string(decoded), nil + } + decoded, err := base64.RawStdEncoding.DecodeString(auth) + if err != nil { + return "", err + } + return string(decoded), nil +} + +// credentialFromHelper resolves credentials through a docker-credential +// helper binary, the same protocol the Docker CLI uses. +func (c *dockerConfigCredentials) credentialFromHelper(ctx context.Context, helper, host string) (*registryCredential, error) { + if !credentialHelperNamePattern.MatchString(helper) { + return nil, fmt.Errorf("invalid credential helper name %q", helper) + } + + serverURL := host + if host == "index.docker.io" { + serverURL = dockerHubConfigKey + } + + output, err := c.runHelper(ctx, helper, serverURL) + if err != nil { + if errors.Is(err, errCredentialsNotFound) { + return nil, nil + } + return nil, err + } + + var resp struct { + Username string `json:"Username"` + Secret string `json:"Secret"` + } + if err := json.Unmarshal(output, &resp); err != nil { + return nil, fmt.Errorf("decode helper response: %w", err) + } + if resp.Secret == "" { + return nil, nil + } + if resp.Username == "" { + return ®istryCredential{Username: resp.Username, Secret: resp.Secret, IdentityToken: true}, nil + } + return ®istryCredential{Username: resp.Username, Secret: resp.Secret}, nil +} + +// execCredentialHelper runs docker-credential- get with the server +// URL on stdin. Helper stderr is deliberately kept out of returned errors so +// no helper output can ever reach a reported check error. +func (c *dockerConfigCredentials) execCredentialHelper(ctx context.Context, helper, serverURL string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, credentialHelperTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker-credential-"+helper, "get") + cmd.Stdin = strings.NewReader(serverURL) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + combined := strings.ToLower(stdout.String() + " " + stderr.String()) + if strings.Contains(combined, "credentials not found") { + return nil, errCredentialsNotFound + } + return nil, fmt.Errorf("credential helper %q: %v", helper, err) + } + if stdout.Len() > maxCredentialHelperOutputBytes { + return nil, fmt.Errorf("credential helper %q: output too large", helper) + } + return stdout.Bytes(), nil +} + +// normalizeRegistryHost canonicalizes a registry reference for credential +// lookups: scheme and path are stripped and the Docker Hub aliases collapse +// onto a single key so config entries stored under the legacy index URL match +// checks against registry-1.docker.io. +func normalizeRegistryHost(registry string) string { + host := strings.ToLower(strings.TrimSpace(registry)) + host = strings.TrimPrefix(host, "https://") + host = strings.TrimPrefix(host, "http://") + if i := strings.Index(host, "/"); i >= 0 { + host = host[:i] + } + switch host { + case "registry-1.docker.io", "index.docker.io", "docker.io", "registry.docker.io": + return "index.docker.io" + } + return host +} + +func configAuthForHost(auths map[string]dockerConfigAuth, host string) (dockerConfigAuth, bool) { + for key, entry := range auths { + if normalizeRegistryHost(key) == host { + return entry, true + } + } + return dockerConfigAuth{}, false +} + +func configHelperForHost(helpers map[string]string, host string) string { + for key, helper := range helpers { + if normalizeRegistryHost(key) == host { + return helper + } + } + return "" +} diff --git a/internal/dockeragent/registry_credentials_test.go b/internal/dockeragent/registry_credentials_test.go new file mode 100644 index 000000000..a24ab5cba --- /dev/null +++ b/internal/dockeragent/registry_credentials_test.go @@ -0,0 +1,576 @@ +package dockeragent + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" +) + +// credentialSourceFunc adapts a function to registryCredentialSource. +type credentialSourceFunc func(ctx context.Context, registry string) *registryCredential + +func (f credentialSourceFunc) Lookup(ctx context.Context, registry string) *registryCredential { + return f(ctx, registry) +} + +func staticCredentials(registry, username, secret string) credentialSourceFunc { + return func(_ context.Context, got string) *registryCredential { + if got != registry { + return nil + } + return ®istryCredential{Username: username, Secret: secret} + } +} + +func basicAuthValue(username, secret string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+secret)) +} + +func newTestCredentialStore(files map[string]string, env map[string]string) *dockerConfigCredentials { + c := &dockerConfigCredentials{ + logger: zerolog.Nop(), + cache: map[string]credentialCacheEntry{}, + getenv: func(key string) string { return env[key] }, + homeDir: func() (string, error) { + if home, ok := env["HOME"]; ok { + return home, nil + } + return "", errors.New("no home") + }, + readFile: func(path string) ([]byte, error) { + if content, ok := files[path]; ok { + return []byte(content), nil + } + return nil, os.ErrNotExist + }, + now: time.Now, + } + c.runHelper = func(_ context.Context, _, _ string) ([]byte, error) { + return nil, errCredentialsNotFound + } + return c +} + +func TestNormalizeRegistryHost(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"registry-1.docker.io", "index.docker.io"}, + {"index.docker.io", "index.docker.io"}, + {"docker.io", "index.docker.io"}, + {"registry.docker.io", "index.docker.io"}, + {"https://index.docker.io/v1/", "index.docker.io"}, + {"http://registry.example.com", "registry.example.com"}, + {"Registry.Example.COM:5000/some/path", "registry.example.com:5000"}, + {"ghcr.io", "ghcr.io"}, + {" quay.io ", "quay.io"}, + {"", ""}, + } + + for _, tt := range tests { + if got := normalizeRegistryHost(tt.in); got != tt.want { + t.Errorf("normalizeRegistryHost(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestDockerConfigCredentials_StaticAuths(t *testing.T) { + auth := base64.StdEncoding.EncodeToString([]byte("user:pa:ss")) + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": fmt.Sprintf(`{"auths":{"registry.example.com":{"auth":%q}}}`, auth), + }, map[string]string{"HOME": "/home/agent"}) + + cred := store.Lookup(context.Background(), "registry.example.com") + if cred == nil { + t.Fatal("Expected credential, got nil") + } + // Passwords may contain colons; only the first splits user from secret. + if cred.Username != "user" || cred.Secret != "pa:ss" || cred.IdentityToken { + t.Fatalf("Unexpected credential: %+v", cred) + } + + if got := store.Lookup(context.Background(), "other.example.com"); got != nil { + t.Fatalf("Expected nil for unknown registry, got %+v", got) + } +} + +func TestDockerConfigCredentials_DockerHubAlias(t *testing.T) { + auth := base64.StdEncoding.EncodeToString([]byte("hubuser:hubpass")) + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": fmt.Sprintf(`{"auths":{"https://index.docker.io/v1/":{"auth":%q}}}`, auth), + }, map[string]string{"HOME": "/home/agent"}) + + cred := store.Lookup(context.Background(), "registry-1.docker.io") + if cred == nil || cred.Username != "hubuser" || cred.Secret != "hubpass" { + t.Fatalf("Expected hub credential via legacy index key, got %+v", cred) + } +} + +func TestDockerConfigCredentials_PlaintextFieldsAndIdentityToken(t *testing.T) { + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": `{"auths":{ + "plain.example.com":{"username":"u","password":"p"}, + "acr.example.com":{"auth":"MDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAwOg==","identitytoken":"refresh-token"} + }}`, + }, map[string]string{"HOME": "/home/agent"}) + + cred := store.Lookup(context.Background(), "plain.example.com") + if cred == nil || cred.Username != "u" || cred.Secret != "p" { + t.Fatalf("Expected plaintext credential, got %+v", cred) + } + + acr := store.Lookup(context.Background(), "acr.example.com") + if acr == nil || !acr.IdentityToken || acr.Secret != "refresh-token" { + t.Fatalf("Expected identity-token credential, got %+v", acr) + } +} + +func TestDockerConfigCredentials_UnpaddedBase64(t *testing.T) { + auth := base64.RawStdEncoding.EncodeToString([]byte("user:pass")) + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": fmt.Sprintf(`{"auths":{"registry.example.com":{"auth":%q}}}`, auth), + }, map[string]string{"HOME": "/home/agent"}) + + cred := store.Lookup(context.Background(), "registry.example.com") + if cred == nil || cred.Username != "user" || cred.Secret != "pass" { + t.Fatalf("Expected credential from unpadded auth, got %+v", cred) + } +} + +func TestDockerConfigCredentials_CredHelpers(t *testing.T) { + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": `{"credsStore":"global","credHelpers":{"helper.example.com":"special"}}`, + }, map[string]string{"HOME": "/home/agent"}) + + var gotHelper, gotServerURL string + store.runHelper = func(_ context.Context, helper, serverURL string) ([]byte, error) { + gotHelper, gotServerURL = helper, serverURL + return []byte(`{"Username":"helper-user","Secret":"helper-secret"}`), nil + } + + cred := store.Lookup(context.Background(), "helper.example.com") + if cred == nil || cred.Username != "helper-user" || cred.Secret != "helper-secret" { + t.Fatalf("Expected helper credential, got %+v", cred) + } + if gotHelper != "special" { + t.Fatalf("Expected per-registry credHelper to win over credsStore, got %q", gotHelper) + } + if gotServerURL != "helper.example.com" { + t.Fatalf("Expected host server URL, got %q", gotServerURL) + } +} + +func TestDockerConfigCredentials_CredsStoreHubServerURL(t *testing.T) { + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": `{"credsStore":"osxkeychain"}`, + }, map[string]string{"HOME": "/home/agent"}) + + var gotServerURL string + store.runHelper = func(_ context.Context, _, serverURL string) ([]byte, error) { + gotServerURL = serverURL + return []byte(`{"Username":"","Secret":"identity"}`), nil + } + + cred := store.Lookup(context.Background(), "registry-1.docker.io") + if cred == nil || !cred.IdentityToken || cred.Secret != "identity" { + t.Fatalf("Expected identity-token helper credential, got %+v", cred) + } + if gotServerURL != dockerHubConfigKey { + t.Fatalf("Expected legacy hub server URL, got %q", gotServerURL) + } +} + +func TestDockerConfigCredentials_HelperMissAndFailure(t *testing.T) { + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": `{"credsStore":"missing"}`, + }, map[string]string{"HOME": "/home/agent"}) + + if cred := store.Lookup(context.Background(), "registry.example.com"); cred != nil { + t.Fatalf("Expected nil on helper miss, got %+v", cred) + } + + store = newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": `{"credsStore":"broken"}`, + }, map[string]string{"HOME": "/home/agent"}) + store.runHelper = func(_ context.Context, _, _ string) ([]byte, error) { + return nil, errors.New("boom") + } + if cred := store.Lookup(context.Background(), "registry.example.com"); cred != nil { + t.Fatalf("Expected nil on helper failure, got %+v", cred) + } +} + +func TestDockerConfigCredentials_RejectsUnsafeHelperName(t *testing.T) { + store := newTestCredentialStore(map[string]string{ + "/home/agent/.docker/config.json": `{"credHelpers":{"registry.example.com":"../evil"}}`, + }, map[string]string{"HOME": "/home/agent"}) + + helperCalled := false + store.runHelper = func(_ context.Context, _, _ string) ([]byte, error) { + helperCalled = true + return []byte(`{"Username":"u","Secret":"s"}`), nil + } + + if cred := store.Lookup(context.Background(), "registry.example.com"); cred != nil { + t.Fatalf("Expected nil for unsafe helper name, got %+v", cred) + } + if helperCalled { + t.Fatal("Helper with unsafe name must not be executed") + } +} + +func TestDockerConfigCredentials_FilePrecedence(t *testing.T) { + authFileCred := base64.StdEncoding.EncodeToString([]byte("podman:override")) + dockerCred := base64.StdEncoding.EncodeToString([]byte("docker:home")) + store := newTestCredentialStore(map[string]string{ + "/etc/pulse/auth.json": fmt.Sprintf(`{"auths":{"registry.example.com":{"auth":%q}}}`, authFileCred), + "/home/agent/.docker/config.json": fmt.Sprintf(`{"auths":{"registry.example.com":{"auth":%q},"only-home.example.com":{"auth":%q}}}`, dockerCred, dockerCred), + }, map[string]string{ + "HOME": "/home/agent", + "REGISTRY_AUTH_FILE": "/etc/pulse/auth.json", + }) + + cred := store.Lookup(context.Background(), "registry.example.com") + if cred == nil || cred.Username != "podman" { + t.Fatalf("Expected REGISTRY_AUTH_FILE to take precedence, got %+v", cred) + } + + // A file earlier in precedence without the host must not shadow a later one. + fallback := store.Lookup(context.Background(), "only-home.example.com") + if fallback == nil || fallback.Username != "docker" { + t.Fatalf("Expected fallthrough to the docker config, got %+v", fallback) + } +} + +func TestDockerConfigCredentials_PodmanRuntimeAuthFile(t *testing.T) { + auth := base64.StdEncoding.EncodeToString([]byte("podman:runtime")) + store := newTestCredentialStore(map[string]string{ + "/run/user/1000/containers/auth.json": fmt.Sprintf(`{"auths":{"registry.example.com":{"auth":%q}}}`, auth), + }, map[string]string{ + "HOME": "/home/agent", + "XDG_RUNTIME_DIR": "/run/user/1000", + }) + + cred := store.Lookup(context.Background(), "registry.example.com") + if cred == nil || cred.Username != "podman" || cred.Secret != "runtime" { + t.Fatalf("Expected podman runtime auth.json credential, got %+v", cred) + } +} + +func TestDockerConfigCredentials_CacheAvoidsRepeatResolution(t *testing.T) { + auth := base64.StdEncoding.EncodeToString([]byte("user:pass")) + reads := 0 + store := newTestCredentialStore(nil, map[string]string{"HOME": "/home/agent"}) + store.readFile = func(path string) ([]byte, error) { + reads++ + if path == "/home/agent/.docker/config.json" { + return []byte(fmt.Sprintf(`{"auths":{"registry.example.com":{"auth":%q}}}`, auth)), nil + } + return nil, os.ErrNotExist + } + current := time.Unix(1700000000, 0) + store.now = func() time.Time { return current } + + for i := 0; i < 3; i++ { + if cred := store.Lookup(context.Background(), "registry.example.com"); cred == nil { + t.Fatal("Expected credential") + } + } + if reads != 1 { + t.Fatalf("Expected a single config read for cached lookups, got %d", reads) + } + + current = current.Add(credentialCacheTTL + time.Second) + if cred := store.Lookup(context.Background(), "registry.example.com"); cred == nil { + t.Fatal("Expected credential after cache expiry") + } + if reads != 2 { + t.Fatalf("Expected re-resolution after TTL, got %d reads", reads) + } +} + +func TestRegistryChecker_FetchDigest_BasicChallengeWithCredentials(t *testing.T) { + headCalls := 0 + var retryAuth string + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + headCalls++ + if req.Header.Get("Authorization") == "" { + return newStringResponse(http.StatusUnauthorized, map[string]string{ + "Www-Authenticate": `Basic realm="Registry"`, + }, ""), nil + } + retryAuth = req.Header.Get("Authorization") + return newStringResponse(http.StatusOK, map[string]string{ + "Docker-Content-Digest": "sha256:private", + }, ""), nil + }), + }, + credentials: staticCredentials("registry.example.com", "user", "pass"), + } + + digest, _, err := checker.fetchDigest(context.Background(), "registry.example.com", "app", "latest", "", "", "") + if err != nil { + t.Fatalf("Expected success, got %v", err) + } + if digest != "sha256:private" { + t.Fatalf("Expected private digest, got %q", digest) + } + if headCalls != 2 { + t.Fatalf("Expected 2 HEAD calls, got %d", headCalls) + } + if retryAuth != basicAuthValue("user", "pass") { + t.Fatalf("Expected Basic retry authorization, got %q", retryAuth) + } +} + +func TestRegistryChecker_FetchDigest_BasicChallengeWithoutCredentialsStillFails(t *testing.T) { + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return newStringResponse(http.StatusUnauthorized, map[string]string{ + "Www-Authenticate": `Basic realm="Registry"`, + }, ""), nil + }), + }, + } + + _, _, err := checker.fetchDigest(context.Background(), "registry.example.com", "app", "latest", "", "", "") + if err == nil || err.Error() != "authentication required" { + t.Fatalf("Expected authentication required, got %v", err) + } +} + +func TestRegistryChecker_FetchDigest_BearerChallengeWithCredentials(t *testing.T) { + var tokenAuth string + var retryAuth string + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "auth.example" { + tokenAuth = req.Header.Get("Authorization") + return newStringResponse(http.StatusOK, nil, `{"token":"private-token"}`), nil + } + if req.Header.Get("Authorization") == "" { + return newStringResponse(http.StatusUnauthorized, map[string]string{ + "Www-Authenticate": `Bearer realm="https://auth.example/token",service="registry.example.com"`, + }, ""), nil + } + retryAuth = req.Header.Get("Authorization") + return newStringResponse(http.StatusOK, map[string]string{ + "Docker-Content-Digest": "sha256:private", + }, ""), nil + }), + }, + credentials: staticCredentials("registry.example.com", "user", "pass"), + } + + digest, _, err := checker.fetchDigest(context.Background(), "registry.example.com", "team/app", "latest", "", "", "") + if err != nil { + t.Fatalf("Expected success, got %v", err) + } + if digest != "sha256:private" { + t.Fatalf("Expected private digest, got %q", digest) + } + if tokenAuth != basicAuthValue("user", "pass") { + t.Fatalf("Expected Basic auth on token negotiation, got %q", tokenAuth) + } + if retryAuth != "Bearer private-token" { + t.Fatalf("Expected negotiated bearer retry, got %q", retryAuth) + } +} + +func TestRegistryChecker_FetchDigest_DockerHubCredentials(t *testing.T) { + var tokenAuth string + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "auth.docker.io" { + tokenAuth = req.Header.Get("Authorization") + return newStringResponse(http.StatusOK, nil, `{"token":"hub-token"}`), nil + } + if req.Header.Get("Authorization") != "Bearer hub-token" { + return newStringResponse(http.StatusUnauthorized, nil, ""), nil + } + return newStringResponse(http.StatusOK, map[string]string{ + "Docker-Content-Digest": "sha256:hub", + }, ""), nil + }), + }, + credentials: staticCredentials("registry-1.docker.io", "hubuser", "hubpass"), + } + + digest, _, err := checker.fetchDigest(context.Background(), "registry-1.docker.io", "team/private", "latest", "", "", "") + if err != nil { + t.Fatalf("Expected success, got %v", err) + } + if digest != "sha256:hub" { + t.Fatalf("Expected hub digest, got %q", digest) + } + if tokenAuth != basicAuthValue("hubuser", "hubpass") { + t.Fatalf("Expected Basic auth on hub token request, got %q", tokenAuth) + } +} + +func TestRegistryChecker_FetchDigest_StaleCredentialsFallBackToAnonymous(t *testing.T) { + tokenRequests := 0 + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "auth.docker.io" { + tokenRequests++ + if req.Header.Get("Authorization") != "" { + // The stored login has been revoked. + return newStringResponse(http.StatusUnauthorized, nil, ""), nil + } + return newStringResponse(http.StatusOK, nil, `{"token":"anon-token"}`), nil + } + if req.Header.Get("Authorization") != "Bearer anon-token" { + return newStringResponse(http.StatusUnauthorized, nil, ""), nil + } + return newStringResponse(http.StatusOK, map[string]string{ + "Docker-Content-Digest": "sha256:public", + }, ""), nil + }), + }, + credentials: staticCredentials("registry-1.docker.io", "stale", "stale"), + } + + digest, _, err := checker.fetchDigest(context.Background(), "registry-1.docker.io", "library/nginx", "latest", "", "", "") + if err != nil { + t.Fatalf("Expected anonymous fallback to succeed, got %v", err) + } + if digest != "sha256:public" { + t.Fatalf("Expected public digest, got %q", digest) + } + if tokenRequests != 2 { + t.Fatalf("Expected credentialed then anonymous token requests, got %d", tokenRequests) + } +} + +func TestRegistryChecker_FetchDigest_CredentialedTokenNotRenegotiated(t *testing.T) { + headCalls := 0 + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "auth.docker.io" { + return newStringResponse(http.StatusOK, nil, `{"token":"hub-token"}`), nil + } + headCalls++ + return newStringResponse(http.StatusUnauthorized, map[string]string{ + "Www-Authenticate": `Bearer realm="https://auth.docker.io/token",service="registry.docker.io"`, + }, ""), nil + }), + }, + credentials: staticCredentials("registry-1.docker.io", "user", "pass"), + } + + _, _, err := checker.fetchDigest(context.Background(), "registry-1.docker.io", "team/private", "latest", "", "", "") + if err == nil || err.Error() != "authentication required" { + t.Fatalf("Expected authentication required, got %v", err) + } + if headCalls != 1 { + t.Fatalf("Expected a single HEAD (no pointless renegotiation), got %d", headCalls) + } +} + +func TestRegistryChecker_FetchDigest_IdentityTokenExchange(t *testing.T) { + var tokenForm string + var tokenMethod string + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "auth.example" { + tokenMethod = req.Method + body, _ := readBodyWithLimit(req.Body, maxRegistryTokenBodyBytes) + tokenForm = string(body) + return newStringResponse(http.StatusOK, nil, `{"access_token":"exchanged"}`), nil + } + if req.Header.Get("Authorization") == "" { + return newStringResponse(http.StatusUnauthorized, map[string]string{ + "Www-Authenticate": `Bearer realm="https://auth.example/token",service="acr.example.com"`, + }, ""), nil + } + if req.Header.Get("Authorization") != "Bearer exchanged" { + return newStringResponse(http.StatusUnauthorized, nil, ""), nil + } + return newStringResponse(http.StatusOK, map[string]string{ + "Docker-Content-Digest": "sha256:acr", + }, ""), nil + }), + }, + credentials: credentialSourceFunc(func(_ context.Context, registry string) *registryCredential { + if registry != "acr.example.com" { + return nil + } + return ®istryCredential{Username: "", Secret: "refresh-secret", IdentityToken: true} + }), + } + + digest, _, err := checker.fetchDigest(context.Background(), "acr.example.com", "team/app", "latest", "", "", "") + if err != nil { + t.Fatalf("Expected success, got %v", err) + } + if digest != "sha256:acr" { + t.Fatalf("Expected ACR digest, got %q", digest) + } + if tokenMethod != http.MethodPost { + t.Fatalf("Expected POST refresh-token grant, got %s", tokenMethod) + } + for _, fragment := range []string{ + "grant_type=refresh_token", + "refresh_token=refresh-secret", + "service=acr.example.com", + "scope=repository%3Ateam%2Fapp%3Apull", + } { + if !strings.Contains(tokenForm, fragment) { + t.Fatalf("Token form missing %q: %q", fragment, tokenForm) + } + } +} + +func TestRegistryChecker_FetchDigest_BasicAuthCarriesIntoManifestList(t *testing.T) { + var listAuth string + checker := &RegistryChecker{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") == "" { + return newStringResponse(http.StatusUnauthorized, map[string]string{ + "Www-Authenticate": `Basic realm="Registry"`, + }, ""), nil + } + if req.Method == http.MethodGet { + listAuth = req.Header.Get("Authorization") + return newStringResponse(http.StatusOK, nil, + `{"manifests":[{"digest":"sha256:platform","platform":{"architecture":"amd64","os":"linux"}}]}`), nil + } + return newStringResponse(http.StatusOK, map[string]string{ + "Docker-Content-Digest": "sha256:index", + "Content-Type": "application/vnd.oci.image.index.v1+json", + }, ""), nil + }), + }, + credentials: staticCredentials("registry.example.com", "user", "pass"), + } + + digest, headDigest, err := checker.fetchDigest(context.Background(), "registry.example.com", "app", "latest", "amd64", "linux", "") + if err != nil { + t.Fatalf("Expected success, got %v", err) + } + if digest != "sha256:platform" || headDigest != "sha256:index" { + t.Fatalf("Expected resolved platform digest, got %q / %q", digest, headDigest) + } + if listAuth != basicAuthValue("user", "pass") { + t.Fatalf("Expected Basic auth on manifest list fetch, got %q", listAuth) + } +}