ADR-027: Grade an env_file: Where the Document Routes It¶
Status: Accepted (narrows one paragraph of ADR-026; leaves ADR-023 intact).
Context: #665 opened on
a silent false negative — a credential moved from environment: into an
env_file: reaches the identical container process environment and CL-0020 and
CL-0021 both go quiet. #669
shipped the half that needed no decision: a service naming an env_file: now
says so on stderr, in all three spellings, with no finding, exit-code or
machine-readable change. The gap is loud. It is not closed.
What is left is one question, and it is narrower than the issue title suggests.
Reading the file is not in dispute — env_file: is named in the document under
lint, which makes it the easiest of the three sibling-file cases to justify
under ADR-026's governing principle. The dispute is whether the credential rules
may fire on what is inside, because that collides with a decision ADR-026 made
six lines below its §2 passage:
Also out of scope: "there is a secret in your
.env" as a finding. The actionable, machine-independent version of that claim is "a secret is committed to git", which is gitleaks/trufflehog territory.
So this is a request to move a recorded boundary, not to fill a gap in one.
The boundary is not adjacent to env_file:. It is the same file. Over the
5,417-file corpus, 414 files (7.64%) name an env_file: across 867 services and
924 path references. 496 of those references — 53.7% — name .env itself,
in 267 files, which is 64.5% of every file that uses the feature. The majority
case for this decision is a file compose-lint already opens, already parses with
parse_env, and already refuses to let the credential rules read.
What Compose actually does¶
Derived from the binary (Compose 5.4.0, client-side, no daemon), because
env_file: shares godotenv's grammar with .env and diverges from it in ways
no prose says out loud:
| behaviour | Compose | same as .env? |
|---|---|---|
| path anchor | the Compose file's own directory | yes |
${VAR} inside the path |
resolved, from .env |
n/a |
../ and absolute paths |
accepted | n/a |
missing file, required (the default) |
the run is refused, exit 1 | no |
missing file, required: false |
skipped, exit 0 | n/a |
grammar (quoting, export, comments, $$) |
godotenv | yes |
bare KEY with no = |
sourced from the shell; omitted when unset | no — .env ships empty |
${VAR} inside a value |
earlier keys in the same file, then earlier env_file:s, then .env, then the shell |
yes |
| forward reference | empty | yes |
format: raw |
no interpolation, no quote stripping | n/a |
| malformed line | the whole run is refused | yes |
| precedence | environment: beats every env_file:; a later file beats an earlier one |
n/a |
| where the values go | the container's process environment, and nowhere else | no |
The last row is the load-bearing one and it was measured, not assumed. An
env_file: key does not resolve a ${VAR} in the Compose document:
with TAG=1.2.3 in x.env and image: "alpine:${TAG}" in the document,
docker compose config emits image: 'alpine:' and warns that TAG is not
set. The value reaches exactly one destination.
Decision¶
CL-0020 and CL-0021 classify the contents of an env_file:, under one addition
to ADR-026's principle:
A value is graded where the document routes it.
env_file:is a declaration that every key in the named file becomes a literal in a named service's process environment..envon its own declares nothing of the kind.
That sentence is what settles the gitleaks boundary, and it settles it without contradicting ADR-026:
-
§2 stays exactly as written. A
.envvalue is still never substituted into anenvironment:value. This decision substitutes nothing — it grades a file the document routes into the environment, at the site of the routing. -
The discriminator is destination, not filename, and destination is a property of the document under lint rather than of the lint host. A
.envvalue's destination is unknown, and by §2's own construction is provably notenvironment:. Anenv_file:value has one destination, verified above. -
The claim is not the one line 224 excluded. Line 224 excluded "a secret is committed to git", which is a fact about a repository. CL-0020's claim is "this credential is a literal in the container's process environment, readable through
docker inspect,/proc/<pid>/environ,docker compose config, process listings and CI logs" — a fact about the deployed configuration, true whether or not the file is in git, and already the verbatim grounding in the rule's own documentation.
env_file: .env is the strongest case for this, not the awkward one. ADR-026
§2 exists because CL-0021's fix text offers
DATABASE_URL: postgres://user:${DB_PASSWORD}@host/db as the remediation, so
firing on a .env that supplies DB_PASSWORD would flag the user for taking the
rule's own advice. Adding env_file: .env undoes that advice — the credential
is back in the process environment, by a different route, and docker compose
config prints it. §2 protects the fix; this ADR fires only where the fix was
cancelled. 267 corpus files are in that shape.
Neither CL-0020 nor CL-0021 names env_file: as a remediation — checked: no
mention in either rule doc, and the only env_file occurrences under docs/ are
three unrelated ADRs and one example Compose file. So unlike the §2 case, nothing
here punishes a documented fix.
The ADR-023 objection, answered¶
The objection recorded in #665 is the real one: env_file: targets are usually
absent from the repository, so the same checkout lints differently on a laptop
and in CI — and, unlike .env, there appeared to be no conservative middle
state, only absent → nothing versus present → findings.
There is one, and it is stronger than the state ADR-026 had to settle for.
Absent and required — which is the default — means the configuration does not
deploy. docker compose config exits 1: env file .../nope.env not found.
There is no shipped configuration to grade, which is precisely the reasoning
ADR-026 used about a whole-source ${VAR} with no .env: "There is no shipped
value, and the document being reported clean cannot deploy." compose-lint says
so — the #669 note, narrowed to the case where the file was actually unreadable —
and grades nothing, which is what it does today. CI loses nothing relative to
this release.
That is not a corner case. Sampling 218 corpus files that name an env_file:
(GitHub contents API; directory listings and .gitignore only — no env-file
content was fetched, matching ADR-026's sampling for the same question):
| targets | files | |
|---|---|---|
| committed beside the Compose file | 137 / 456 (30.0%) | 79 / 218 have at least one |
| absent from the repository | 319 / 456 (70.0%) | 127 / 218 have none present |
of those absent, required |
306 / 319 (95.9%) | — |
| unresolvable or outside the project dir | 30 / 456 | — |
So for roughly seven named targets in ten, a fresh clone cannot docker compose
up at all, and the honest report is "this document is incomplete", not "this
document is clean". For the other three in ten the file is committed and the run
is identical on a laptop and in CI — strictly better than today's silence on
both machines.
ADR-026's fourth resolving finding transfers verbatim: the divergence fails safe. No finding disappears from CI relative to today, because CI does not read these files today either. A committed target is read on both machines. An absent one leaves CI exactly as blind as it is now, while the laptop gets stricter and says which file it read.
What this means concretely¶
-
Which files. Every path in every spelling (bare string, list of strings, list of mappings with
path:), resolved relative to the Compose file's own directory, with${VAR}in the path resolved from.envas Compose resolves it. A path that stays unresolved is not read and is noted. -
What is read.
parse_env, plus the three placesenv_file:diverges from.env: a bareKEYis Compose's shell lookup and is therefore left unresolved rather than treated as empty;format: rawdisables interpolation and quote processing; a missingrequiredfile is the note above rather than a parse failure. No process-environment fallback, for ADR-023's reason and by ADR-026's divergence 1 — a value the tool cannot honestly build stays out of the rules' hands.MAX_ENV_BYTESand_safe_readapply unchanged. -
What consumes it. CL-0020 and CL-0021 only, on keys not shadowed by the service's own
environment:(Compose's precedence, so a key in both is graded once, at the spelling that wins). Nothing else — verified that no other rule readsenvironmentvalues, and verified above that these keys never reach document interpolation. -
ADR-026 §5's retention filter does not apply, and the honest claim changes. For
.env, only the values a run needs are kept. Here every key is a graded key, because every key is deployed. The statement that must appear in the docs is "every key in anenv_file:is examined", never "we do not read your secrets". -
No disclosure — and the mechanism is not the one #665 named. That comment argued the rules are safe because there is no
evidencefield. There is: both rules set it (CL0020_credential_env_keys.py:277,CL0021_connection_string_credentials.py:212), and both set it to the key name. The guarantee is what the field holds, not that it is absent, and it is the stronger claim of the two because it is a property of the code rather than of a schema. No value has ever reached a message, JSON, SARIF or code scanning, and nothing here adds a path by which one could. -
Attribution. A finding on an
env_file:key carriessource_fileset to the file as written andlineset to the line within it. That extendssource_file, whose docstring currently scopes it to a run that merged more than one document; the extension is the same claim in both cases — the evidence is not in the file named in the report — and ADR-025 already verified that naming a file absent from the repository is safe for SARIF.evidence(the key) keeps the finding's identity stable under ADR-024, so the fingerprint does not move if the prose does. -
Paths outside the project directory are refused, and noted. Compose reads them; compose-lint does not. ADR-026 §4 refuses out-of-project
COMPOSE_FILEentries because a document must not redefine the gate's scope; the reason here is different but no weaker — anenv_file: /home/runner/.aws/credentialsadded in a pull request would put lint-host key names into a report, which is the ADR-023 leak arriving through the one field these rules do emit (verified that Compose accepts both an absolute path and a../climb). 20 corpus files name one. -
--no-envcovers it, and widens. ADR-026 §6 stopped the flag count at two and this does not move it. The flag's help text is currently specific — "ignore a.envsitting beside the Compose file" — and becomes "do not read the env files beside this one"; its §6 promise, that it reproduces the previous release's behaviour exactly, is what makes the widening obligatory rather than optional, since after this change the previous behaviour includes theenv_file:read. A second flag was considered and rejected on ADR-026's own reasoning. -
label_file:is decided, not implemented. It is the same file-reference shape and is entirely unknown to compose-lint (0 hits insrc/, 0 intests/). No rule readslabels:, so nothing is missed today. The principle above already says what happens when one does: alabel_file:value is graded wherever alabels:value would be, by the same destination test, and until then it stays unread and unnoted because there is nothing it could silence.
Alternatives considered¶
-
Keep the #669 note and close #665. ADR-025 already ruled on this shape, in its own words: "an unresolved
include:is a gap because compose-lint cannot read the file; a sibling overlay is a gap because it does not. Being loud about what you cannot do is honest; warning about something you could simply do is a placeholder."env_file:is the second kind. Choosing this means accepting a permanent silent false negative — ADR-023's worst failure mode — in a rule whose own documentation states the harm. -
Read the files but classify nothing. Nothing else consumes an
env_file:value — verified, twice over — so this is a no-op with a header line. -
Fire on named targets but exempt
.env. Filename-based, so it exempts 64.5% of the population, and it exempts precisely the case where CL-0021's recommended fix has been undone. It also re-introduces the mistake §2's amendment fixed: a rule that depends on where a value came from rather than on where it goes. -
An opt-in flag. ADR-023 clause 3 names an opt-in flag as the admissible form for lint-host context. An
env_file:is named in the document, so it is not that; ADR-026's own precedent (and ADR-019's withdrawal of the one opt-in feature this project shipped) applies unchanged. -
Extend ADR-026 line 224 to cover
env_file:explicitly. Coherent, and it is the status quo made deliberate. Rejected because line 224's actual argument — that the actionable form of the claim is a git-hygiene one — is false for a file the document routes into a container.
Consequences¶
- Files clean on a previous release may report new CL-0020 and CL-0021 findings,
including on a
.envcompose-lint already reads. The documented MINOR behaviour (compatibility.md), with the documented escape hatches: pin the version, or gate on--fail-on. - The corpus cannot measure this, but a reconstruction can, and did. The
corpus is filename-filtered (
scripts/corpus/fetch.py:29) so it holds no sibling files: a corpus run reports every target absent and produces notes rather than findings. What it can measure is the population that newly has a file opened — 414 files, 867 services, 924 references, 496 of them a.envalready being read — and not the finding count.
That was measured by rebuilding the projects instead: fetching each named
target its repository actually commits, staging it beside the compose file,
and running the rules twice against the same parsed document, with and without
env_files. Of 163 projects whose env file could be read, 90 (55%) gained at
least one finding, 538 in total — 491 CL-0020 and 47 CL-0021 across 127
distinct key names. 83.6% of the values are opaque literals; most of the rest
are placeholders (changeme, your_*_here), which fire by design —
#561 settled that
AUTH_TOKENS: your_token_here is a finding.
The number is a floor on a biased sample. Only 44% of named targets are
committed to their repository at all; the rest are gitignored, which is where
live credentials concentrate and where no measurement reaches. The placeholder
rate is itself evidence of that bias — a project that commits an env file is
likelier to be committing an example one. The reconstruction is a scratch
measurement and is deliberately not part of scripts/corpus/: a fetcher that
pulls credential-bearing files is not something this repository should ship.
- ADR-026 line 224 is narrowed, not deleted. "There is a secret in your .env"
remains out of scope. "There is a credential your document routes into a
container's process environment" is in scope, and always was — only its
implementation stopped at the document boundary.
- The env_file: grammar becomes a second compatibility surface with Compose,
including the three divergences from .env above. It belongs in
tests/test_env_semantics.py, which already re-derives the .env grammar from
the binary on every run, so a Compose release that changes either one fails
there rather than silently mis-reading a user's stack.
- The required:/absent split becomes user-visible: an absent required target is
a statement that the project does not deploy from this checkout, which is new
information the note did not previously carry.
- A run's stderr gets longer on the 7.64% of files that use the feature: one line
per file read, plus the existing note narrowed to the unread cases.
Scope: This ADR amends the "secret in your .env" paragraph of ADR-026 and
nothing else in it — §§1–6 stand, and §2 in particular is strengthened rather
than weakened. ADR-023 is untouched: clause 2's declared-proxy rule is what the
announced read satisfies, and clause 3 still governs anything judged to be
lint-host context, which a file named in the document is not. Out of scope, as in
ADR-026: the ambient shell environment, COMPOSE_ENV_FILES, --env-file, and
--project-directory.