Skip to content

Fix make secrets-init — SOPS matches creation rules on the input path - #6

Merged
Gerrrt merged 2 commits into
mainfrom
fix/bootstrap-sops-creation-rule
Aug 2, 2026
Merged

Fix make secrets-init — SOPS matches creation rules on the input path#6
Gerrrt merged 2 commits into
mainfrom
fix/bootstrap-sops-creation-rule

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Hit on a real run on the monitoring host:

-- creating observability.sops.yaml from the template
error loading config: no matching creation rules found
make: *** [Makefile:62: secrets-init] Error 1

Cause

SOPS selects a creation_rule by matching path_regex against the input path. The script encrypted the template and redirected the output:

sops --encrypt secrets/observability.example.yaml > secrets/observability.sops.yaml

so SOPS tested secrets/.*\.sops\.ya?ml$ against the example filename, which doesn't end in .sops.yaml. No rule matched. The output name is never consulted, so having the destination right made no difference.

Fix

Copy to the destination name first, then encrypt in place — the path the rule actually targets.

The plaintext window

Between the cp and a verified encryption, that file is plaintext at a path meant to be committed, whose name says "encrypted" — and it is deliberately not gitignored, because encrypted secrets belong in git. Anything that leaves it behind is a leak waiting to be committed.

The first version cleaned up only on the two failures it explicitly checked. Review correctly pointed out that a chmod failure under set -e, a Ctrl-C mid-encrypt, or a SIGTERM all skipped the rm. A trap on EXIT INT TERM is now armed immediately after the cp and cleared only once the sops: metadata block is confirmed present. Success is verified by inspecting the file, not by trusting the exit status — CI does catch a plaintext secrets file, but only after it has been pushed.

Why it wasn't caught earlier

This path needs an age key end to end, and the earlier work stopped at "cannot verify without the user's key" instead of generating a throwaway one. That was the wrong call: a disposable keypair in a sandbox HOME exercises the whole chain without touching anything real.

Verification

Sandbox clone, its own HOME, disposable keypair:

  1. bootstrap produces a genuinely encrypted file — keys readable, values ENC[AES256_GCM...], no placeholder text left
  2. sops --encrypt --in-place round-trips after editing values
  3. render-config.sh writes snmp.yaml, webhook_url and .env
  4. a community containing / & \ $ renders byte-for-byte intact, and all four devices keep distinct values
  5. no SNMP community reaches .env — only the two values compose interpolates
  6. docker compose config passes against the real rendered .env
  7. git sees only .sops.yaml and the encrypted secrets file as stageable
  8. a broken age recipient leaves nothing behind
  9. SIGINT to the script's own process group mid-encrypt — a real Ctrl-C — leaves nothing behind: present before the signal, absent after

That is the first time the secrets path has been exercised end to end rather than reasoned about.

On test 9

Three earlier attempts were inconclusive rather than passing, and each looked like a result:

  • signalling a process group that, in a non-interactive shell, also contained the test harness
  • sending SIGINT to the script's pid, where bash defers the handler until the foreground command returns — sops completed and the file legitimately survived encrypted, which reads as a leak unless you check the contents
  • a setsid run whose $! had already exited, so the kill addressed an empty pgid and no signal was ever sent

Only setsid plus a pgrep-resolved pgid actually exercises the path. Same failure mode as the xargs bug fixed in #5: a check that reports something without having tested what you think it tested.

What to do on the host

The keypair and .sops.yaml written by the failed run are correct — only the encrypt step failed, so nothing needs undoing:

git pull
make secrets-init     # now creates secrets/observability.sops.yaml
make secrets-edit     # replace every change-me value

Then commit .sops.yaml and secrets/observability.sops.yaml together, and carry on with make validate and make up.

Back up ~/.config/sops/age/keys.txt off that machine first. Without it, every encrypted secret in this repository is unrecoverable.

Reported from a real run on the monitoring host:

    -- creating observability.sops.yaml from the template
    error loading config: no matching creation rules found
    make: *** [Makefile:62: secrets-init] Error 1

SOPS selects a creation_rule by matching path_regex against the *input* path.
The script encrypted the template and redirected the output:

    sops --encrypt secrets/observability.example.yaml > secrets/observability.sops.yaml

