From a896c0a5a9115eee2325a1c804aebf8a5a10b3cb Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 23 Aug 2026 03:09:13 -0700 Subject: [PATCH] feat(playbooks): sweep world-readable secret-bearing .env files to 0600 Eight stacks on ana-docker kept secret-bearing compose .env files at mode 0644 -- readable by every local account on a box with four interactive users. Verified as a real exposure, not a theoretical one, by reading one as `nobody`. Six other stacks already used 0600, so this converges on the existing house pattern rather than inventing one. Swept (all verified mode 600, unreadable by nobody, compose config still rendering as the deploy user, container untouched): vaultwarden traefik beszel gitea-runner miniflux news-digest searxng vor Post-sweep the host has zero secret-bearing .env readable by nobody. Checked before writing the playbook, and re-checked by the playbook itself so a bad case cannot be swept in by accident: - every target is owned by lkraven, the deploy user, so 0600 preserves the deploy path (asserted by rendering compose config AS lkraven, not root -- checking the mode proves the bits changed, only rendering as the deploy user proves the next deploy still resolves its variables) - none is bind-mounted into a container. vaultwarden looked like it was; that `- .env` is under `env_file:`, not `volumes:`. A genuinely bind-mounted .env is read by the container UID and 0600 could break it, so the playbook refuses that case. Two gotchas recorded in the file: - elway's variable regex eats any bare identifier in braces, so a docker --format Go template containing {{end}} or {{println}} fails as an undefined variable. Grep the raw inspect JSON instead. - the `ana-docker` ssh alias resolves to lkraven, who needs a sudo password; run elway against infra-ops@10.250.50.70 for NOPASSWD. --- playbooks/tighten-env-perms.yaml | 105 +++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 playbooks/tighten-env-perms.yaml diff --git a/playbooks/tighten-env-perms.yaml b/playbooks/tighten-env-perms.yaml new file mode 100644 index 0000000..c416eb2 --- /dev/null +++ b/playbooks/tighten-env-perms.yaml @@ -0,0 +1,105 @@ +# Tighten a world-readable compose `.env` that holds secrets to 0600. +# +# WHY: found 2026-08-23 on ana-docker. Eight stacks kept secret-bearing .env +# files at mode 0644 — readable by every local account on the box (verified by +# reading one as `nobody`; the host has four interactive users). Six other +# stacks already used 0600, so this is converging on the existing house +# pattern rather than inventing one. +# +# Usage — one run per stack: +# scripts/elway ana-docker --playbook playbooks/tighten-env-perms.yaml \ +# --var stack=vaultwarden +# +# SAFE BECAUSE, verified before writing this: +# - every target .env is owned by lkraven, and lkraven is the deploy user, +# so 0600 preserves the deploy path +# - none of them is bind-mounted INTO a container. They are consumed either +# by `env_file:` or by `${VAR}` interpolation, both of which docker +# compose reads at deploy time as the invoking user. A .env that WERE +# bind-mounted would be read by the container's own UID and 0600 could +# break it — check for that before adding a stack to this sweep. +# - chmod does not touch a running container; env is injected at create. +# +# The playbook re-checks ownership itself and refuses if it is not lkraven, +# so a stack that does not fit the above cannot be swept in by accident. + +vars: + stack: "" + compose_root: /opt/docker/compose + expect_owner: lkraven + +steps: + - name: Refuse to run without an explicit stack + shell: test -n "{{ stack }}" + changed_when: "false" + + - name: Target .env exists + sudo: true + shell: test -f {{ compose_root }}/{{ stack }}/.env + changed_when: "false" + + - name: Owner is the deploy user (else 0600 would break deploys) + sudo: true + shell: | + own=$(stat -c %U {{ compose_root }}/{{ stack }}/.env) + test "$own" = "{{ expect_owner }}" || { + echo "REFUSING: .env is owned by $own, not {{ expect_owner }} — 0600 would lock the deploy user out" + exit 1; } + echo "owner ok: $own" + changed_when: "false" + + - name: Not bind-mounted into a container (that would be read by the container UID) + sudo: true + # Greps the raw inspect JSON rather than using a Go range template: + # elway's variable regex matches any bare identifier in braces, so + # `{{end}}` and `{{println}}` get eaten as undefined variables. Anything + # starting with a dot (`{{.Source}}`) or containing a space + # (`{{json .Mounts}}`) passes through, but plain grep avoids the whole + # class of trap. + shell: | + if docker inspect {{ stack }} 2>/dev/null | grep -qE '"Source": *"[^"]*/\.env"'; then + echo "REFUSING: {{ stack }} bind-mounts its .env; 0600 may break the container" + exit 1 + fi + echo "no .env bind mount" + changed_when: "false" + + - name: Tighten to 0600 + sudo: true + # Skipped when already 0600, so a re-run reports OK instead of a phantom + # CHANGED and the sweep is safe to run repeatedly. + when: "test \"$(sudo stat -c %a {{ compose_root }}/{{ stack }}/.env)\" != \"600\"" + shell: | + before=$(stat -c %a {{ compose_root }}/{{ stack }}/.env) + chmod 600 {{ compose_root }}/{{ stack }}/.env + echo "{{ stack }}: $before -> 600" + changed_when: "true" + +verify: + - name: Mode is 0600 and the file is no longer world-readable + sudo: true + shell: | + m=$(stat -c %a {{ compose_root }}/{{ stack }}/.env) + test "$m" = "600" || { echo "mode is $m, expected 600"; exit 1; } + if sudo -u nobody test -r {{ compose_root }}/{{ stack }}/.env 2>/dev/null; then + echo "STILL readable by nobody"; exit 1; fi + echo "mode 600, not readable by nobody" + changed_when: "false" + + - name: The deploy user can still read it — compose renders as lkraven + # The assertion that matters. Checking the mode proves the bits changed; + # only rendering the compose file as the DEPLOY user proves the next + # deploy can still resolve its variables. + sudo: true + shell: | + su -s /bin/bash -c 'cd {{ compose_root }}/{{ stack }} && docker compose config >/dev/null' {{ expect_owner }} \ + && echo "compose config OK as {{ expect_owner }}" \ + || { echo "COMPOSE CONFIG FAILED as {{ expect_owner }} — reverting is: chmod 644"; exit 1; } + changed_when: "false" + + - name: Nothing restarted + sudo: true + shell: | + docker ps --filter "name={{ stack }}" --format '{{.Names}} {{.Status}}' | head -3 + echo "(a chmod cannot restart a container; this is a sanity line, not a gate)" + changed_when: "false"