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.
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user