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"