Warn when the .env holding the API token is readable by others - #137
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #136.
The problem
internal/client.loadDotenvread.envand handed back whatever it found without looking at the file's mode. A.envcreated by a shell redirect or an editor default lands at0644on most systems, so if it holdsCONFLUENCE_TOKENthen 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
.envjust read is reachable by anyone but its owner (mode.Perm()&0o077 != 0) and actually containsCONFLUENCE_TOKEN. The message names the file, what is wrong with its mode, and the remedy:The token gate is the interesting half. A
.envholding onlyCONFLUENCE_URLandCONFLUENCE_USERNAMEat0644leaks 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.loadDotenvhas 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.envand never warns, so there is no need for a suppression flag.It stats rather than lstats — a
.envsymlinked to a0600file is perfectly safe, and the link's own0777would cry wolf on every run. The user execute bit is ignored for the same reason:0700is odd, not a leak. The check lives inloadDotenvbecause that is the one function both the discovered project-root.envand an explicit--env-filepass through, and it reaches the reader throughSetSecurityWarner, a package-level hook wired inPersistentPreRunEbeside the existingSetRetryLogger— whose doc comment already explains why: twelve commands build a client throughResolvewith an identical literal, so anything passed per-call is something the thirteenth silently forgets, andinternal/clientproduces no output of its own.Where the warning goes, and the mistake on the way there
This started as a JSON-exempt
ui.SecurityWarn: everyinternal/uihelper is a no-op under--jsonon 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 noresults[]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.mdpublishes the stderr error object as#/$defs/errorObject, andcmd/children's own test runsschematest.ValidateErrorover all of stderr as a single document. So a run with both a bad.envmode and a stderr error object — a rejected credential, a missing username, a failedfind/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 becausePersistentPreRunEnever 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
warningsarray, and no command fills it:NewEnvelopeandEmitErrordrain 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'sreportSecurityWarningfeeds both paths at once, since at the time the warning is raised nothing yet knows which document this run will produce.schema_versionstays at 1: markfluence is unreleased, so there is no consumer to break.Also from review
0622is a genuine finding and nobody can read it, so "readable by others" was a claim a reader could check and disbelieve.accessDescriptionnow says readable / writable / accessible from the bits actually set..envunderMy Docspreviously produced achmodthat would have silently acted on the wrong path.SetSecurityWarnerline left every test passing, because each half is tested against its own double.TestSecurityWarnerIsWiredrunsPersistentPreRunEandResolvefor 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.gostill said "ten commands" next to the new hook's "twelve"; both now say twelve.Verification
make checkclean. Tests cover the mode × token table (including0400and0700staying silent, and a token-less0644staying silent), the message's wording per mode, the quoted remedy, the symlink case, delivery throughResolveitself so the check cannot end up wired only to the--env-filebranch, and that stderr under--jsonremains a single valid document. Smoke-tested live in all three states: human warning,--jsonwith the warning in the envelope and clean stdout, and a--jsonfailure whose stderr still parses as one document with the warning inside it.Deliberately out of scope
A group- or world-writable
.envwith no token in it.CONFLUENCE_URLresolves from.envtoo, so anyone who can write that file can point markfluence at a host they control and collect the token from$CONFLUENCE_TOKENon the next run — arguably the more dangerous file, and the token gate skips it. Covering it means warning on&0o022regardless 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.