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-<name> 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.
This commit is contained in:
rcourtman
2026-08-13 10:06:26 +01:00
parent faf13973c4
commit 3355f7a671
13 changed files with 1279 additions and 151 deletions
+74 -69
View File
@@ -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
}
+36
View File
@@ -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")
}
})
}
+4 -2
View File
@@ -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 <https://pulserelay.pro/download.html>,
+1
View File
@@ -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)* |
@@ -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-<name> 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
@@ -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-<name> 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
+4 -2
View File
@@ -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 <https://pulserelay.pro/download.html>,
@@ -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)* |
+27 -12
View File
@@ -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
@@ -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)
}
}
+170 -66
View File
@@ -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 "<token>", 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)
@@ -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-<helper> 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 &registryCredential{Username: username, Secret: entry.IdentityToken, IdentityToken: true}, nil
}
if username == "" || secret == "" {
return nil, nil
}
return &registryCredential{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 == "<token>" {
return &registryCredential{Username: resp.Username, Secret: resp.Secret, IdentityToken: true}, nil
}
return &registryCredential{Username: resp.Username, Secret: resp.Secret}, nil
}
// execCredentialHelper runs docker-credential-<helper> 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 ""
}
@@ -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 &registryCredential{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":"<token>","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 &registryCredential{Username: "<token>", 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)
}
}