Skip to content

fix(secrets): the lab's sops rule matched nothing, so it used the estate's key - #321

Merged
Gerrrt merged 1 commit into
mainfrom
gerrrt/sops-lab-rule-matched-nothing
Sep 5, 2026
Merged

fix(secrets): the lab's sops rule matched nothing, so it used the estate's key#321
Gerrrt merged 1 commit into
mainfrom
gerrrt/sops-lab-rule-matched-nothing

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Found by following build-the-lab-guest.md on alexander: make secrets-edit STACK=lab fails with sops' no identity matched any of the recipients.

What actually happened

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.

The rule shipped as:

path_regex: 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 to match — 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.

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_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 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.yaml was never committed — no bad ciphertext in the repository.
  • The file holds only secrets/lab.example.yaml's placeholder values. Nothing real was encrypted to the wrong key.
  • The estate's own secrets are untouched.
  • One untracked file on one host, which the runbook now tells you to delete.

The guard

scripts/check_sops_rules.py, wired into make validate and CI. 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 fall-through matters.

It also prints the resolution, so the separation is visible rather than inferred from two regexes:

.sops.yaml OK — 2 creation_rule(s), each matching:
  secrets/lab.sops.yaml -> secrets/lab(\..+)?\.sops\.ya?ml$
  secrets/observability.sops.yaml -> (secrets/.*|backups/firewall/.*)\.sops\.ya?ml$
  backups/firewall/config-20260101T000000Z.sops.yaml -> (secrets/.*|backups/firewall/.*)\.sops\.ya?ml$

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, 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 validate passes.

🤖 Generated with Claude Code

…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>
@Gerrrt
Gerrrt merged commit eb14e98 into main Sep 5, 2026
3 checks passed
@Gerrrt
Gerrrt deleted the gerrrt/sops-lab-rule-matched-nothing branch September 5, 2026 04:01
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.

1 participant