Security Assurance Case¶
This document is the single-page assurance case for compose-lint: what it
defends, against whom, where the trust boundaries are, and which design
choices and tooling enforce each claim. It is the artifact the OpenSSF
Best Practices Silver assurance_case criterion asks for, and the doc to
read first if you are evaluating whether to depend on compose-lint in a
security-sensitive pipeline.
For the narrower question of what compose-lint will and will not detect in your Compose files, see SECURITY-EXPECTATIONS.md. For the vulnerability-reporting process, see SECURITY.md.
What compose-lint is¶
compose-lint is a local static analyzer for Docker Compose files. A user
runs compose-lint docker-compose.yml; the tool reads the file, applies
a fixed set of rules, prints findings to stdout (text, JSON, or SARIF),
and exits 0 / 1 / 2. It does not connect to a network and does not run as
a daemon.
check, --explain and init -o never modify a Compose file. The one
subcommand that writes to an input is fix --apply, which rewrites the
file in place via an atomic swap — dry-run is the default, and every apply
is guarded by a re-parse and a verify-apply pass (ADR-014).
init also writes, but to .compose-lint.yml rather than to an input, and
it refuses an -o that resolves to one of the files it read, --force or
not. Neither fix --apply nor init writes anything in a run where a rule
crashed, because the sweep that decides what to write was incomplete.
Trust boundaries¶
┌──────────────────────┐ ┌──────────────────────┐
│ UNTRUSTED INPUT │ safe-load │ TRUSTED CORE │
│ - Compose YAML file │ ─────────────▶ │ - Parser (engine) │
│ - Config YAML file │ │ - Rule predicates │
│ - CLI args │ │ - Formatters │
│ - Environment │ │ │
└──────────────────────┘ └──────────┬───────────┘
│
│ text / JSON / SARIF
▼
┌──────────────────────┐
│ TRUSTED OUTPUT │
│ - stdout / stderr │
│ - exit code │
└──────────────────────┘
There is exactly one untrusted boundary: the YAML and config files the
user hands the tool, plus CLI argument strings and the environment. The
environment variables compose-lint reads are CLI surface
(ADR-037); the one that
names a program is PAGER, which --explain runs on an interactive terminal
(ADR-034). It is the invoking user's own
setting, never a value from a Compose or config file. Everything else — the
Python interpreter, the venv, the rule implementations, the runtime
image — is trusted by construction (pinned, signed, reproducible).
There is no network input, no credential store, no multi-tenant mode, and no privileged escalation surface. The runtime image has no shell, no package manager, and runs as UID 65532.
Threat model¶
The realistic adversary is a malicious or carelessly authored Compose
file reaching compose-lint. The user has chosen to run the tool
against it; the file came from a repo, a PR, a CI job, or a pasted
snippet.
| # | Attacker goal | Mechanism | Mitigation |
|---|---|---|---|
| 1 | Execute attacker code in the linter process | YAML deserialization gadget; embedded Python tag | All YAML is parsed via yaml.SafeLoader (compose) or yaml.safe_load (config). No yaml.load, no eval/exec on parsed values. Custom LineLoader is asserted to subclass SafeLoader at import time. |
| 2 | Crash the linter or hang it indefinitely | Pathological YAML structures (deep nesting, billion-laughs) | Parser traversals are iterative, not recursive (fix for #61). Continuous fuzzing on parser + engine via ClusterFuzzLite (cflite-pr.yml per-PR, cflite-batch.yml daily). |
| 3 | Cause a false negative — slip a real misconfig past the linter | Hardened-but-unusual syntax, alternate-form constructs | Every rule ships positive and negative tests; tests/compose_files/safe_*.yml enforces hardened-but-unusual fixtures (CONTRIBUTING.md §"Adding a new rule"). Corpus snapshot (tests/corpus_snapshot.json.gz) locks output across 1,500+ real-world Compose files; PRs that change findings show the diff. Rule-loader test catches mutation-untested code paths via mutmut. Where a rule has a spelling table, every equivalent spelling of a flagged primitive is generated from one table and asserted (tests/test_CL0016_spellings.py); a gap no real file uses is recorded there or on the rule's page rather than fixed on discovery (Triage for a newly found gap below). Every file a document can name (include:, extends:, project_directory:, env_file:, .env, COMPOSE_FILE, a linked Compose or override file, the discovered policy file) is crossed with every placement of its target (inside the project, inside the repository by path or by link, outside it, into .git, absolute) in one table, tests/test_containment_matrix.py, so a read site that drifts from the shared containment root fails a cell (ADR-038). |
| 4 | Cause a false positive — make the linter fail benign files | Adjacent-but-clean configs (named volumes, sub-form syntax) | Same negative-test discipline as #3, plus the corpus snapshot regression gate. |
| 5 | Exfiltrate data from the linter or its host | Make the linter open a network connection | Product code performs no network I/O. Documented hardened docker run recipe pins --network none and is exercised in CI (docker-smoke matrix). |
| 6 | Compromise the linter via a poisoned dependency | Tampered package on PyPI | All deps and dev-tools are hash-pinned in requirements*.lock; CI installs with pip install --require-hashes. Renovate updates lockfiles weekly; pip-audit, dependency-review, and OpenSSF Scorecard run continuously. |
| 7 | Substitute a malicious build of compose-lint downstream | Tampered wheel or container image post-publish | PyPI dists are Sigstore-signed (PEP 740 attestations + GitHub Release .sigstore.json bundles, post-release verified by verify-release-signatures). Container manifests are cosign-signed; SLSA build provenance attached to every artifact; SBOM (SPDX) and OpenVEX attested to images. |
| 8 | Compromise the runtime image | Vulnerable shell / package manager / interpreter | Runtime image is gcr.io/distroless/python3-debian13:nonroot (no shell, no apt, UID 65532). Two-stage Dockerfile strips pip binaries from the runtime venv; only .dist-info is retained for SCA visibility. Documented hardened-run flags pin --read-only --cap-drop ALL --security-opt no-new-privileges:true --user 65532:65532 --pids-limit 256, all exercised in CI. See ADR-009. |
| 9 | Tamper with a release tag in transit | Push a malicious tag that triggers publish.yml |
publish.yml verify-tag requires the tag to be annotated (git tag -s produces this), reachable from origin/main, and (gated by .github/allowed_signers) cryptographically verified via git verify-tag. |
Triage for a newly found gap¶
Row #3 puts every alternate spelling of a flagged primitive in scope, and rule reviews keep finding gaps: false negatives, false positives, forms no rule grades. Most have no real-world use. They are spellings an adversary writes, not ones an author writes by accident, and one issue, measurement and PR per gap is the expensive way to close them. So a gap is triaged by its demand before any work is spent on it.
Demand is the number of corpus services that contain the exact form the
change would grade, or stop grading, measured over the current corpus.
A service is overshadowed when a finding it already carries calls for the
same fix, or for a fix that would also remove the new finding: a more severe
finding, another spelling of the same primitive (the rule's spelling table says
which spellings are one primitive), or a dominating finding such as
privileged: true over any device, capability or cgroup finding. A finding for
a different object at the same severity does not overshadow: a second exposed
port is a second exposure. The test is what the author is already being told
to fix, not the severity label alone.
| Demand | Overshadowed | Action |
|---|---|---|
| 0 | — | Record it, and do not build it. A spelling gets a row in the rule's spelling table (an expected failure, if the rule disagrees); anything else gets a recorded not-graded entry on the rule's page or in an issue |
| > 0 | Some services are not | Build it. A change to what a rule fires on still needs the maintainer's approval |
| > 0 | Every service is | Queue it: record it as above, close the issue with the demand-gated label, and recount when the corpus is refreshed |
Demand decides whether effort is spent now, and nothing else: it never enters a severity, and never retires a rule (ADR-028). A zero count also needs a raw-text check that the matcher is not simply broken before it is believed. A refinement that changes no finding at all (grounding, wording, a severity argument) has no demand by construction; record it on the rule's page or in an issue, and don't chase it.
A spelling table is one entry per flagged primitive, with generators that
derive each equivalent input from it, so a new row is covered by every
generator and a new generator covers every row. CL-0016 has one
(tests/test_CL0016_spellings.py). A generator is added only once a premise
check or a recorded measurement shows the spelling reaches the same object;
the table checks that the linter treats equivalent inputs alike, not that they
are equivalent. Until a rule has its own table, a zero-prevalence spelling gap
in it is recorded on the rule's page or in an issue, and fixed when the table
lands.
Secure-design principles applied¶
- Least privilege. Distroless
:nonrootruntime; documented hardeneddocker runflags; CI workflows declarepermissions: {}at the workflow level and grant per-job scopes only. - Economy of mechanism. PyYAML is the sole runtime dependency (CONTRIBUTING.md "Code standards"). The product is pure Python.
- Fail-safe defaults.
--fail-on highis the default. Unknown YAML shapes exit 2 instead of being silently ignored. SafeLoader is the default — there is no unsafe-load path. - Defense in depth. Image hardening and runtime-flag hardening and lint-time rule enforcement; a defect in any one layer does not remove the others.
- Reproducibility. Hash-pinned Python lockfiles, digest-pinned Docker base images, SHA-pinned third-party GitHub Actions. Renovate bumps the pins.
- Tag-rooted provenance. SSH-signed annotated git tags root the
Sigstore attestation chain. The
verify-tagjob inpublish.ymlrefuses to publish from anything else.
Implementation-weakness coverage¶
Common Python-level weakness classes and their mitigations:
| Weakness class | Mitigation |
|---|---|
| Unsafe deserialization (CWE-502) | yaml.SafeLoader everywhere; custom loader asserts subclass at import time. |
| Command injection / shell-out (CWE-78, CWE-77) | One subprocess: --explain on an interactive terminal pipes the rule's doc to a pager through subprocess.Popen with an argv list and no shell (ADR-034). The argv comes only from $PAGER (split with shlex) or the built-in less default, never from Compose or config content; the doc text goes to its stdin. No os.system, no shell=True. CI runs Bandit at medium severity and above (-ll), which does not report the low-severity notices on that call. |
| Output and CI-log injection (CWE-117, CWE-150) | Everything printed from a Compose file goes through one sanitizer (_output.py): control characters, bidi and invisible code points are shown as \uXXXX, and the CI workflow-command openers (##[ and Azure's ##vso[ anywhere, :: at the start of a line after any Unicode white space, as the runner trims it) are escaped the same way. That covers the text report, every stderr line including argparse's own errors, and JSON and SARIF on stdout, where the bracket is written as the JSON escape \u005b so the data is unchanged. The GitHub Action also brackets each compose-lint run with a fresh random stop-commands token, so the runner processes no command from the tool's output, and prints no path the checkout chose outside those brackets. |
| Path traversal on input (CWE-22) | Two classes of path. User-supplied targets (argv, --config) are read as given, and written only by fix --apply / init, with one exception: a Compose file named on argv that is itself a symlink to a file outside both its own directory and the directory the run started in (the checkout, in CI) is refused, because argv is often a list the checkout chose (bare discovery, the Action's default list and pattern:, a changed-files list, pre-commit). A plain path is read wherever it points. Found and document-supplied paths — the discovered .compose-lint.yml/.compose-lint.yaml, discovered primary files and the sibling compose.override.yml, env_file: targets, COMPOSE_FILE entries, the sibling .env and an include: entry's project_directory: (ADR-026, ADR-027) — are chosen by untrusted content, and a symlink among them cannot escape the checkout: each is refused, not read, when it resolves outside both its own root and the directory the run started in (document-supplied ones are also tested lexically first, so a path written with .. out of the project is refused whether or not it is a link). A refused Compose document is a coverage gap; a refused policy file is a configuration error. Both are read-only. |
| Resource exhaustion (CWE-400) | Iterative parser traversals; byte, scan-length, merge-pair and service-count caps (_limits.py); ClusterFuzzLite runs as a continuous gate. |
| Type confusion in rule predicates | mypy --strict on every commit; rules receive plain types only (AGENTS.md). |
| Logic bug in a rule (false negative or positive) | Per-rule positive + negative + hardened-but-unusual fixtures; corpus snapshot regression gate; mutation testing on rule predicates via mutmut. |
| Outdated dependency with known CVE | pip-audit (informational on PRs, blocking at release in publish.yml); dependency-review; Renovate weekly + same-day OSV security alerts; OpenVEX document for stripped-component CVEs (ADR-012), whose vulnerable_code_not_present justification the PR and release Docker smokes assert against the built image, and whose completeness the daily vuln-report checks by diffing pip's OSV advisory list against the statement names. |
| Tampered release artifact | Sigstore + cosign + SLSA provenance + SBOM + post-release verify-release-signatures job. |
Continuous-assurance tooling¶
Every claim above is enforced by automation that runs without human prompting:
- CI gate (every PR + push):
ruff,ruff format --check,mypy src/ tests/,pytestmatrix on Python 3.11–3.14,bandit,actionlint,dependency-review,dockerfile-digestsmanifest-list check,docker-smoke(clean fixture exits 0, insecure fixture exits 1, SARIF is valid JSON, pip code stripped with its metadata kept),action-smoke, DCO trailer check, no-AI-attribution check, version-string consistency, CHANGELOG-bump gate. (pip-auditalso runs here but is informational — the blocking dependency-CVE gate is at release time; see below.) - Dependency-CVE gate:
pip-auditiscontinue-on-erroron PRs (a live-advisory disclosure must not block unrelated work); Renovate OSV alerts open a same-day security PR;publish.ymlrunspip-auditas a hard gate before any artifact is built. - Static analysis: CodeQL (security-and-quality queries) on push, PR, and weekly schedule; Bandit per push.
- Fuzzing: ClusterFuzzLite per-PR (
cflite-pr.yml) and daily batch (cflite-batch.yml) against the parser and engine. - Supply-chain scoring: OpenSSF Scorecard weekly and on branch-protection changes.
- Release-time gates (
publish.yml): annotated-tag check, tag- reachable-from-main check, SSH tag-signature verification (via.github/allowed_signers), TestPyPI smoke, multi-arch Docker smoke under hardened flags (including the pip-stripped check behind the OpenVEX statements), manualrelease-gateapproval, post-release Sigstore identity verification, cosign signature verification on the pushed manifest, version-matches-tag verification on the published image.
Out-of-scope concerns¶
The following are not within compose-lint's assurance perimeter:
- The Compose file's runtime behavior. compose-lint statically analyzes the YAML; it does not start containers. Runtime exploits rooted in image contents, network policy, or kernel CVEs are out of scope.
- Image-content vulnerabilities. Pair compose-lint with Trivy or Docker Scout for image CVE scanning.
- Dockerfile lint. Pair with Hadolint.
- Compose schema validity. Pair with dclint. compose-lint flags insecure configurations that are valid YAML; it does not gate on Compose schema correctness.
- Multi-tenant or service-mode operation. compose-lint runs per-invocation as a CLI; there is no daemon, queue, or shared process state.
These boundaries are restated in SECURITY-EXPECTATIONS.md in user-facing language.
Maintenance and review¶
This assurance case is reviewed each MINOR release (per
docs/RELEASING.md). If a release adds a new attack
surface — a network call, a new untrusted-input source, a new
dependency, a runtime mode — the corresponding row in the threat-model
table must be updated in the same PR. CHANGELOG entries with a
Security heading should reference the row they relate to.
The single-maintainer continuity question (what happens if the maintainer is unavailable) is addressed in docs/CONTINUITY.md.