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) + } +}