so SOPS tested `secrets/.*\.sops\.ya?ml$` against the **example** filename,
which does not end in .sops.yaml. No rule matched. The output name is never
consulted, so the destination being correct made no difference.

Copy to the destination name first, then encrypt in place, which is the path the
rule actually targets.

Two guards added, because the failure mode of getting this wrong is worse than
an error message. `cp` followed by a failed encrypt leaves a plaintext file
sitting at a path whose name says "encrypted" — and it is not gitignored,
because encrypted secrets are meant to be committed. So on any encryption
failure the partial file is removed, and success is confirmed by checking for
the `sops:` metadata block rather than trusting the exit status. CI catches a
plaintext secrets file, but only after it has been pushed.

This was never caught because the whole path needs an age key, and the earlier
work stopped at "cannot verify without the user's key" rather than generating a
throwaway one.

Verified end to end in a sandbox with its own HOME and a disposable keypair:

  1. bootstrap produces a genuinely encrypted file — keys readable, values
     ENC[AES256_GCM...], no placeholder text remaining
  2. `sops --encrypt --in-place` round-trips after editing values
  3. render-config.sh writes snmp.yaml, webhook_url and .env
  4. a community containing / & \ and $ renders byte-for-byte intact, and all
     four devices keep distinct values
  5. no SNMP community reaches .env — only the two values compose interpolates
  6. `docker compose config` passes against the real rendered .env
  7. git sees only .sops.yaml and the encrypted secrets file as stageable; no
     .env, .rendered/ or plaintext copy can be committed
Copilot AI review requested due to automatic review settings August 2, 2026 21:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the bootstrap flow that underpins make secrets-init so SOPS selects the correct creation_rule by ensuring the encrypted file’s input path matches .sops.yaml’s path_regex.

Changes:

  • Copy the plaintext template to the destination *.sops.yaml filename first, then run sops --encrypt --in-place so creation_rules match the input path.
  • Add guardrails to delete the destination file if encryption fails or if the output does not contain the expected sops: metadata.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/bootstrap.sh
Comment on lines +79 to +96
cp "${EXAMPLE_FILE}" "${SECRETS_FILE}"
chmod 600 "${SECRETS_FILE}"

if ! sops --encrypt --in-place "${SECRETS_FILE}"; then
rm -f "${SECRETS_FILE}"
die "encryption failed — removed the partial file rather than leave
plaintext credentials sitting at a path that looks encrypted.

Check that .sops.yaml lists a valid age recipient:
grep -A2 creation_rules ${SOPS_CONFIG}"
fi

# A plaintext file at this path would be committed as if it were encrypted,
# and CI only catches that after the push. Verify before claiming success.
if ! grep -q '^sops:' "${SECRETS_FILE}"; then
rm -f "${SECRETS_FILE}"
die "sops reported success but produced no encrypted output — file removed"
fi
…ecks

Review feedback, and correct: cleanup only covered the two failures explicitly
checked. Between the `cp` and a verified encryption the file is plaintext at a
path that is meant to be committed and whose name says "encrypted" — and it is
deliberately not gitignored, so anything leaving it behind is a leak waiting to
be committed. A chmod failure under `set -e`, a Ctrl-C mid-encrypt, or a SIGTERM
all skipped the rm.

A trap on EXIT INT TERM is now armed immediately after the cp and cleared only
once the sops metadata block is confirmed present.

Verified in an isolated sandbox with its own HOME and a disposable keypair:

  * happy path still produces an encrypted file
  * a broken age recipient leaves nothing behind
  * SIGINT delivered to the script's own process group mid-encrypt — a real
    Ctrl-C — leaves nothing behind: present before the signal, absent after

Three earlier attempts at that last test were inconclusive rather than passing,
which is worth recording because each one looked like a result:

  * signalling a process group that, in a non-interactive shell, also contained
    the test harness
  * sending SIGINT to the script's pid, where bash defers the handler until the
    foreground command returns — sops completed and the file legitimately
    survived encrypted, which reads as a leak unless you check the contents
  * a setsid run whose $! had already exited, so the kill addressed an empty
    pgid and no signal was ever sent

Only the setsid + pgrep-resolved pgid version actually exercises the path.
@Gerrrt
Gerrrt merged commit 45d9426 into main Aug 2, 2026
3 checks passed
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.

2 participants