# 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"