fix(secrets): the lab's sops rule matched nothing, so it used the estate's key - #321
Merged
Merged
Conversation
…ate's key ADR-0020 gave stacks/lab its own creation_rule above the catch-all because that catch-all matches ALL of secrets/: a recipient added there can decrypt the estate's SNMP communities and Grafana admin password, and a lab host holding those inverts the trust direction ADR-0007 exists to protect. The rule shipped as `secrets/lab\..*\.sops\.ya?ml$`, which cannot match `secrets/lab.sops.yaml`. After `secrets/lab\.` consumes the only dot before `sops`, `\.sops\.` has no second dot left; it would have matched `secrets/lab.something.sops.yaml` and nothing else. So the rule matched nothing, sops fell through to the catch-all, and `make secrets-init STACK=lab` on the guest encrypted the lab's secrets to the ESTATE's key — doing precisely what the rule was added to prevent, and reporting success while it did. Nothing caught it. sops does not warn about a creation_rule that matches no file; it silently uses the next one. The separation existed in the file, was reviewed, was merged, and was not real. It surfaced days later on `alexander` as sops' "no identity matched any of the recipients", which names the symptom and not the cause — the guest holds only its own key, and the file was not encrypted to it. No bad ciphertext reached the repository: secrets/lab.sops.yaml was never committed, so the damage is one untracked file on one host, holding the template's placeholder values. The runbook gains the recovery, which is to remove it and re-run secrets-init against the fixed rule. scripts/check_sops_rules.py is the guard, and its central assertion is the one that would have caught this: every creation_rule must match at least one file this repository actually encrypts. A rule matching nothing is a typo or dead, and both are indistinguishable from working until the day the fall-through matters. It also prints which rule each path resolves to, so the separation is visible in CI output rather than inferred from two regexes. The paths are derived — stacks from scripts/stacks.sh, the firewall path from the shape backup-firewall.sh writes — rather than a fourth hand-kept list. Verified by putting the broken pattern back: the check fails and names the dead rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Found by following
build-the-lab-guest.mdonalexander:make secrets-edit STACK=labfails with sops'no identity matched any of the recipients.What actually happened
ADR-0020 gave
stacks/labits owncreation_ruleabove the catch-all, because that catch-all matches all ofsecrets/— a recipient added there can decrypt the estate's SNMP communities and Grafana admin password.The rule shipped as:
which cannot match
secrets/lab.sops.yaml. Aftersecrets/lab\.consumes the only dot beforesops,\.sops\.has no second dot left to match — it would have matchedsecrets/lab.something.sops.yamland nothing else.So the rule matched nothing, sops fell through to the catch-all, and
make secrets-init STACK=labon the guest encrypted the lab's secrets to the estate's key — doing precisely what the rule was added to prevent, and reporting success while it did.bootstrap.sh's refusal did not help: it guards against writing one key into two rules, and the failure was a layer below that, in which rule sops picked.Why nothing caught it
sops does not warn about a
creation_rulethat matches no file. It silently uses the next one. The separation existed in the file, was reviewed, was merged, and was not real. It surfaced days later on a machine you have to walk to, as an error naming the symptom and not the cause.Blast radius
Small, and worth stating precisely:
secrets/lab.sops.yamlwas never committed — no bad ciphertext in the repository.secrets/lab.example.yaml's placeholder values. Nothing real was encrypted to the wrong key.The guard
scripts/check_sops_rules.py, wired intomake validateand CI. Its central assertion is the one that would have caught this: everycreation_rulemust match at least one file this repository actually encrypts. A rule matching nothing is a typo or dead, and both are indistinguishable from working until the fall-through matters.It also prints the resolution, so the separation is visible rather than inferred from two regexes:
Paths are derived — stacks from
scripts/stacks.sh, the firewall path from the shapebackup-firewall.shwrites — rather than a fourth hand-kept list.Verified by putting the broken pattern back, not by reading the fix: the check fails and names the dead rule.
Also
The runbook gains a recovery callout at §4, since anyone following it hits this.
make validatepasses.🤖 Generated with Claude Code