compose-lint compared with DCLint, KICS, Checkov, Trivy, Semgrep, DockSec and Hadolint¶
Which of these tools read a Docker Compose file and check it for security
misconfigurations? Five do: compose-lint, DCLint, KICS, Semgrep's
p/docker-compose ruleset and OWASP DockSec. Three do not: Checkov, Trivy
and Hadolint lint Dockerfiles or images, which is a different file.
Two of the five, Semgrep and KICS, only work fully on files that still carry
the top-level version: key from the old Compose file format. The Compose
Specification that Docker Compose has followed since 2020 makes that key
obsolete, and docker compose warns when it sees one. The ninth option that
search results keep suggesting, a script of your own, has its own section
below.
The table was checked against each tool's documentation on 2026-10-08. The
next day all eight tools were run against the same deliberately broken
Compose file, and that run corrected the documentation in three places. The
run is described further down. On 2026-10-17 the five tools that read Compose
were run again on stacks spread across several files, where the setting comes
from extends:, include:, an override file or .env. Sources are linked
under each heading.
| Tool | Reads Compose files | Compose security rules it has | Auto-fix for Compose | SARIF | Scope |
|---|---|---|---|---|---|
| compose-lint | Yes | 27, each citing OWASP, CIS or Docker docs | Six rules, dry-run diff first | Yes | Docker Compose only |
| KICS | Yes | 21 queries | remediate command; Compose support not documented |
Yes | Terraform, Kubernetes, CloudFormation, Compose and more |
| OWASP DockSec | Yes (--compose) |
17 | No (--fix edits Dockerfiles only) |
Yes | Dockerfiles, images, Compose |
Semgrep p/docker-compose |
Yes | 6 | 1 of the 6 carries a fix | Yes | General-purpose code scanner |
| DCLint | Yes | 2 of 15 (unbound port interfaces, explicit image tag) | Formatting and ordering rules | — | Docker Compose style, structure and schema |
| Checkov | No | 0 | — | Yes | Terraform, Kubernetes, CloudFormation, Dockerfile and more |
| Trivy | No | 0 | — | Yes | Images, filesystems, IaC |
| Hadolint | No | 0 | — | Yes | Dockerfile only |
A script of your own (yq/jq, Conftest/OPA, a custom Semgrep or KICS rule) |
Whatever it parses | Whatever you write | Whatever you write | — | See below |
The table says which rules each tool documents. The three runs below show which of them fired on which file. compose-lint's own gaps are listed at the end of the page. It does not replace the image scanner or the Dockerfile linter you already run.
Run on the same file¶
Documentation describes what a tool is meant to check. A run shows what it
checked. The fixture has sixteen services, each with one deliberate
misconfiguration, written to the Compose Specification, so without the
obsolete version: key. Each tool was run from its published image on
2026-10-09. The fixture and the exact commands are in
Reproduce it.
| Misconfiguration | compose-lint | KICS | DockSec | Semgrep | DCLint |
|---|---|---|---|---|---|
| Docker socket mounted | ✅ | ✅ | ✅ | ✗ † | — |
privileged: true |
✅ | ✅ | ✅ | ✗ † | — |
no-new-privileges missing |
✅ | ✅ | ✅ | ✗ † | — |
Unpinned :latest tag |
✅ | — | ✅ | — | ✅ |
Port published on 0.0.0.0 |
✅ | ✅ | ✗ ‡ | — | ✅ |
No cap_drop: [ALL] |
✅ | ✅ | — | — | — |
| Writable root filesystem | ✅ | — | ✅ | ✗ † | — |
network_mode: host |
✅ | ✅ | ✅ | — | — |
seccomp:unconfined |
✅ | ✅ | ✅ | ✗ † | — |
pid: host / ipc: host |
✅ | ✅ | ✅ | — | — |
| Sensitive host path mounted | ✅ | ✅ | ✅ | — | — |
logging: driver: none |
✅ | — | — | — | — |
user: root |
✅ | — | ✗ | — | — |
| Tag without digest | ✅ | — | — | — | — |
Credential in environment: |
✅ | ✅ | ✅ | — | — |
| No memory/CPU limits | ✅ | ✗ * | ✅ | — | — |
| Caught, of 16 | 16 | 10 | 11 | 0 † | 2 |
✅ flagged · ✗ not flagged · — no rule for it. * KICS's memory and CPU
queries fire only when the file carries the obsolete version: key; with
version: "3.9" added it catches 11. † Semgrep's rules all require that key;
with it added the same six rules produced 31 findings and caught these 5.
‡ DockSec's all-interfaces rule only covers a fixed list of admin, database
and broker ports, and the fixture publishes 8080.
The run contradicted the documentation in three places:
- Semgrep's six Compose rules all require a top-level
version:key. Each one is written aspattern-inside: version: … services: …, so on a Compose Specification file they report nothing. Semgrep's own summary for the run was six rules, one file, zero findings. Withversion: "3.9"added and nothing else changed, the same rules produced 31 findings and caught 5 of the 16. The ruleset's own test fixtures carryversion: "3.9". This is known upstream as semgrep-rules#4035 (July 2026), with a fix in PR #4037 waiting for review since August. The requirement was added on purpose in 2021 so the rules would not match theservices:block of a GitHub Actions workflow, and the fix has to keep that behaviour. When it merges, Semgrep's column changes. - KICS's limit queries depend on the same key. The memory and CPU
queries only fire when
versionis 3 or later, andPids Limit Not Setonly fires when it is below 3, which means the old version 2 format. On a current file none of the three can fire. ItsNo New Privileges Not Setquery only fires on a service that already has asecurity_opt:list; services without one get the broaderSecurity Opt Not Setinstead. - DockSec's Compose rules need only Trivy installed. Its default mode
also scans each service's image, which needs the Docker CLI, and runs an
AI explanation pass, which needs an AI provider key, OpenAI by default.
Without either, it still reports the Compose findings but exits with
code 3. The run used
--scan-only, which skips the AI pass.
Checkov, Trivy and Hadolint were run on the same file too and, as their documentation says, none of them read it as a Compose file. Their sections below say what each one checks instead.
Versions, as each tool reported them: compose-lint 0.34.0, DCLint 3.1.0, KICS 2.1.20, DockSec 2026.8.19 (image tag 2026.9.21; the release declares 2026.9.21 but the tool still reports the older string), Semgrep 1.179.0, Checkov 3.3.26, Trivy 0.75.0, Hadolint 2.15.1.
Against the OWASP cheat-sheet rules¶
The first fixture was built from compose-lint's own rules, so a full score
on it only shows that those rules fire. It says nothing about coverage. The
second fixture is built from the OWASP Docker Security Cheat
Sheet
instead, with one service per violation that can be expressed in a Compose
file. That covers Rules 1 to 8, 12 and 13. Rules 0, 9, 10 and 11 concern the
host, the CI pipeline and the daemon. Twenty-one services, same tools, no
version: key: twenty were run on 2026-10-09, and the SELinux row was added
on 2026-10-14 against the same image digests. This fixture shows where
compose-lint has no rule.
| OWASP rule — violation | compose-lint | KICS | DockSec | Semgrep | DCLint |
|---|---|---|---|---|---|
| 1 — Docker socket mounted | ✅ | ✅ | ✅ | ✗ † | — |
2 — user: root set explicitly |
✅ | — | ✗ | — | — |
2 — no user:, image default |
— | — | ✅ | — | — |
3 — privileged: true |
✅ | ✅ | ✅ | ✗ † | — |
| 3 — default capability set, nothing dropped | ✅ | ✅ | — | — | — |
3 — cap_drop: [ALL] then cap_add: [SYS_ADMIN] |
✅ | ✅ | ✅ | — | — |
4 — no-new-privileges not set |
✅ | ✅ | ✅ | ✗ † | — |
5 — network_mode: host |
✅ | ✅ | ✅ | — | — |
5 — no networks:, joins the shared default network |
— | — | ✅ | — | — |
5a — port published on 0.0.0.0 |
✅ | ✅ | ✗ ‡ | — | ✅ |
6 — seccomp:unconfined |
✅ | ✅ | ✅ | ✗ † | — |
6 — apparmor:unconfined |
✅ | — | ✅ | — | — |
6 — SELinux label:disable |
✅ | — | ✗ | ✗ † | — |
| 7 — no memory or CPU limits | ✅ | ✗ * | ✅ | — | — |
7 — no pids_limit |
— | ✗ | — | — | — |
7 — restart: always, unbounded |
— | — | — | — | — |
| 8 — root filesystem writable | ✅ | — | ✅ | ✗ † | — |
| 8 — host directory mounted read-write | — | — | — | — | — |
12 — credential as a literal environment: value |
✅ | ✅ | ✅ | — | — |
13 — mutable :latest tag |
✅ | — | ✅ | — | ✅ |
| 13 — version tag without a digest | ✅ | — | — | — | — |
| Flagged, of 21 | 16 | 9 | 13 | 0 † | 2 |
| No rule for it | 5 | 10 | 5 | 15 | 19 |
| Has a rule, missed it | 0 | 2 | 3 | 6 † | 0 |
✅ flagged · ✗ has a rule for it, did not flag it · — no rule for it.
* fires with the obsolete version: "3.9" key present (10 of 21). † Semgrep's
rules all require that key; with it added they catch these 6. ‡ only for a
fixed list of sensitive ports. Checkov, Trivy and
Hadolint are 0 of 21 on both variants and are left off the table.
compose-lint has no rule for five of the twenty-one. They are listed under
What compose-lint does not do. It missed
none of the rules it has. DockSec is the closest second and the only tool
that models the shared default network. It misses an explicit user: root,
because it checks for the absence of user:, its port rule did not
cover the port the fixture publishes, and its disabled-security_opt rule
fired on seccomp:unconfined and apparmor:unconfined but not on
label:disable. KICS has queries for two of the three
Rule 7 items and fired neither on a file without version:. With
version: "3.9" it recovers memory and CPU; its PID query only fires on the
old version 2 format, and it has no query for restart: always. No tool
flags a read-write bind mount that could be :ro, and none flags
restart: always. Only compose-lint flags a missing digest.
Run on a stack spread across files¶
Both fixtures above are single, self-contained files. Most stacks are not. A
service inherits from a base with extends:, another file is pulled in with
include:, compose.override.yml is merged on top, and values come from
.env. Compose deploys the merged result. A tool that grades each file as
written can miss a setting that only exists after the merge, or report one
the merge has already undone.
The third run tests that. Each scenario puts privileged: true, the Docker
socket mount and a port published on all interfaces behind one Compose
mechanism, and docker compose config confirmed that each one deploys them.
Four more scenarios look dangerous as written and deploy safely. Every
scenario was run once as written and once with version: "3.9" prepended.
KICS, Semgrep and DCLint were given the whole directory, so a base, included
or override file was in front of them as a file of its own. compose-lint and
DockSec were given compose.yaml, the file docker compose starts from. The
run is from 2026-10-17, with every image pinned by digest; the fixtures and
commands are in Reproduce it.
Each cell lists privileged, the socket and the port, in that order.
| Where the settings come from | compose-lint | KICS | DockSec | Semgrep |
|---|---|---|---|---|
| Written inline (control) | ✅ ✅ ✅ | ✅ ✅ ✅ | ✅ ✅ — | ✗ ✗ — † |
extends: a service in the same file |
✅ ✅ ✅ | ◐ ◐ ◐ | ◐ ◐ — | ✗ ✗ — † |
extends: a service in another file |
✅ ✅ ✅ | ◐ ◐ ◐ | ✗ ✗ — | ✗ ✗ — † |
include: of another file |
✅ ✅ ✅ | ✅ ✅ ✅ | ✗ ✗ — | ✗ ✗ — † |
compose.override.yml |
✅ ✅ ✅ | ✗ ✗ ✗ | ✗ ✗ — | ✗ ✗ — † |
YAML anchor and <<: merge key |
✅ ✅ ✅ | ✅ ✅ ✅ | ✅ ✅ — | ✗ ✗ — † |
${VAR:-default} |
✅ ✅ ✅ | ✗ ✗ ✗ | ✗ ✅ — | ✗ ✗ — |
A value from .env |
✅ ✅ ✅ | ✗ ✗ ✅ ‡ | ✗ ✗ — | ✗ ✗ — |
Four more scenarios carry one or two settings each:
| Scenario | compose-lint | KICS | DockSec | Semgrep |
|---|---|---|---|---|
Socket and port in the long volumes: and ports: syntax |
✅ ✅ | ✗ ✅ | ✅ — | ✗ — † |
Socket as secrets: with file: /var/run/docker.sock |
✅ | ✗ | ✗ | ✗ |
seccomp=unconfined |
✅ | ✗ | ✗ | ✗ |
seccomp:unconfined (control) |
✅ | ✅ | ✅ | ✗ † |
✅ flagged on the service Compose deploys · ◐ flagged on the base service,
not on the service that extends it · ✗ has a rule for it, did not flag it ·
— no rule for it. † Semgrep's rules require the version: key; with it
added, Semgrep flags the inline, include: and override settings, flags the
base for both extends: rows, flags the socket but not privileged behind
the anchor, and catches the long-syntax socket and seccomp:unconfined. ‡ KICS
also flags the port when .env sets it to 127.0.0.1, so the finding comes
from an address it cannot read rather than from the address Compose deploys.
DCLint is left off these tables because it has a rule for the port only. It
flagged a port set from a variable whether it resolved to 0.0.0.0 or
127.0.0.1, and it did not read a base or included file
(compose-lint vs DCLint).
The four scenarios that deploy safely, where ✅ means no finding:
| Looks dangerous, deploys safely | compose-lint | KICS | DockSec | Semgrep |
|---|---|---|---|---|
privileged: true, set back to false by compose.override.yml |
✅ | ✗ | ✗ | ✅ † |
Port bound to ${BIND:-127.0.0.1} |
✅ | ✅ | — | — |
no-new-privileges=true, the = spelling |
✅ | ✗ | ✗ | ✅ † |
Port, socket and privileged from a .env with safe values |
✅ | ✗ | ✅ | ✅ |
✅ no finding · ✗ reported a setting Compose does not deploy · — no rule for
it. † with the version: key added, Semgrep reports both.
What the run shows:
extends:is not merged. KICS, DockSec and Semgrep report the base service and give the service that extends it only their baseline findings, such as a missing healthcheck, so they read that service without merging the base into it. When the base is in another file, KICS and Semgrep report that file and DockSec, which reads the file it is given, reports nothing.- Other files are graded only if the tool happens to scan them. KICS
catches
include:because it scans the included file as a file of its own. It parsed one file in the override scenario:compose.override.ymlwas not picked up as a Compose file. DCLint read onlycompose.yamlin theextends:andinclude:scenarios. - No other tool reads
.env. KICS and DCLint flag a port whose address is a variable whether it resolves to0.0.0.0or127.0.0.1. - Spelling matters. Docker disables seccomp for
seccomp=unconfinedandseccomp:unconfinedalike (Seccomp: 0in/proc/self/statusfor both). KICS and DockSec catch only the:form, and both reportno-new-privileges=trueas missing. - Only compose-lint flags the socket arriving as a secret.
The run does not show that YAML anchors are a problem for the other tools:
KICS reports the anchored settings at the anchor's lines and DockSec on the
service. Compose profiles, several -f files and COMPOSE_FILE were not
tested.
Versions: compose-lint 0.36.0, KICS 2.1.20, DockSec image 2026.9.21, Semgrep 1.180.0, DCLint 3.1.0.
Does Checkov scan docker-compose.yml?¶
No. Checkov's Docker coverage is its Dockerfile policy
index: 28 policies
(CKV_DOCKER_1 to CKV_DOCKER_11 and CKV2_DOCKER_1 to CKV2_DOCKER_17),
all typed dockerfile. There is no docker_compose framework, and a
compose.yaml passed to checkov is not inspected as a Compose file. Guides
that show checkov --framework docker_compose describe a flag that does not
exist.
If you already run Checkov for Terraform or Kubernetes, keep running it and add compose-lint for the Compose files it skips. As of 2026-10-09 there is no open request for Compose support on Checkov's tracker.
Does Trivy lint Docker Compose files?¶
Not the Compose file itself. Trivy's misconfiguration
scanner handles
Dockerfile, Kubernetes, Terraform and Terraform plans, CloudFormation, Helm,
Azure ARM and Ansible. Docker Compose is not one of its configuration types. What Trivy does cover is the
images a Compose file references. compose-lint never pulls an image or
reports a CVE, so the two fit together: trivy image for what is inside the
image, compose-lint for how the Compose file runs it.
Compose support was requested in April 2025
(aquasecurity/trivy#8729).
A maintainer replied that it could be added but that "there are no checks for
it" to reuse. The most recent comment in the thread, from July 2026, is from
a user surprised that trivy config did not catch a port bound to all
interfaces. That is the Rule 5a case in the run above.
compose-lint vs DCLint¶
They do different jobs and are often run together. DCLint
(zavoloklom/docker-compose-linter)
validates a Compose file against the schema and applies fifteen
best-practice rules. Most are about ordering and formatting (alphabetical
services, key order, quoted ports) or structure (no version: field, no
duplicate container names or exported ports, a project name). Two overlap
with security: no-unbound-port-interfaces and
service-image-require-explicit-tag. In both runs it caught exactly those
two. On a stack spread across files
its port rule flagged an address set from a variable whether it resolved to
0.0.0.0 or 127.0.0.1, and it did not read a base file or an included
file. Its --fix applies the formatting rules.
compose-lint does not validate the schema. An invalid file is a usage error,
not a finding. Its 27 rules are all security rules: capabilities,
no-new-privileges, read_only, the Docker socket, host namespaces,
resource limits, credentials in environment: and command lines, digest
pinning. Each rule ships with the fix, the error message you will see if the
fix breaks something, and the OWASP or CIS citation behind it. Where both tools flag
the same thing, such as an image without a tag or a port bound to
0.0.0.0, compose-lint grades it as a security finding with a severity and
DCLint reports it as a lint error.
compose-lint vs KICS¶
KICS (docs.kics.io)
is the closest in coverage, with 21 Docker Compose queries: the Docker
socket mount, no-new-privileges, privileged containers, unrestricted
capabilities, the default seccomp profile, shared host namespaces, memory,
CPU and PID limits, and security_opt. In the run above it caught 10 of 16
on a file without version: and 11 with version: "3.9" added, because the
memory and CPU queries key off the version number and skip a Compose
Specification file. KICS is a broad IaC scanner and Compose is one of many
platforms it covers. Each query page describes the pattern and shows a
compliant and a non-compliant sample. The queries compose-lint lacks are
ones it retired or declined with the reason on the rule page: the PID limit
and the missing healthcheck
(ADR-028), the host user namespace and
cgroup_parent (CL-0010), the restart cap
(CL-0026) and privileged host ports
(CL-0005).
Three issues on KICS's tracker matter if you rely on it for Compose. The
maintainers' position, stated in 2023 on
#6509, is that the
version: key is needed to avoid false positives, and the limit queries
still test it: memory and CPU need version 3 or later, PID a version below 3.
No New Privileges Not Set reports a service that uses
the newer no-new-privileges=true spelling as missing it
(#7077, open since 2024;
compose-lint accepts both spellings). An anonymous volume is reported as
Volume Has Sensitive Host Directory
(#8047, open since May
2026).
On a stack spread across files, KICS
reported the base service rather than the one that extends it, did not pick
up compose.override.yml, did not resolve ${VAR:-default} or .env,
missed the socket written in the long volumes: syntax, and caught
seccomp:unconfined but not seccomp=unconfined, which Docker treats the
same.
compose-lint is Compose-only and puts its effort into the fix. Each rule page
explains what the setting does at runtime, which error messages the hardened
configuration produces when an image was not built for it, and how to tell a
real breakage from a false alarm. Its claims about runtime behaviour are
re-tested against live containers in CI. KICS has a remediate command, but
its documentation does not say whether Compose is one of the platforms it
rewrites. compose-lint's fix edits the findings of six rules in place after
a dry-run diff and lists the rest with the fix to make by hand.
compose-lint vs Semgrep's p/docker-compose ruleset¶
Semgrep's registry has six Compose security rules under
yaml/docker-compose/security:
the Docker socket as a volume, no-new-privileges, privileged services,
seccomp confinement disabled, SELinux separation disabled, and a writable
root filesystem. All six are WARNING severity and one
(privileged-service) has an autofix. All six match only a file that still
has the obsolete version: key; the run above has
the numbers and the upstream fix. The same reporter filed a second gap, that
an explicit user: root is not detected
(#4036, fix
PR #4048). Until those
merge, the ruleset is one line to enable if Semgrep already runs in your CI
and your files keep version:. If you have removed version:, as Compose
now recommends, the ruleset does nothing and reports nothing.
Two narrower gaps showed up on a
stack spread across files, both with
version: present. The socket rule did not fire on
/var/run/docker.sock:/sock, the socket mounted at another path inside the
container. The seccomp rule fired on a security_opt: list holding only
seccomp:unconfined, and not on one that also held apparmor:unconfined.
compose-lint covers those six and the rest of its 27 rules: capability tiers, resource limits, credentials in environment variables, connection strings and command-line arguments, digest pinning, sensitive host paths, mount propagation, logging disabled. Severities are set per rule from a stated model rather than one level for everything, and each finding carries fix guidance that a one-line Semgrep message has no room for.
compose-lint vs OWASP DockSec¶
DockSec (OWASP/DockSec) scans
Dockerfiles, images and, with --compose, Compose files, and uses an LLM to
explain and prioritise findings. Its 17 Compose rules are documented and
need only Trivy installed; the Docker CLI adds a scan of each service's
image and an AI provider key adds the explanation pass. In the run above
its Compose rules caught 11 of 16: the socket mount, privileged, host
network and namespaces, the sensitive host path, seccomp unconfined, the
plaintext secret, the :latest tag, missing no-new-privileges, writable
root and missing limits. It also reported two cross-service "exploit
chains", such as an internet-facing service sharing the default network with
a service that holds a committed credential, which no other tool here
attempts. It did not flag the 0.0.0.0 bind,
because its port rule only covers sensitive ports, the missing cap_drop,
logging: none, the missing digest, or an explicit user: root. Its
--fix applies mechanical Dockerfile changes only. Compose findings are
listed as "needs review".
compose-lint is deterministic. The same file produces the same findings,
severities and diff on every run, with no model and no network access, and
its auto-fix works on the Compose file. Which matters more depends on
whether the linter is a CI gate or an advisor. The explicit user: root gap
was not on DockSec's tracker as of 2026-10-09.
On a stack spread across files,
DockSec resolved YAML anchors but read only the file it was given: it did not
follow extends: into another file, include: or compose.override.yml. In
the same file it reported the base service rather than the one that extends
it. It caught seccomp:unconfined but not seccomp=unconfined, and reported
no-new-privileges=true as missing.
When a script of your own is enough¶
Search results and AI answers for these questions often suggest skipping the
tools and writing the check yourself: yq '.services[] | select(.privileged
== true)', a Conftest policy, a custom Semgrep or KICS rule. For a single
check in a pre-commit hook, such as "nothing in this repository may say
privileged: true", that is the right answer. No tool on this page does it
better.
What the script does not give you is the Compose semantics, and the runs
above show what those cost even for funded tools: Semgrep's rules are silent
on every modern file because they anchor on version:, KICS gates its limit
queries the same way, and KICS reports no-new-privileges=true as missing
over a : versus = difference. A script that grades what Compose actually
deploys has to:
- resolve
${VAR:-default}to the value Compose ships when no.envis present; - follow
extends:andinclude:across files and apply their merge order; - expand YAML anchors and
<<:merge keys; - read both the short and the long
ports:syntax; - treat
security_optas a list where the last entry naming a key wins, which is whatextends:produces when a child un-hardens its base; - notice that
secrets:withfile:is a bind mount of that host file under another key, so a socket can arrive that way too.
compose-lint runs each of those checks (how it works,
what a run reads), and most of them were added
because a real file slipped past a simpler version. The
run on a stack spread across files
shows the tools above missing several of them too: extends:, overrides,
.env, the long volume syntax and the socket as a secret. Compose changes over
time, and a script has to change with it.
Custom rules for the tools above inherit the tool's parser, which makes them a middle path, but only where the tool reads the file. A custom KICS query (Rego) or Semgrep rule can check anything in a Compose file the tool parses. A custom Checkov policy cannot help, because Checkov never loads the file.
Does Hadolint lint Docker Compose files?¶
No. Hadolint lints Dockerfiles and
does not read Compose files. A request for Compose support from 2025
(hadolint#1091) is
still open with no reply from the maintainers; the one answer, from another
user, was to parse the YAML with a script or yq. Nothing on
this page replaces it. A hardened Compose file that runs an image built from
an unhardened Dockerfile is only half the work, and the two tools do not
overlap.
What compose-lint does not do¶
- Validate the Compose schema. An invalid file is exit 2, not a finding. Pair it with DCLint.
- Scan images. It never pulls an image, inspects layers or reports CVEs. Pair it with Trivy or Docker Scout.
- Lint Dockerfiles. It reads
compose.yamlanddocker-compose.yml, neverDockerfile. Pair it with Hadolint. - Watch running containers. It is a static analyzer. See Security expectations for the full list.
The second run also shows five things the OWASP cheat sheet asks for that compose-lint has no rule for:
- A service with no
user:at all (Rule 2). The image'sUSERis not in the Compose file, so compose-lint cannot tell a root default from a non-root one and does not guess. It flagsuser: rootwhen written. DockSec takes the other approach and flags the absence. - Services on the shared default network (Rule 5). Met by construction:
Compose attaches a project to its own
<project>_defaultuser-defined bridge, so thedocker0bridge that CIS 5.30 asks not to use is avoided without anetworks:key, and whether two services of one project should reach each other is a design decision no rule grades (CL-0008). - No
pids_limit(Rule 7). CL-0012 flagged this and was retired. On a systemd host the limit is bounded byDefaultTasksMaxwhether or not the file sets it. restart: always(Rule 7). Docker already delays each restart by double the previous delay, and a retry cap turns a restart loop into an outage, so no rule asks for one (CL-0026).- A host directory mounted read-write that could be
:ro(Rule 8). CL-0013 and CL-0025 flag sensitive and root-equivalent host paths, where write access is what makes the path dangerous. Whether an ordinary path needs writing is a fact about the application that the Compose file does not carry, so an ordinary read-write bind mount is not a finding.
Reproduce it¶
The fixture is sixteen services with one deliberate misconfiguration each, named for the compose-lint rule it targets. Do not run it.
services:
socket: # CL-0001 Docker socket mounted
image: portainer/portainer-ce:2.21.4
volumes:
- /var/run/docker.sock:/var/run/docker.sock
privileged: # CL-0002 privileged: true
image: busybox:1.36.1
privileged: true
nnp: # CL-0003 no-new-privileges missing (nothing else wrong)
image: nginx:1.27.2-alpine
cap_drop: [ALL]
read_only: true
latest: # CL-0004 unpinned tag
image: redis:latest
ports: # CL-0005 published on 0.0.0.0
image: nginx:1.27.2-alpine
ports:
- "8080:80"
caps: # CL-0006 no cap_drop ALL
image: nginx:1.27.2-alpine
read_only: true
security_opt: [no-new-privileges:true]
rootfs: # CL-0007 writable root filesystem
image: nginx:1.27.2-alpine
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
hostnet: # CL-0008 network_mode: host
image: nginx:1.27.2-alpine
network_mode: host
seccomp: # CL-0009 seccomp unconfined
image: nginx:1.27.2-alpine
security_opt:
- seccomp:unconfined
pidhost: # CL-0010 host PID/IPC namespace
image: nginx:1.27.2-alpine
pid: host
ipc: host
hostpath: # CL-0013 sensitive host path
image: busybox:1.36.1
volumes:
- /etc:/host-etc
nolog: # CL-0014 logging driver none
image: nginx:1.27.2-alpine
logging:
driver: none
root: # CL-0018 user: root
image: nginx:1.27.2-alpine
user: root
nodigest: # CL-0019 tag without digest (otherwise pinned)
image: nginx:1.27.2-alpine
secret: # CL-0020 plaintext credential in environment
image: postgres:16.4
environment:
POSTGRES_PASSWORD: hunter2
nolimits: # CL-0026 no memory/cpu limits
image: nginx:1.27.2-alpine
Each tool ran from its published image with the directory holding the file
mounted read-only at /src:
docker run --rm -v "$PWD:/src:ro" composelint/compose-lint check --format json /src/compose.yaml
docker run --rm -v "$PWD:/src:ro" zavoloklom/dclint -f json /src
docker run --rm -v "$PWD:/src:ro" -v "$PWD/out:/out" checkmarx/kics scan -p /src -o /out --report-formats json -t DockerCompose
docker run --rm -v "$PWD:/src:ro" owasp/docksec:2026.9.21 --compose /src/compose.yaml --format json --scan-only
docker run --rm -v "$PWD:/src:ro" semgrep/semgrep semgrep --config p/docker-compose --json /src
docker run --rm -v "$PWD:/src:ro" bridgecrew/checkov -f /src/compose.yaml -o json
docker run --rm -v "$PWD:/src:ro" aquasec/trivy config /src -f json
docker run --rm -v "$PWD:/src:ro" hadolint/hadolint hadolint -f json /src/compose.yaml
compose-lint reports the hostpath service as CL-0025 (a writable
root-equivalent host mount) rather than CL-0013. The table counts that as
caught. Its 85 findings are the 16 intended ones plus the baseline hardening
misses on every service that lacks them, which is what the tool is for.
The OWASP-framed fixture has the same shape, one service per row of its
table, named rule<N>-<violation>, with the same images. It was run with
the same commands, once as written and once with version: "3.9" prepended.
The SELinux service (security_opt: [label:disable]) was run the same way
on 2026-10-14, with each image pinned to the digest the 2026-10-09 run had
recorded.
The stack spread across files¶
Each scenario is its own directory. Every image is nginx:1.27.2-alpine,
and none of the scenarios is meant to be started.
# inline/compose.yaml (control)
services:
app:
image: nginx:1.27.2-alpine
privileged: true
ports: ["8080:80"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
# extends-same-file/compose.yaml
services:
base:
image: nginx:1.27.2-alpine
privileged: true
ports: ["8080:80"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
app:
extends: base
# extends-other-file/common.yml holds the base service above;
# extends-other-file/compose.yaml
services:
app:
extends:
file: common.yml
service: base
# include/sub/stack.yml holds the inline app service;
# include/compose.yaml
include:
- sub/stack.yml
services:
web:
image: nginx:1.27.2-alpine
# override/compose.yaml
services:
app:
image: nginx:1.27.2-alpine
# override/compose.override.yml
services:
app:
privileged: true
ports: ["8080:80"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
# anchor/compose.yaml
x-danger: &danger
privileged: true
ports: ["8080:80"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
services:
app:
<<: *danger
image: nginx:1.27.2-alpine
# interpolation-default/compose.yaml
services:
app:
image: nginx:1.27.2-alpine
privileged: ${PRIV:-true}
ports: ["${BIND:-0.0.0.0}:8080:80"]
volumes: ["${SOCK:-/var/run/docker.sock}:/sock"]
# dotenv/compose.yaml, with dotenv/.env holding
# PRIV=true, BIND=0.0.0.0 and SOCK=/var/run/docker.sock
services:
app:
image: nginx:1.27.2-alpine
privileged: ${PRIV}
ports: ["${BIND}:8080:80"]
volumes: ["${SOCK}:/sock"]
# long-syntax/compose.yaml
services:
app:
image: nginx:1.27.2-alpine
ports:
- target: 80
published: "8080"
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
# secret-file/compose.yaml
services:
app:
image: nginx:1.27.2-alpine
secrets: [sock]
secrets:
sock:
file: /var/run/docker.sock
# seccomp-equals/compose.yaml; seccomp-colon is the same with seccomp:unconfined
services:
app:
image: nginx:1.27.2-alpine
security_opt: ["seccomp=unconfined"]
# socket-elsewhere/compose.yaml (the Semgrep socket-rule probe)
services:
app:
image: nginx:1.27.2-alpine
volumes: ["/var/run/docker.sock:/sock"]
The four that deploy safely:
# safe-override/compose.yaml
services:
app:
image: nginx:1.27.2-alpine
privileged: true
# safe-override/compose.override.yml
services:
app:
privileged: false
# safe-default/compose.yaml
services:
app:
image: nginx:1.27.2-alpine
ports: ["${BIND:-127.0.0.1}:8080:80"]
# safe-nnp-equals/compose.yaml
services:
app:
image: nginx:1.27.2-alpine
cap_drop: [ALL]
read_only: true
security_opt: ["no-new-privileges=true"]
# safe-dotenv/ is dotenv/ with .env holding
# PRIV=false, BIND=127.0.0.1 and SOCK=/srv/data
Each scenario directory was mounted read-only at /src. compose-lint runs
with /src as its working directory, so the scenario directory is the
boundary it reads within:
docker run --rm -w /src -v "$PWD:/src:ro" composelint/compose-lint:0.36.1@sha256:b6742a7ff80a1a75e824a8aa1c6579946fdd48c771f7085a046a18f95c3fe19d check --format json /src/compose.yaml
docker run --rm -v "$PWD:/src:ro" -v "$PWD/out:/out" checkmarx/kics:v2.1.20@sha256:3e5a268eb8adda2e5a483c9359ddfc4cd520ab856a7076dc0b1d8784a37e2602 scan -p /src -o /out --report-formats json -t DockerCompose --disable-secrets
docker run --rm -v "$PWD:/src:ro" -v "$PWD/out:/root/.docksec/results" owasp/docksec:2026.9.21@sha256:646e8250e2dc98ac4c8fd5f9a5d37f9aecae149a468271b5b1dc04092f927be4 --compose /src/compose.yaml --format json --scan-only
docker run --rm -v "$PWD:/src:ro" semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3 semgrep --metrics=off --config p/docker-compose --json /src
docker run --rm -v "$PWD:/src:ro" zavoloklom/dclint:3.1.0@sha256:dd6e0faa9fa8856a643a1e55476077d555b8e9c946cc481f8fbee203662c3e77 -f json -r /src
docker compose config in each directory is the ground truth for what
deploys. A finding counts for a row only when it comes from that tool's own
rule for the setting.
Corrections¶
The counts in the summary table come from the linked documentation as read on 2026-10-08. The runs are from 2026-10-09 and 2026-10-17 with the versions listed above. Tools change. If a row is out of date, open an issue with the link or the output that shows it and the row will be corrected.
Known changes that would move these results when they land:
- Semgrep's column goes from 0 to 6 of 21 when
semgrep-rules#4037
merges, and gains
user: rootwith #4048. The same fix moves its column in the run on a stack spread across files to theversion:results in the † notes. - KICS stops reporting the
no-new-privileges=truespelling as missing when #7077 is fixed. - Trivy gets a column if trivy#8729 is taken up.
- DockSec moves to 14 of 21 if it starts flagging an explicit
user: root.