Skip to content

Warn when the .env holding the API token is readable by others - #137

Merged
willkg merged 8 commits into
mainfrom
env-file-permissions-warning
Sep 7, 2026
Merged

Warn when the .env holding the API token is readable by others#137
willkg merged 8 commits into
mainfrom
env-file-permissions-warning

Conversation

@willkg

@willkg willkg commented Sep 7, 2026

Copy link
Copy Markdown
Member

Fixes #136.

The problem

internal/client.loadDotenv read .env and handed back whatever it found without looking at the file's mode. A .env created by a shell redirect or an editor default lands at 0644 on most systems, so if it holds CONFLUENCE_TOKEN then every account on the machine can read the API token, and markfluence said nothing. The token is deliberately never a command-line flag precisely because it is the one value that must not be casually visible — which makes the file it lives in the thing worth checking.

What it does

Warns — never fails — when the .env just read is reachable by anyone but its owner (mode.Perm()&0o077 != 0) and actually contains CONFLUENCE_TOKEN. The message names the file, what is wrong with its mode, and the remedy:

  ! /home/you/docs/.env is readable by others (mode 0644) and holds your API token; run: chmod 600 /home/you/docs/.env

The token gate is the interesting half. A .env holding only CONFLUENCE_URL and CONFLUENCE_USERNAME at 0644 leaks nothing — neither is a secret, and the cloud ID is documented as not being one either — and a warning that fires on a file with no secret in it is how a security warning becomes something people learn to scroll past. loadDotenv has already parsed the file when the check runs, so the gate costs nothing. It also makes the message's "holds your API token" a fact rather than a guess. A useful side effect: a CI job that passes credentials through the environment has no token in .env and never warns, so there is no need for a suppression flag.

It stats rather than lstats — a .env symlinked to a 0600 file is perfectly safe, and the link's own 0777 would cry wolf on every run. The user execute bit is ignored for the same reason: 0700 is odd, not a leak. The check lives in loadDotenv because that is the one function both the discovered project-root .env and an explicit --env-file pass through, and it reaches the reader through SetSecurityWarner, a package-level hook wired in PersistentPreRunE beside the existing SetRetryLogger — whose doc comment already explains why: twelve commands build a client through Resolve with an identical literal, so anything passed per-call is something the thirteenth silently forgets, and internal/client produces no output of its own.

Where the warning goes, and the mistake on the way there

This started as a JSON-exempt ui.SecurityWarn: every internal/ui helper is a no-op under --json on the premise that "warnings are carried in the structured payload instead", and this warning has no payload to be carried in — it names no page and no markdown file, so no results[] entry fits it. Writing it to stderr regardless looked right, on the reasoning that stderr is not part of the JSON contract.

That reasoning was wrong, and review caught it. README.md publishes the stderr error object as #/$defs/errorObject, and cmd/children's own test runs schematest.ValidateError over all of stderr as a single document. So a run with both a bad .env mode and a stderr error object — a rejected credential, a missing username, a failed find/search/children --space — would have emitted one human line followed by JSON, breaking exactly the consumer the schema invites. The repo's tests missed it only because PersistentPreRunE never runs in subpackage tests, leaving the hook nil.

So the warning travels inside the documents instead. Both the envelope and the error object gain a top-level warnings array, and no command fills it: NewEnvelope and EmitError drain a package-level collector, because the only thing in it is raised during credential resolution — below any command, before either document exists. The error object needs it as much as the envelope does, since a fatal failure emits no envelope and a credential failure is the run where this warning matters most. In human mode the stderr line is unchanged. cmd/root.go's reportSecurityWarning feeds both paths at once, since at the time the warning is raised nothing yet knows which document this run will produce.

schema_version stays at 1: markfluence is unreleased, so there is no consumer to break.

Also from review

  • The message named the wrong problem for two thirds of the modes it fires on. 0622 is a genuine finding and nobody can read it, so "readable by others" was a claim a reader could check and disbelieve. accessDescription now says readable / writable / accessible from the bits actually set.
  • The remedy is the point of the message, so a path a shell would mangle is quoted: a .env under My Docs previously produced a chmod that would have silently acted on the wrong path.
  • Nothing pinned the wiring — deleting the one SetSecurityWarner line left every test passing, because each half is tested against its own double. TestSecurityWarnerIsWired runs PersistentPreRunE and Resolve for real, and I confirmed by mutation that it fails without that line. A retry log going quiet is a debugging annoyance; a security warning going quiet is the feature not existing.
  • retrylog.go still said "ten commands" next to the new hook's "twelve"; both now say twelve.

Verification

make check clean. Tests cover the mode × token table (including 0400 and 0700 staying silent, and a token-less 0644 staying silent), the message's wording per mode, the quoted remedy, the symlink case, delivery through Resolve itself so the check cannot end up wired only to the --env-file branch, and that stderr under --json remains a single valid document. Smoke-tested live in all three states: human warning, --json with the warning in the envelope and clean stdout, and a --json failure whose stderr still parses as one document with the warning inside it.

Deliberately out of scope

A group- or world-writable .env with no token in it. CONFLUENCE_URL resolves from .env too, so anyone who can write that file can point markfluence at a host they control and collect the token from $CONFLUENCE_TOKEN on the next run — arguably the more dangerous file, and the token gate skips it. Covering it means warning on &0o022 regardless of contents, which widens this past "should be rw-user-only" and warns everyone with a group-writable shared checkout. Recorded in #136 rather than fixed here.

