Files
vh a896c0a5a9 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.
2026-08-23 03:09:13 -07:00

106 lines
4.4 KiB
YAML

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