A scanner that looks at one artifact at a time reports findings one at a time. But the risk in a multi-service stack is often in the relationships between services, not in any single one of them.
A database with a committed password is one finding. A database published on 0.0.0.0 is another.
A database that is both, and shares a network with an internet-facing service, is a single
exploitable path that neither finding describes on its own.
DockSec detects these paths and reports them as chains: the services involved, the findings that combine, the attack path in order, and the one change that breaks it most effectively.
Chain detection is a graph query over the compose topology - what is exposed, what holds a credential, what can reach what, what has already escaped the container boundary. That is a deterministic question, so it is answered deterministically.
The practical consequences:
- Chains are reported with
--scan-only, offline, and with no API key. - The same stack always produces the same chains.
- A CI gate can depend on them.
The AI correlation pass adds a layer on top - ranking chains against each other, catching shapes the rules do not cover, and explaining impact in the context of a specific application. It is not load-bearing for the detection itself.
Severity: CRITICAL
A database, cache, or broker publishes a port on all interfaces and its credential is a literal value in the compose file.
services:
db:
image: postgres:16
ports:
- "5432:5432" # binds 0.0.0.0
environment:
POSTGRES_PASSWORD: hunter2 # committed to the repositoryNo application vulnerability is required. Anyone who can route to the host and read the repository can connect to the datastore and authenticate. On a cloud VM with a public address, that is the internet.
Breaks with: binding to 127.0.0.1 (or dropping the published port and using expose), and
moving the credential to a Docker secret. Either alone reduces the severity; both remove the chain.
Severity: CRITICAL
A service that publishes a port also mounts the Docker socket or runs privileged.
services:
web:
image: nginx
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sockThe service is reachable from outside the host, and the container boundary it sits behind is already open. Remote code execution in this service is a host compromise rather than a contained one - the usual "it is only a container" mitigation does not apply.
Breaks with: removing the socket mount or the privileged flag. That is the change that matters; closing the port just makes the escape path harder to reach.
Severity: HIGH
An internet-facing service shares a network with a credentialed datastore that is not itself exposed.
services:
web:
image: nginx
ports:
- "80:80" # internet-facing
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: hunter2
# no ports, no networks - so it joins the default network, with webThis is the chain that per-service review misses most often. db is not exposed, so it looks
contained. web has no credential, so it looks low-value. But compose puts both on the default
network when neither declares one, so compromising web yields authenticated access to db.
Breaks with: putting the datastore on its own network that the exposed service does not join, or moving the credential to a secret so a compromise of the front-end does not hand over the password.
Exploit chains
[CRITICAL] PostgreSQL on 'db' is reachable from any host and its credential is in the compose file
services: db
combines: compose-port-bound-all-interfaces, compose-plaintext-secret-env
Port 5432 is published with no host IP, so it binds 0.0.0.0 and accepts connections from
outside the host. The credential in POSTGRES_PASSWORD is committed alongside it, so anyone
who can read the repository can authenticate to the datastore directly - no application
vulnerability required.
break it: Bind the port to 127.0.0.1 (or drop it and use 'expose'), and move
POSTGRES_PASSWORD to a Docker secret.
- services - which services take part. A chain spanning two services is one no per-service view would surface.
- combines - the individual rule IDs. Each is documented in the compose rule reference.
- break it - the single most effective change. Chains usually have several links; this names the one worth doing first.
Chains also appear in --json under exploit_chains, so CI can act on them:
docksec --compose docker-compose.yml --scan-only --json \
| jq '.exploit_chains[] | select(.severity == "CRITICAL")'Stated plainly, because an empty chain list should not be read as "no chains exist":
- Chains that span a Dockerfile and a compose file (a vulnerable package in an image that a chain then reaches).
- Chains involving Kubernetes or Helm resources - compose only, for now.
- Reachability: whether the vulnerable code path in an exposed service is actually invoked.
- Trust relationships expressed outside the compose file, such as a firewall or a service mesh policy. A stack protected by one may still report a chain, which is a reasonable case for a waiver.