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
-
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.
-
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
GETendpoints 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. -
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; aDOCKER_CONFIGset in the service'senvironment: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).
- 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.)
- 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_addtiers 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
:rowould 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_socketinscripts/validate_rule_premises.pydrivesdocker compose runwith the key set and asserts four things from inside the service: the socket mount isrw, the daemon answers through it,DOCKER_CONFIGis/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