Every helper here is a no-op in JSON mode on the premise stated at
jsonMode: the content is carried in the structured payload instead. That
premise does not hold for a warning about the caller's credentials -- it
names neither a page nor a file, so no results entry can carry it, and
the envelope has no top-level warnings field. Routed through Warn it
would disappear in exactly the automated runs most likely to have a
world-readable .env.

Named narrowly on purpose. This is a hole in "stdout is the payload and
everything else is quiet under --json", so it is for credential hygiene
and nothing else; ordinary warnings belong in the payload, which is what
Warn enforces. Stderr was never part of the JSON contract.

Refs #136
The .env reader handed back whatever it found without looking at the
file's mode, so a .env left at the 0644 an editor or a redirect produces
made the API token readable by every account on the machine. The token is
deliberately never a flag because it is the one value that must not be
casually visible; the file it lives in is the thing to check.

Two halves to the rule. The mode: any group or other bit set
(mode.Perm()&0o077), so 0600 and the more restrictive 0400 stay quiet and
the user execute bit is ignored. And the file must actually contain
CONFLUENCE_TOKEN -- a .env holding only the URL and username leaks
nothing (the cloud ID is documented as not a secret either), and a
warning that fires on a file with no secret in it is how a security
warning becomes something people learn to scroll past. loadDotenv has
already parsed the file, so the gate is free.

It stats rather than lstats: a .env symlinked to a 0600 file is safe, and
the link's own 0777 would cry wolf every run. It lives in loadDotenv
because that is the one function both the discovered .env and an explicit
--env-file pass through. And it reaches the reader through a
package-level hook wired beside SetRetryLogger, for the reason that one
documents -- twelve commands build a client through Resolve with an
identical literal, and internal/client produces no output.

A group- or world-writable .env with no token in it is knowingly not
covered, though CONFLUENCE_URL resolves from there too and rewriting it
would redirect the token to another host. Recorded in #136.

Refs #136
README's configuration section: chmod 600 .env after copying the
example, what the warning covers, and why it survives --json when
nothing else does. .env.example says it at the point someone copies the
file. CLAUDE.md records both halves of the rule, the stat-not-lstat
choice, and the writable case left uncovered.

Refs #136
…ilence"

The helper rested on a false premise, stated in its own doc comment:
"stderr is never part of the JSON contract". It is. README publishes the
stderr error object as #/$defs/errorObject, and cmd/children's test
validates the whole of stderr as one schema document -- so a
human-readable line printed ahead of it breaks a consumer the schema
itself invites.

The warning belongs in the documents, which the next commits do.

Refs #136
A warning about the .env that supplied the API token belongs to no page
and no file, so no results entry can hold it, and stderr is unavailable:
under --json it is itself a schema-validated document. Both output
documents therefore gain a top-level warnings array.

The error object gets it as well as the envelope, and that is where it
matters most -- a fatal failure emits no envelope, and a credential
resolution failure is exactly the run where "your .env is world-readable"
is worth reading.

No command fills the field. NewEnvelope and EmitError drain a
package-level collector, because the only thing in it is raised during
credential resolution: below any command, before either document exists,
and threading a value through twelve commands is how the thirteenth
comes to forget it. Unreleased, so schema_version stays at 1.

Refs #136
From review of this branch. Three things.

reportSecurityWarning in root.go now feeds both paths at once: ui.Warn
for a human, jsonout.AddWarning for the documents. Both rather than
either, because the warning is raised during credential resolution --
before anything knows whether this run emits an envelope, an error
object, or neither.

The message named the wrong problem for two thirds of the modes it
fires on: 0622 is a real finding and nobody can read it, so
accessDescription now says readable/writable/accessible from the bits
actually set. And the chmod line is the point of the message, so a path
a shell would mangle is quoted -- a .env under "My Docs" produced a
remedy that silently chmod'd the wrong thing.

Nothing pinned the wiring: deleting the SetSecurityWarner call left
every test passing, since each exercises its own half against its own
double. TestSecurityWarnerIsWired runs PersistentPreRunE and Resolve for
real, and is verified to fail without that line. Two more cover the
delivery split, including that stderr under --json stays a single valid
document.

Also corrects retrylog.go's "ten commands" to twelve, so the two
adjacent hooks stop disagreeing about the count they both cite.

Refs #136
README: chmod 600 after copying the example, what the warning covers,
and where it appears in each mode. The envelope and error-object
examples gain the warnings key, and the schema notes describe it beside
roots -- invocation-level, not per-result.

CLAUDE.md records that the --json silencing rule survived this feature
rather than bending for it, and why the first attempt was wrong: stderr
under --json is a validated document, so the warning travels inside it.

Refs #136
…ilence" (test)

The test file was left behind by the earlier revert, so a fresh checkout
of the branch would not compile: it exercises a helper that no longer
exists. Local runs passed only because the file was already gone from
the working tree.
@willkg
willkg merged commit ad2b741 into main Sep 7, 2026
1 check passed
@willkg
willkg deleted the env-file-permissions-warning branch September 7, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

warn when .env holding the API token is readable by others

1 participant