Contributing to compose-lint
August 11, 2026 · View on GitHub
Thanks for wanting to help. This is a small, focused project, so the bar for contributions is clarity and authoritative grounding rather than breadth.
Before you start
- Bug reports and feature requests: open an issue using one of the templates.
- Security vulnerabilities: do not open a public issue. See SECURITY.md.
- New rule proposals: use the "Rule proposal" issue template. Every rule must be grounded in an authoritative source (OWASP Docker Security Cheat Sheet, CIS Docker Benchmark, or Docker official documentation). Opinion-only rules are not accepted.
- Larger changes: open an issue to discuss first so you don't sink time into a change that doesn't fit the project's scope.
See AGENTS.md for the full design philosophy — especially the sections on rule grounding, severity assignment, and what's explicitly out of scope.
Maintainers
- Todd Matens (@tmatens) — repository admin, releases, security response.
Maintainers review and merge PRs, triage issues within 14 days, respond to security reports within 7 days per SECURITY.md, and cut releases per docs/RELEASING.md.
Development setup
git clone https://github.com/tmatens/compose-lint.git
cd compose-lint
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
git config core.hooksPath .githooks
The last command activates the repo's git hooks. The pre-push hook blocks unsigned commits — see commit signing for setup.
Local quality checks
All four must pass locally before you push. CI runs the same commands.
ruff check src/ tests/ # Linting
ruff format --check src/ tests/ # Formatting
mypy src/ # Type checking (strict mode)
pytest # Tests
Statement coverage must stay >= 80% on main. The CI coverage job
enforces this; check locally before pushing a change that touches a lot
of code:
pytest --cov=compose_lint --cov-report=term-missing --cov-fail-under=80
Code standards
- Python 3.10+ required. Don't use syntax or stdlib features added after
3.10 (e.g., no
typealiases from 3.12, noExceptionGroupfrom 3.11 without checking availability). - Type annotations on all public functions (
mypy --strictis enforced). - PyYAML is the only runtime dependency. Do not add others without
discussion. Dev tooling goes in the
[dev]extras. - Rules receive plain Python types (
dict,list,str,int,bool). Never leak parser-specific types into rule code. - Latest stable versions for any new dependency unless there's a specific, documented reason otherwise.
Adding a new rule
- Create
src/compose_lint/rules/CL{NNNN}_{snake_name}.py - Inherit from
BaseRule, use the@register_ruledecorator - Set
id,name,severity,description,references— references must cite OWASP, CIS, or Docker official docs - Implement
check(service_name, service_config, global_config, lines)yieldingFindingobjects - Add test file
tests/test_CL{NNNN}.pywith both positive (triggers) and negative (clean) cases. Negative cases must include at least one hardened-but-unusual configuration the rule must not flag (e.g. the short-form security_opt, CMD-SHELL healthcheck, named-volume mount, digest-pinned image without a tag — whichever pattern is adjacent to the rule's trigger and easy to misread). These can live in the rule's mixed fixture or in a dedicatedtests/compose_files/safe_*.yml. - Add fixture YAML files in
tests/compose_files/ - Add rule documentation in
docs/rules/CL-{NNNN}.md - Fix guidance must be specific and actionable — show the exact YAML change
- Include a direct link to the supporting OWASP/CIS/Docker docs section
- If the rule describes container runtime state, add a premise check to
scripts/validate_rule_premises.pyproving the insecure state is Docker's default (absence rules) or that the flagged config produces the insecure behavior (presence rules). It runs in CI (rule-premises) and guards against flagging a Docker default — the CL-0022/CL-0023 failure mode - Derive the severity, don't pick it. Work through the procedure in docs/severity.md and write the derivation block on the rule page — baseline, precondition, impact, qualifier, derived, shipped, scoping assumptions, and an Evidence line naming the premise check or captured observation that backs the impact claim. Write the fields before looking at the number you wanted; if the result disagrees with instinct, fix an axis definition or file an override, never try a different cell
- Add the row to
docs/severity.md's assignment table. The page and the table must state the same derivation —tests/test_severity_matrix.pyfails if they drift, if the cell does not produce the printed severity, or if a shipped severity differs from the derived one without a declared override and a link - List the rule on every surface: the README table,
docs/index.md, and the mkdocs nav.tests/test_rule_surfaces.pychecks all of them - Map it to ATT&CK in
src/compose_lint/attack.py, or record why no technique fits — an unmapped rule needs an entry in the test'sUNMAPPED_BY_DESIGNallow-list with its reason
Rule requirements
- Grounded in an authoritative source that demonstrates the need in a
container context (CIS Docker, OWASP Docker Cheat Sheet, Docker docs) — not
generic host/Linux hardening a container's defaults already neutralize. If
container-context grounding is thin, validate the premise at runtime
(
scripts/validate_rule_premises.py). No opinion-only rules. - Every finding must be actionable. If you can't tell the user exactly what to change, the finding isn't ready.
- Severity is derived, not chosen. It is the value the two-axis matrix produces for the rule's cell, under a stated attacker baseline and the grounded Docker posture. Shipping a different number is legal only as a declared override from the closed reason list, with a link. See docs/severity.md — and note that "I evaluated cells until one gave the number I expected" is the documented failure mode the model exists to prevent.
- Rule IDs are permanent from 1.0. Never reuse or retire a
CL-XXXXID once 1.0 ships; pre-1.0, a mis-grounded rule may be removed and its ID reclaimed.
Commit conventions
- One logical change per commit. Rules, features, and refactors each get their own commit. Don't bundle unrelated changes.
- Imperative subject line, under 72 characters. "Add CL-0011 rule for X", not "Added CL-0011" or "CL-0011".
- Explain the why in the body, not just the what. The diff already shows what changed; the commit message exists to explain the reason.
- Sign your commits. See commit signing below.
- Sign off your commits. Use
git commit -sto add theSigned-off-by:trailer required by the DCO — this is separate from cryptographic signing. - No AI attribution. Do not include
Co-Authored-Bytrailers or any other references to AI/coding assistants in commit messages, code, or documentation.
We do not use Conventional Commits
prefixes (feat:, fix:, etc.). Descriptive imperative subjects are preferred
because they read naturally in git log without tooling.
Commit signing
All commits to main must be signed so GitHub shows the "Verified" badge.
Unsigned commits can be spoofed — anyone can set user.email to yours and
open a PR from a fork that attributes to you.
SSH signing is the easiest setup because it uses the same key you already push with:
git config --global gpg.format ssh
git config --global user.signingkey "key::$(cat ~/.ssh/id_ed25519.pub)"
git config --global commit.gpgsign true
git config --global tag.gpgsign true
Then add the same public key to GitHub as a Signing Key (separate list from authentication keys) at https://github.com/settings/ssh/new.
Verify locally with git log --show-signature. If it prints
Good "git" signature, you're set. On GitHub, your commits will show a green
Verified badge.
Developer Certificate of Origin
All commits must carry a Signed-off-by: trailer certifying that you wrote
the change (or have the right to submit it under this project's MIT license).
This is the Developer Certificate of Origin.
It is independent of commit signing above: cryptographic
signing proves who committed, DCO asserts right to contribute.
Add the trailer automatically with -s:
git commit -s -m "Your change"
Or enable it once per-clone so every commit gets signed off:
git config format.signOff true
The Signed-off-by name and email must match your commit author identity. CI
will block the PR if any commit is missing a matching trailer. Fix existing
commits with git commit --amend --signoff or git rebase --signoff main.
Pull requests
All changes to main go through a PR — including maintainer changes.
External contributors: you won't have push access to this repository.
Fork it, create your branch on
the fork, and open the PR from that branch back to main here. Everything
below applies the same way; the DCO and commit-signing checks run on fork PRs
too, so set those up before your first commit.
- Create a branch from
main. Name it descriptively:docs/contributor-workflow,rules/CL-0011-user-namespaces,fix/parser-merge-keys. - Make small, focused commits (see commit conventions).
- Run local checks. All four must pass before you push.
- Open a PR and fill out the template. Link any related issue.
- Wait for CI — all required checks must be green before merge.
- Respond to review comments. All comments must be resolved before merge.
- Squash-merge when approved. We use squash-merge exclusively so
mainstays linear with one commit per logical change. The full PR history is preserved on the PR page for context.
PR expectations
- Keep PRs small. Easier to review, easier to revert, easier to bisect. A PR that touches 5 files is almost always better than one that touches 30.
- Don't mix refactors with behavior changes. Land the refactor first, then the behavior change, in separate PRs.
- Update tests. New rules need positive and negative tests. Bug fixes need a regression test.
- Update documentation if you change behavior. Rule changes need
docs/rules/CL-XXXX.md; CLI changes needREADME.md; version-visible changes need a CHANGELOG entry. - Regenerate the corpus snapshot if your change touches rule predicates,
severity, or finding line attribution. Run the corpus locally (see "Corpus
snapshot" below), then
python scripts/snapshot.py generateand commit the updatedtests/corpus_snapshot.json.gzalongside the rule change. Reviewers will see the diff in the PR.
Corpus snapshot
tests/corpus_snapshot.json.gz locks compose-lint's output across a corpus
of real-world Compose files so unintended rule drift is visible in PR diffs.
- Generate a corpus locally with the helper scripts at
~/.cache/compose-lint-corpus/scripts/(out of tree by design — seeLICENSE-corpus.md). SetCOMPOSE_LINT_BINto your in-repo binary. - After a rule change, regenerate via
python scripts/snapshot.py generateand commit the updatedtests/corpus_snapshot.json.gz. Verify a clean run withpython scripts/snapshot.py verify. - The schema test (
tests/test_corpus_snapshot_schema.py) runs in CI on every PR and rejects schema changes that would carry third-party content into the snapshot. Don't widen the schema beyond rule_id, service, and line without revisitingLICENSE-corpus.md. - Don't commit
index.jsonlor any compose file from the corpus — those are third-party content and live only in your local cache.
Reporting bugs
Use the bug report issue template. Include:
- The minimal Compose file that triggers the behavior
- What you expected to happen
- What actually happened (full command output)
- Your compose-lint version (
compose-lint --version) and Python version
Code of conduct
This project follows the Contributor Covenant. By participating you agree to uphold it.