Static-analysis linter for Docker Compose files. Finds insecure container configuration with OWASP/CIS-grounded rules; emits SARIF for GitHub Code Scanning.
Project description
compose-lint
Static-analysis linter for docker-compose.yml and compose.yaml that catches dangerous misconfigurations before they reach production — privileged containers, unpinned images, host-network sharing, sensitive bind mounts, hard-coded credentials, and more.
In a scan of 6,444 public Docker Compose files on GitHub, 91% of those that parse had at least one security finding (68% HIGH or CRITICAL) — nearly all skip basic capability restrictions, 52% run images without a pinned digest, and 58% bind ports to all interfaces. compose-lint catches these in CI before they ship. Read the full State of Docker Compose Security report →
What it catches:
- Privilege flaws —
privileged: true, missingcap_drop,no-new-privilegesnot set, root user, host namespace sharing - Network exposure — wildcard port binds,
network_mode: host - Supply-chain — unpinned images, missing digest pins
- Filesystem and credential leaks — Docker socket mounts, sensitive host paths, plaintext credentials in
environment:
Use it if you ship Compose to production, want defense in depth in a homelab, or want a fast pre-merge gate on infrastructure-as-code. Fits the same niche as Hadolint, the Dockerfile linter and dclint, the Compose schema linter: zero-config, opinionated, fast, and grounded in the OWASP Docker Security Cheat Sheet and CIS Docker Benchmark.
Installation
pip
pip install compose-lint
Docker — composelint/compose-lint
docker run --rm -v "$(pwd):/src" composelint/compose-lint
The Docker image is distroless, multi-arch, and runs nonroot — see Security posture below for SLSA, Sigstore, and OpenVEX details.
Running with full hardening
The image is safe-by-default (distroless, nonroot, read-only attack surface) so the simple form above is fine for most use. If you want to dogfood compose-lint's own rules against the container that runs it, the fully-hardened invocation is:
docker run --rm \
--read-only \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--network none \
--user 65532:65532 \
--pids-limit 256 \
-v "$(pwd):/src:ro" \
composelint/compose-lint:0.7.0
| Flag | Rule satisfied |
|---|---|
--security-opt no-new-privileges:true |
CL-0003 |
--cap-drop ALL |
CL-0006 |
--read-only |
CL-0007 |
--pids-limit 256 |
CL-0012 (defense-in-depth; rule fires only on 0/-1) |
--user 65532:65532 |
CL-0018 (matches the image's existing default) |
--network none and :ro on the bind mount are extra hardening — compose-lint never reaches the network and only reads its inputs.
For full supply-chain reproducibility (and to satisfy CL-0004 / CL-0019), replace the :0.7.0 tag with a digest pin: composelint/compose-lint@sha256:<digest>. Get the current digest from Docker Hub or with docker buildx imagetools inspect composelint/compose-lint:0.7.0 --format '{{json .Manifest}}' | jq -r '.digest'.
A Compose-form equivalent that lints clean across every rule lives in tests/compose_files/safe_self_hosted.yml.
Quick Start
Run without arguments to auto-detect compose.yml, compose.yaml, docker-compose.yml, or docker-compose.yaml in the current directory:
compose-lint
Or pass files explicitly:
compose-lint docker-compose.yml docker-compose.prod.yml
Don't recognize a rule ID in the output? --explain prints the full rule doc — what it catches, why it matters, the fix, and the OWASP/CIS reference — without leaving the terminal:
compose-lint --explain CL-0005
Docker equivalent:
docker run --rm -v "$(pwd):/src" composelint/compose-lint docker-compose.prod.yml
Compose compatibility
compose-lint targets the Compose Specification used by Compose v2 and v3. Compose v1 files (services declared at the top level) are skipped with a stderr note rather than failing the run — Docker retired Compose v1 in 2023. Structural fragments (files containing only volumes: / networks: / configs: / secrets: / x-* keys, typically merged via -f overlay.yml) are skipped for the same reason. Genuinely unrecognised shapes still exit 2.
Python 3.10+ is required for the pip install path; the Docker image is self-contained.
Example Output
Given this docker-compose.yml:
services:
traefik:
image: traefik:v3.0@sha256:aaaabbbbccccddddeeeeffff00001111222233334444555566667777888899990
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "8080:80"
and this .compose-lint.yml (suppressing CL-0001 for traefik with a tracked reason):
rules:
CL-0001:
exclude_services:
traefik: "SEC-1234 approved — socket proxy planned for 2026-Q3"
running compose-lint docker-compose.yml produces:
files: docker-compose.yml · config: .compose-lint.yml · fail-on: high
docker-compose.yml
service: traefik (line 9)
line severity rule message
9 SUPPRESSED CL-0001 Docker socket mounted via '/var/run/docker.sock:/var/run/docker.sock'. This gives the container full control over the Docker daemon.
reason: SEC-1234 approved — socket proxy planned for 2026-Q3
9 HIGH CL-0013 Service mounts sensitive host path '/var/run/docker.sock' (under /var/run). This exposes host system files to the container.
9 │ - /var/run/docker.sock:/var/run/docker.sock
│ ────────────────────
fix: Remove the bind mount for /var/run/docker.sock. If the container needs specific files, copy them into the image at build time or use a named volume with only the required data.
ref: https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-8---set-filesystem-and-volumes-to-read-only
11 HIGH CL-0005 Port '8080:80' is bound to all interfaces. Docker bypasses host firewalls (UFW/firewalld), potentially exposing this port to the public internet.
11 │ - "8080:80"
│ ───────
fix: Bind to localhost: 127.0.0.1:8080:80
If public access is needed, use a reverse proxy with TLS.
ref: https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html#rule-5a---be-careful-when-mapping-container-ports-to-the-host-with-firewalls-like-ufw
docker-compose.yml: 2 high · 1 suppressed (not counted)
✗ FAIL · 2 findings at or above high
Exit code is 1 (two findings at or above the default --fail-on high threshold). Suppressed findings are shown for auditability but do not count toward the threshold. Findings are grouped by service and ordered highest-severity first within each service; the fix block and reference URL print only once per rule id per file — pass -v / --verbose to repeat them on every finding, or -q / --quiet for one compact line per finding.
Rules
| ID | Severity | Description | OWASP | CIS |
|---|---|---|---|---|
| CL-0001 | CRITICAL | Docker socket mounted | Rule #1 | 5.31 |
| CL-0002 | CRITICAL | Privileged mode enabled | Rule #3 | 5.4 |
| CL-0003 | MEDIUM | Privilege escalation not blocked | Rule #4 | 5.25 |
| CL-0004 | MEDIUM | Image not pinned to version | Rule #13 | 5.27 |
| CL-0005 | HIGH | Ports bound to all interfaces | Rule #5a | 5.13 |
| CL-0006 | MEDIUM | No capability restrictions | Rule #3 | 5.3 |
| CL-0007 | MEDIUM | Filesystem not read-only | Rule #8 | 5.12 |
| CL-0008 | HIGH | Host network mode | Rule #5 | 5.9 |
| CL-0009 | HIGH | Security profile disabled | Rule #6 | 5.21 |
| CL-0010 | HIGH | Host namespace sharing | Rule #3 | 5.8, 5.15, 5.16, 5.21 |
| CL-0011 | HIGH | Dangerous capabilities added | Rule #3 | 5.5 |
| CL-0012 | MEDIUM | PIDs cgroup limit disabled | — | 5.29 |
| CL-0013 | HIGH | Sensitive host path mounted | Rule #8 | 5.5 |
| CL-0014 | MEDIUM | Logging driver disabled | — | 5.x |
| CL-0015 | LOW | Healthcheck disabled | — | 4.6, 5.27 |
| CL-0016 | HIGH | Dangerous host device exposed | — | 5.18 |
| CL-0017 | MEDIUM | Shared mount propagation | — | 5.20 |
| CL-0018 | MEDIUM | Explicit root user | Rule #7 | 5.x |
| CL-0019 | MEDIUM | Image tag without digest | Rule #13 | 5.27 |
| CL-0020 | HIGH | Credential-shaped env key with literal value | Rule #11 | 5.x |
| CL-0021 | HIGH | Credential embedded in connection-string env value | Rule #11 | 5.x |
Severity Levels
Findings are rated LOW, MEDIUM, HIGH, or CRITICAL based on exploitability and impact scope. See docs/severity.md for the full scoring matrix.
Configuration
Create .compose-lint.yml to disable rules, exclude specific services, or adjust severity:
rules:
CL-0001:
enabled: false
reason: "SEC-1234 — approved 2026-07-01"
CL-0003:
exclude_services:
minecraft: "entrypoint switches users via su-exec"
CL-0005:
severity: medium
Disabled and excluded findings still appear marked SUPPRESSED with the reason flowing to JSON's suppression_reason and SARIF's justification (recognized by GitHub Code Scanning) — they do not affect exit code. Pass --skip-suppressed to hide them.
See docs/configuration.md for per-service exclusion semantics, precedence rules, and the full output-format mapping.
CLI Reference
compose-lint [OPTIONS] [FILE ...]
--format {text,json,sarif} Output format (default: text)
--fail-on {low,medium,high,critical}
Minimum severity to trigger exit 1 (default: high)
-v, --verbose Repeat the fix block and reference on every finding (text mode)
-q, --quiet One line per finding — no fix, reference, or excerpt (text mode)
--skip-suppressed Hide suppressed findings from output
--config PATH Path to config file (default: .compose-lint.yml)
--explain CL-XXXX Print the full documentation for a single rule
--version Show version and exit
Color is on when stdout is a terminal. Set NO_COLOR to disable it (even on a
terminal) or FORCE_COLOR to force it through a pipe — e.g. into less -R or a
CI log that renders ANSI.
Exit Codes
| Code | Meaning |
|---|---|
| 0 | No findings at or above the --fail-on threshold |
| 1 | One or more findings at or above the --fail-on threshold |
| 2 | Usage error (invalid args, file not found, invalid Compose file) |
The default threshold is high — medium and low findings don't fail CI unless you opt in:
compose-lint --fail-on low docker-compose.yml # fail on everything
compose-lint --fail-on critical docker-compose.yml # only critical
CI Integration
GitHub Actions
The easiest path — runs compose-lint and uploads findings to GitHub Code Scanning. Pinned to immutable SHAs for reproducible CI; Renovate keeps the pins current:
# .github/workflows/lint.yml
name: Compose Lint
on: [push, pull_request]
jobs:
compose-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: tmatens/compose-lint@23da963b2ee586592eb1afe34b5a3b620e52966d # v0.7.0
with:
sarif-file: results.sarif
Or install from PyPI directly:
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- run: pip install compose-lint
- run: compose-lint docker-compose.yml
Forgejo Actions
Forgejo Actions runs GitHub-Actions-compatible workflows via act_runner, with two practical differences: cross-instance action refs need full URLs (https://code.forgejo.org/...), and most default runner configs don't support container: jobs — so install via apt + pip rather than a Python base image:
# .forgejo/workflows/validate.yml
name: Validate
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
compose-lint:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Install compose-lint
run: |
apt-get update -qq
apt-get install -yqq --no-install-recommends python3-pip
pip3 install --break-system-packages --no-cache-dir compose-lint==0.7.0
- name: Run compose-lint
run: compose-lint --fail-on high
Forgejo has no SARIF UI today — --format sarif still produces a valid document, but there's no security-tab equivalent to render it. Verified on Forgejo 11.0.12, April 2026.
SARIF output
compose-lint --format sarif docker-compose.yml > results.sarif
Pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/tmatens/compose-lint
rev: v0.7.0
hooks:
- id: compose-lint
How it compares
| Tool | Compose security rules | Scope | Zero config |
|---|---|---|---|
| compose-lint | Yes | Docker Compose | Yes |
| KICS | Yes | Broad IaC (Terraform, K8s, Compose, ...) | No |
| Hadolint | No — Dockerfile only | Dockerfile | Yes |
| dclint | Yes — schema/structure only | Docker Compose | Yes |
| Trivy | No — Dockerfile + image scanning | Dockerfiles, images, repos | Yes |
| Checkov | No — no Compose support | Broad IaC (Terraform, K8s, ...) | No |
If you need broad IaC coverage across Terraform, Kubernetes, and more, KICS covers Docker Compose and is worth evaluating. If you want a lightweight, focused tool with zero config and actionable fix guidance for Compose files specifically, this is it.
Not in scope: compose-lint does not validate Compose schema, scan images for CVEs, lint Dockerfiles, or rewrite files. Pair it with dclint for schema/structure, Hadolint for Dockerfiles, and Trivy for image CVEs.
Security posture
compose-lint is built to be safe to depend on:
- Runtime image: distroless Python on Debian, multi-arch (
linux/amd64+linux/arm64), nonroot UID 65532, no shell or package manager at runtime. See ADR-009. - Supply chain: every release ships SLSA build provenance and Sigstore attestations. Published to PyPI via Trusted Publishers (OIDC) — no manual
twine upload, no long-lived API tokens. - Vulnerability transparency: each release ships an OpenVEX document declaring known pip CVEs
not_affectedwith justificationvulnerable_code_not_present— pip code is stripped from the runtime venv and only.dist-infometadata is retained for SCA scanner attribution. - External audit: tracked on OpenSSF Scorecard and OpenSSF Best Practices Baseline 2; CodeQL, Docker Scout, and ClusterFuzzLite run on every PR.
- Reporting vulnerabilities: see SECURITY.md.
Contributing
See CONTRIBUTING.md for development setup and how to add rules.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file compose_lint-0.8.0.tar.gz.
File metadata
- Download URL: compose_lint-0.8.0.tar.gz
- Upload date:
- Size: 1.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e786253cc0eb1706a352305426eab4ad1e439214e48a3f3c287c6a52716d0851
|
|
| MD5 |
d718db64697f799a829e0bb9c717fd49
|
|
| BLAKE2b-256 |
1c42bcbb0316b992cf24addb8b75c7d9f3d0c752010afad5cc667fa8b8f9b75e
|
Provenance
The following attestation bundles were made for compose_lint-0.8.0.tar.gz:
Publisher:
publish.yml on tmatens/compose-lint
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
compose_lint-0.8.0.tar.gz -
Subject digest:
e786253cc0eb1706a352305426eab4ad1e439214e48a3f3c287c6a52716d0851 - Sigstore transparency entry: 1615075276
- Sigstore integration time:
-
Permalink:
tmatens/compose-lint@94c3389d42a8a8e5239df90010f0921c84956cb6 -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/tmatens
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@94c3389d42a8a8e5239df90010f0921c84956cb6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file compose_lint-0.8.0-py3-none-any.whl.
File metadata
- Download URL: compose_lint-0.8.0-py3-none-any.whl
- Upload date:
- Size: 96.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c6a60d8e16573f53d0a6ae89de0bd3ec481e0b27983cac654d41494041f06855
|
|
| MD5 |
fb06e1da097d6737137d4cdf95440a6a
|
|
| BLAKE2b-256 |
4657632248c5603cada31111594109463d49c6877ae39a6c7c069cb15686337b
|
Provenance
The following attestation bundles were made for compose_lint-0.8.0-py3-none-any.whl:
Publisher:
publish.yml on tmatens/compose-lint
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
compose_lint-0.8.0-py3-none-any.whl -
Subject digest:
c6a60d8e16573f53d0a6ae89de0bd3ec481e0b27983cac654d41494041f06855 - Sigstore transparency entry: 1615075281
- Sigstore integration time:
-
Permalink:
tmatens/compose-lint@94c3389d42a8a8e5239df90010f0921c84956cb6 -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/tmatens
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@94c3389d42a8a8e5239df90010f0921c84956cb6 -
Trigger Event:
push
-
Statement type: