compose-harden
Scan any docker-compose.yml for container-hardening gaps, get a score,
then auto-fix what's safe to auto-fix — with a document explaining every
change, and every issue it noticed but refused to guess at.
$ compose-harden scan docker-compose.yml
Service: web
✖ cap_drop: ALL -- no `cap_drop: [ALL]`
✖ no-new-privileges -- missing `no-new-privileges` in security_opt
✖ read_only root filesystem -- root filesystem is writable
✔ not running privileged
✔ image version pinned
✖ non-root user set -- no non-root `user:` configured
✔ not using host network
✔ seccomp not disabled
Score: 50/100 (8/16 checks passed)
$ compose-harden fix docker-compose.yml --dry-run
depends_on:
- db
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges:true
+ read_only: true
+ tmpfs:
+ - /tmp
...plus an AUDIT.md that explains why each line was added, in plain
language, service by service.
Don't want to run it just to see what it does? Real, generated examples are checked into this repo:
| Input | Output |
|---|---|
examples/docker-compose.yml |
scan-output.txt · scan-output.json · scan-output.sarif |
| same file | docker-compose.hardened.yml · AUDIT.md |
Why this exists
There are great tools for scanning a Docker setup for problems
(docker-bench-security, Trivy, CrowdSec, Docker's own Hardened Images at
the base-image level). There's nothing that closes the loop end to end:
- read an existing, real-world
docker-compose.yml - score it against known hardening controls, in a format a CI pipeline can gate on
- apply the ones that are safe to auto-fix, without breaking the file's comments or formatting
- explain, in a document a non-security person can read, what changed and why — and just as importantly, what it refused to touch automatically and why
compose-harden does exactly that.
What it checks
| Check | Auto-fixed by fix? |
What it means |
|---|---|---|
cap_drop: [ALL] |
Yes | Drops every Linux capability, shrinking what a compromised process can do |
security_opt: [no-new-privileges:true] |
Yes | Blocks privilege escalation via setuid binaries |
read_only: true |
Yes | Root filesystem can't be written to at runtime |
tmpfs scratch space |
Yes (when read_only is active) |
Keeps the app working under read_only |
Not privileged: true |
No — flagged only | Privileged mode disables nearly all isolation at once |
| Image version pinned | No — flagged only | :latest/no tag means the image can silently change under you |
Non-root user: set |
No — flagged only | The correct UID is image-specific; a wrong guess breaks startup |
Not network_mode: host |
No — flagged only | Host networking removes the container's network namespace entirely |
seccomp not unconfined |
No — flagged only | Only fires if seccomp was explicitly turned off; Docker's default profile is fine and isn't flagged |
Full rationale for each is in compose_harden/rules.py
and gets reproduced in the generated AUDIT.md for the exact services it
touches. The four auto-fixable ones need no service-specific knowledge to
apply safely; the rest depend on things only the operator knows (why
privileged mode is on, what UID an image expects), so they're surfaced,
never guessed at.
Deliberately not in scope: healthchecks, restart policies, resource limits. Those are operability/reliability concerns, not attack-surface ones — folding them into a "security score" would make the score mean two different things at once.
CI/CD integration
scan is built to gate a pipeline:
compose-harden scan docker-compose.yml --min-score 80 # exits 1 if score < 80
compose-harden scan docker-compose.yml --json # machine-readable
compose-harden scan docker-compose.yml --sarif out.sarif # for GitHub Code Scanning
A ready-to-copy workflow that scans on every PR and uploads findings to
GitHub's Security tab is at
examples/github-actions/compose-harden.yml.
Design principle: never re-serialize your YAML
Most tools that "fix" a YAML file parse it into a data structure, edit
the structure, and dump it back out — which silently strips comments,
reorders keys, and changes quoting style. compose-harden fix never does
this. It reads your file as plain text, decides what to change using a
read-only parse, and then makes surgical line insertions at the
correct indentation. Everything you didn't ask it to touch comes out
byte-for-byte identical.
Install
pip install compose-harden # once published
# or, from source:
git clone https://github.com/joshua-michael/compose-harden
cd compose-harden
pip install -e .
Usage
# Read-only check + score, exit 1 if not fully clean -- safe to run anywhere
compose-harden scan docker-compose.yml
# Same, but gate CI at a lower bar while you migrate an existing project
compose-harden scan docker-compose.yml --min-score 80
# Show what fix would change + full audit report, write nothing
compose-harden fix docker-compose.yml --dry-run
# Write docker-compose.hardened.yml + AUDIT.md next to it
compose-harden fix docker-compose.yml
# Overwrite in place (keeps a .bak backup automatically)
compose-harden fix docker-compose.yml --in-place
# Skip a check/rule you don't want applied or scored
compose-harden scan docker-compose.yml --skip host_network
compose-harden fix docker-compose.yml --skip read_only --skip tmpfs
# Add extra writable paths some images need alongside read_only
compose-harden fix docker-compose.yml --extra-tmpfs /var/cache/nginx
# Custom output locations
compose-harden fix docker-compose.yml -o hardened.yml --audit-output SECURITY-CHANGES.md
Run compose-harden scan --help or compose-harden fix --help for the full flag list.
How it decides what's safe to touch
- If a control is already present and correct → skipped, marked "already present" in the audit (the tool is idempotent — running it twice is a no-op the second time).
- If a control is present but customized (e.g. you already listed
specific capabilities in
cap_dropinstead ofALL) → left untouched and flagged as a conflict for manual review, instead of overwriting your deliberate choice. - If a control is absent → added, using the same indentation style as the rest of your file.
tmpfsis only added whenread_onlyis actually active for that service — there's no point adding writable scratch space you don't need.
Limitations
fixonly editscap_drop,security_opt(no-new-privileges),read_only, andtmpfs. It does not touch networking, port bindings, secrets, or resource limits — that's a deliberate scope boundary, not an oversight.- Assumes conventional 2-space YAML indentation and a top-level
services:map (the vast majority of real-world compose files). YAML anchors/aliases andextends:are not specially handled yet. - This is a fast first pass, not a substitute for an actual security review or for Docker's own Hardened Images at the base-image layer — use both.
Development
pip install -e ".[dev]"
pytest
See CONTRIBUTING.md for how the rule/check engine is structured before adding a new one.
License
MIT — see LICENSE.
Release files for compose-harden 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| compose_harden-0.2.0.tar.gz | 23.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| compose_harden-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 45.1 kB
Release files / compose_harden-0.2.0.tar.gz
| Download URL | compose_harden-0.2.0.tar.gz |
|---|---|
| Size | 23.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
163481a68f91f4f24f49223249ea2e4d43eb9d7cd4522a7b8bc3c6da82b08090
|
|
BLAKE2b-256 checksum How to use checksums |
9514c4e71255800d214a04b2ca46882582d6390e0e68531347ba63a8e6183516
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 10, 2026.
Transparency logRelease files / compose_harden-0.2.0-py3-none-any.whl
| Download URL | compose_harden-0.2.0-py3-none-any.whl |
|---|---|
| Size | 21.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6ce85bb24f2c44dc4bc50791d78b57935b7f231c0b688345de10d611ab073815
|
|
BLAKE2b-256 checksum How to use checksums |
b7a74b47261fcd03dfca7d0593944a1eca992324838cc8ce47ec14c762062b14
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 10, 2026.
Transparency log