Skip to content

CL-0031: use_api_socket: true — engine API delegated with registry credentials

Severity: CRITICAL (how it is derived)

use_api_socket: true makes Compose bind-mount the host's Docker socket into the service read-write and copy the registry logins of whoever runs the stack in beside it, so the container controls the host daemon and can push and pull as that person. Remove the key, or give the service a filtered socket; a tool that needs both grants is excluded with the reason written down and run from a deploy identity that holds no logins it does not need.

Fix

# Before
services:
  gateway:
    image: docker/mcp-gateway:latest
    use_api_socket: true

# After, when the service needs the engine API but not your registry identity:
# the filtered socket from the CL-0001 page. No credentials are copied in.
services:
  socket-proxy:
    # as on the CL-0001 page: GET-only filter, nobody + docker group, socket :ro
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./sockets:/sock
  gateway:
    image: docker/mcp-gateway:latest
    environment:
      DOCKER_HOST: unix:///sock/docker.sock
    volumes:
      - ./sockets:/sock               # the filtered socket, not the real one
  1. Decide which of the two grants the service uses. The key hands over two things at once: the daemon API, and your registry identity. A service that needs neither loses the key and nothing else. A service that reads from the daemon (lists containers, watches events) needs the API and not your logins. A service that creates containers and pulls private images as you, which is what an engine-management gateway does, needs both.

  2. API only: replace the key with a filtered socket. The proxy block and the group setup are on the CL-0001 page, step by step. The proxy answers only the GET endpoints the consumer calls and copies no credentials anywhere. A consumer that creates containers or pulls a private image will fail behind it; that failure is the sign it belongs in the next case.

  3. Both: there is no in-file fix. Exclude the service with the reason written down (see When to suppress) and narrow the credential half on the host, which is the only place it can be narrowed. Compose copies the complete login set of the user who runs docker compose up; a DOCKER_CONFIG set in the service's environment: is respected by the client inside the container, but the file is still placed (measured). Run the stack from a Docker config that holds only the logins this stack needs, with a read-only token where the registry offers one:

install -d -m 0700 ~/.docker-mcp                 # a config just for this stack
DOCKER_CONFIG=~/.docker-mcp docker login registry.example.com   # the scoped token
DOCKER_CONFIG=~/.docker-mcp docker compose up -d

Compose reads the host-side DOCKER_CONFIG when it resolves what to copy (measured: a login made under a test config, and nothing else, landed in the container).

  1. Check what the service received:
docker compose run --rm --entrypoint sh gateway -c \
  'test -S /var/run/docker.sock && echo socket || echo no-socket;
   test -f /run/secrets/docker/config.json && echo logins || echo no-logins'
# no-socket
# no-logins

With the key the same probe printed socket and logins, DOCKER_CONFIG was /run/secrets/docker, and docker version inside the service was answered by the host daemon. (Measured on Compose v5.0.0; the mount and the placed file are re-proven on every CI run, _cl0031_api_socket.)

  1. Check the finding is gone:
compose-lint -q compose.yaml | grep CL-0031    # prints nothing once fixed

Tested on: rootful Docker Engine 29.1.3 with Compose v5.0.0 on Linux, daemon at Docker's defaults, in October 2026. Not tested: Docker Desktop, where Enhanced Container Isolation blocks the key unless an administrator allows it, so the finding over-reports there; rootless Docker; a Windows engine, where Compose refuses the key.

When to suppress

The service is an engine-management tool that creates containers and pulls images as you, and you have accepted that a compromise of it is a compromise of the host and of every registry account in your Docker config. Docker's MCP gateway is the common case; its documentation tells users to set this key. Exclude that one service with a reason: that names the tool and the deploy identity the stack runs under, and keep the rule live for every other service:

# .compose-lint.yml
rules:
  CL-0031:
    exclude_services:
      gateway: "MCP gateway; run as the mcp-deploy user, whose Docker config holds only the GHCR read token"

The exclusion covers this key only. A service that also mounts the socket under volumes: carries a CL-0001 finding of its own, and that one is excluded, or not, on its own reason.

What it detects

A service whose use_api_socket is the YAML boolean true. Compose added the key in 2.37.1, as a service-level boolean.

What is not graded, because Compose refuses to run it: the quoted string "true" and any interpolated form such as ${X:-true}, both rejected as services.<name>.use_api_socket must be a boolean (measured on Compose v5.0.0, with and without the variable set). The key is not in Compose's interpolation cast list, so there is no spelling that deploys other than the bare boolean. This is the line already drawn for port and security_opt forms Docker refuses. use_api_socket: false is clean.

The parser reads the YAML 1.1 spellings yes and on as booleans, so a file that spells the key that way is graded as true. Compose refuses both with the same must be a boolean error (measured on v5.0.0), so such a file does not deploy until the spelling is corrected, and true is the only correction that runs. The finding describes the grant the author is one edit from making; it is kept rather than special-cased, since the parser has no way to tell the spellings apart and nothing deployable is misreported.

An override file is read first: use_api_socket: false in compose.override.yml cancels a base true, and a true in the override alone is a finding. The rule grades the merged configuration, as Compose runs it.

A service that sets the key and mounts the socket under volumes: carries both findings. They are two grants, not two spellings of one: the credential copy is nothing a socket mount does, and the socket mount is one CL-0001's exclusion was written for. Neither overshadows the other.

Why it matters

One boolean, two grants. The first is the socket. From the Compose implementation (pkg/compose/apiSocket.go, read at v5.0.0 and main), the key adds a read-write bind mount of /var/run/docker.sock to the service, unconditionally. That is the grant CL-0001 describes: the daemon API creates privileged containers with the host root filesystem mounted, so it is host root with one extra step. Measured: the socket is present in the service, mountinfo shows it rw, and docker version inside the service is answered by the host daemon.

The second grant is the one no socket mount makes. Compose resolves every registry login the invoking user holds, through GetAllCredentials(), which reads the credential store and every configured credential helper, writes them as a config file at /run/secrets/docker/config.json, and sets DOCKER_CONFIG=/run/secrets/docker unless the author set it. Measured, on a throwaway registry with a generated password and a fake helper wired through credHelpers: with one docker login and one helper entry on the host, the placed file held both, in plain JSON. A credential kept in an OS keychain on the host is written out as text inside the container. The file is mode 444, owned by root, and a service running as user: "65534:65534" read it. And it is a copy into the root filesystem, not a mount: nothing in mountinfo names it, which is also why the key cannot be combined with read_only: true (Compose refuses: cannot create config "" in read-only service).

The service acts as you. With the key set, a docker push from inside the service to an authenticated registry succeeded. From a service with a plain /var/run/docker.sock volume and no key, the same push was refused. So a compromise of the service is a compromise of every registry account in your Docker config: Docker Hub, GHCR, a cloud registry, whatever docker login left there, with the ability to push a poisoned tag to any image those accounts can write. The socket half is host root; the credential half reaches assets that are not on the host at all. The impact axis stops at Host, so the second grant does not raise the number. It is why the key has its own rule rather than being a route inside CL-0001: an exclusion written for a socket mount is read as accepting a socket mount, and if it also silenced this key, it would accept the credential copy too, with nothing in the file to show it (ADR-028, post-1.0 amendment).

It is a documented pattern. Compose's reference describes the key as letting the container "interact with the underlying container engine through the API socket", with the user's credentials mounted so the container can act as a delegate for their pushes and pulls, and Docker's MCP gateway documentation tells users to set it. Five of the eight corpus files that set the key run that gateway. A vendor-recommended setting is still graded for what it grants, as privileged: true is for Docker-in-Docker. Docker treats the grant as sensitive too: CVE-2026-6406 (GHSA-3f9v-226v-2qc9, CVSS 4.0 8.8) was Docker Desktop's Enhanced Container Isolation failing to block the --use-api-socket mount path, which exposed the socket and the user's registry credentials to a container the policy said could have neither. That advisory grounds what the key is; it is not a severity input, since it scored a Desktop policy bypass rather than the grant at rootful defaults. Compose's own source comment calls the current implementation temporary, pending engine support, so what the key mounts may change; it is held under its own id so that change is re-derived on this page rather than split out of CL-0001.

ATT&CK coverage

Remediating this finding contributes to mitigating the following MITRE ATT&CK techniques (pinned to ATT&CK v18). compose-lint is a static analyser, so this is mitigation coverage — it detects nothing at runtime.

Technique Tactic
T1610 Deploy Container Execution
T1609 Container Administration Command Execution
T1611 Escape to Host Privilege Escalation
T1612 Build Image on Host Stealth
T1552.007 Unsecured Credentials: Container API Credential Access
T1552.001 Unsecured Credentials: Credentials In Files Credential Access
T1613 Container and Resource Discovery Discovery

See also

  • CL-0001 — the socket half of this grant, every way a volumes:, secrets: or named-volume mount can spell it, and the filtered-socket fix this page points at
  • CL-0002 — the registry's other bundled grant: one key that hands over every capability, device and profile, graded as its own rule beside the cap_add tiers for the same reason this one sits beside CL-0001
  • CL-0007 — the hardening this key is incompatible with; its fixer refuses a service that sets the key
  • CL-0020 — credentials the author wrote into the file; this rule is about credentials the author never sees
  • Hardening guide, step 9 — where this rule fits when hardening a whole file

Severity derivation

Derivation (see severity model):

  • Baseline: A — the attacker already has code execution in this container, as the workload uid
  • Precondition: Direct — talking to the daemon over the socket is a supported API call, and the credentials file is read with cat; no technique and no second defect
  • Impact: Host — the daemon API creates privileged containers, so it is host control. The credential copy reaches further than the host, but the axis stops at Host
  • Qualifier/modifier: none — the mount is read-write, and :ro would not narrow the API anyway (see CL-0001); the credentials file is readable by any uid in the service
  • Derived: Direct × Host = CRITICAL
  • Shipped: CRITICAL
  • Scoping assumptions: the grounded posture (ADR-020): rootful Docker Engine on Linux. Docker Desktop with Enhanced Container Isolation blocks the key unless allowed, and the Compose source refuses it on a Windows engine; both read down from this score, as every CRITICAL does. Only the YAML boolean is graded; the string and interpolated spellings never deploy
  • Evidence: _cl0031_api_socket in scripts/validate_rule_premises.py drives docker compose run with the key set and asserts four things from inside the service: the socket mount is rw, the daemon answers through it, DOCKER_CONFIG is /run/secrets/docker, and the credentials file exists. It prints tokens only and never reads the file. The second grant was measured once, by hand, on a throwaway registry: a login and a helper-resolved entry both landed in the placed file, and a push through the socket succeeded with the key and was refused without it. That measurement is not repeated in CI, because it needs a registry login on the runner, and the premise check must run before any such login so that nothing real is ever copied into a probe

References: - OWASP Docker Security Rule #1 - CIS Docker Benchmark 5.32 — Ensure that the Docker socket is not mounted inside any containers - Compose file reference: use_api_socket - GHSA-3f9v-226v-2qc9 / CVE-2026-6406 — the Desktop isolation bypass through this key's mount path