fix(docker): inicializar permissoes do storage com seguranca
This commit is contained in:
1 parent
e142a44c01
commit
2be97ea969
8 files changed
+421
-3
No files matched your search
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# Permissões do armazenamento local (volume montado em /app/.data).
|
||||
#
|
||||
# Cada caso cria o próprio volume descartável com prefixo único e remove só os
|
||||
# volumes que criou. Nunca toca nos volumes do compose
|
||||
# (adaptador-planilhas-local-postgres, adaptador-planilhas-local-storage).
|
||||
#
|
||||
# ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
|
||||
set -euo pipefail
|
||||
# Git Bash no Windows converteria /app/... em caminho do Windows.
|
||||
export MSYS_NO_PATHCONV=1
|
||||
|
||||
IMAGE="${ADAPTER_IMAGE:-adaptador-planilhas:local}"
|
||||
PREFIX="adaptador-storage-test-$$-$RANDOM"
|
||||
CREATED=()
|
||||
CONTAINERS=()
|
||||
|
||||
cleanup() {
|
||||
for c in "${CONTAINERS[@]:-}"; do [ -n "$c" ] && docker rm -f "$c" >/dev/null 2>&1 || true; done
|
||||
for v in "${CREATED[@]:-}"; do [ -n "$v" ] && docker volume rm "$v" >/dev/null 2>&1 || true; done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Define VOL no shell atual: dentro de $(...) o registro em CREATED se perderia.
|
||||
new_volume() {
|
||||
VOL="$PREFIX-$1"
|
||||
docker volume create "$VOL" >/dev/null
|
||||
CREATED+=("$VOL")
|
||||
}
|
||||
|
||||
# Mesmas restrições do serviço storage-init do docker-compose.yml.
|
||||
prepare() {
|
||||
docker run --rm --network none --user 0:0 \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
|
||||
--cap-add DAC_READ_SEARCH --cap-add SETUID --cap-add SETGID \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v "$1:/app/.data" "$IMAGE" prepare-storage "${@:2}"
|
||||
}
|
||||
|
||||
as_app() { local v="$1"; shift; docker run --rm --network none -v "$v:/app/.data" "$IMAGE" exec "$@"; }
|
||||
as_root() { local v="$1"; shift; docker run --rm --network none --user 0:0 -v "$v:/app/.data" "$IMAGE" exec "$@"; }
|
||||
|
||||
# Estado do volume como root: dono, grupo, modo, tamanho e caminho, mais o hash dos arquivos.
|
||||
snapshot() {
|
||||
as_root "$1" sh -c 'cd /app/.data && find . -printf "%U:%G %m %s %p\n" | sort && find . -type f -exec sha256sum {} + | sort -k2'
|
||||
}
|
||||
|
||||
expect_owner_mode() {
|
||||
local v="$1" path="$2" want="$3"
|
||||
local got
|
||||
got=$(as_root "$v" stat -c '%u:%g %a' "$path")
|
||||
[ "$got" = "$want" ] || { echo "ERRO: $path = '$got', esperado '$want'"; exit 1; }
|
||||
}
|
||||
|
||||
fail() { echo "ERRO: $*"; exit 1; }
|
||||
|
||||
echo "== imagem: $IMAGE =="
|
||||
docker image inspect "$IMAGE" >/dev/null
|
||||
|
||||
echo "== A. volume novo =="
|
||||
new_volume a; VA="$VOL"
|
||||
# A1: sem storage-init, o volume vazio herda dono e modo da imagem.
|
||||
expect_owner_mode "$VA" /app/.data "1001:1001 750"
|
||||
expect_owner_mode "$VA" /app/.data/storage "1001:1001 750"
|
||||
as_app "$VA" sh -c ': > /app/.data/storage/ok && rm /app/.data/storage/ok' || fail "uid 1001 não escreve no volume novo"
|
||||
# A2: com storage-init, o mesmo resultado e a prova de escrita.
|
||||
prepare "$VA" | tee /tmp/storage-a.log
|
||||
grep -q "STORAGE_WRITABLE" /tmp/storage-a.log || fail "storage-init não confirmou escrita"
|
||||
echo "ok: volume novo 1001:1001 750, escrita pelo uid 1001"
|
||||
|
||||
echo "== B. volume existente, criado antes da correção (raiz root:root 755) =="
|
||||
new_volume b; VB="$VOL"
|
||||
as_root "$VB" sh -c '
|
||||
chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage
|
||||
mkdir -p /app/.data/storage/ws-alpha/modelos
|
||||
printf "conteudo do modelo alpha\n" > /app/.data/storage/ws-alpha/modelos/modelo.xlsx
|
||||
chown -R 1001:1001 /app/.data/storage/ws-alpha
|
||||
printf "arquivo legado de root\n" > /app/.data/storage/legado.bin
|
||||
chmod 640 /app/.data/storage/legado.bin'
|
||||
# Antes da preparação o uid 1001 não escreve na raiz: é o defeito original.
|
||||
if as_app "$VB" sh -c ': > /app/.data/storage/x' 2>/dev/null; then fail "o cenário B deveria começar sem escrita"; fi
|
||||
prepare "$VB" 2>&1 | tee /tmp/storage-b.log
|
||||
grep -q "STORAGE_WRITABLE" /tmp/storage-b.log || fail "storage-init não confirmou escrita"
|
||||
grep -q "AVISO: 1 item" /tmp/storage-b.log || fail "o arquivo de root deveria ser só reportado"
|
||||
expect_owner_mode "$VB" /app/.data "1001:1001 750"
|
||||
expect_owner_mode "$VB" /app/.data/storage "1001:1001 750"
|
||||
# Conteúdo e dono dos arquivos existentes intactos; só os dois diretórios mudaram.
|
||||
expect_owner_mode "$VB" /app/.data/storage/legado.bin "0:0 640"
|
||||
expect_owner_mode "$VB" /app/.data/storage/ws-alpha/modelos/modelo.xlsx "1001:1001 644"
|
||||
test "$(as_root "$VB" cat /app/.data/storage/ws-alpha/modelos/modelo.xlsx)" = "conteudo do modelo alpha" \
|
||||
|| fail "conteúdo do modelo alterado"
|
||||
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo legado alterado"
|
||||
as_app "$VB" sh -c ': > /app/.data/storage/novo && rm /app/.data/storage/novo' || fail "uid 1001 não escreve após preparar"
|
||||
echo "ok: volume existente preservado e liberado para o uid 1001"
|
||||
|
||||
echo "== C. idempotência =="
|
||||
s1=$(snapshot "$VB")
|
||||
prepare "$VB" > /tmp/storage-c1.log 2>&1
|
||||
prepare "$VB" > /tmp/storage-c2.log 2>&1
|
||||
s2=$(snapshot "$VB")
|
||||
[ "$s1" = "$s2" ] || { diff <(echo "$s1") <(echo "$s2") || true; fail "a preparação repetida alterou o volume"; }
|
||||
if grep -q -- "->" /tmp/storage-c2.log; then fail "a segunda execução não deveria alterar dono nem modo"; fi
|
||||
echo "ok: duas execuções extras, nenhum byte, dono ou modo alterado"
|
||||
|
||||
echo "== C2. --fix-existing é limitado ao volume e preserva conteúdo =="
|
||||
prepare "$VB" --fix-existing > /tmp/storage-c3.log 2>&1
|
||||
expect_owner_mode "$VB" /app/.data/storage/legado.bin "1001:1001 640"
|
||||
test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo alterado"
|
||||
if grep -q "AVISO" /tmp/storage-c3.log; then fail "ainda há item com outro dono"; fi
|
||||
echo "ok: dono ajustado só quando pedido, conteúdo intacto"
|
||||
|
||||
echo "== D. web e worker compartilham o volume =="
|
||||
new_volume d; VD="$VOL"
|
||||
prepare "$VD" >/dev/null
|
||||
# Um contêiner grava como a web grava, outro lê e apaga como o worker faz na limpeza.
|
||||
as_app "$VD" sh -c 'mkdir -p /app/.data/storage/ws/jobs && printf saida > /app/.data/storage/ws/jobs/out.csv'
|
||||
test "$(as_app "$VD" cat /app/.data/storage/ws/jobs/out.csv)" = "saida" || fail "outro contêiner não lê o arquivo"
|
||||
as_app "$VD" rm /app/.data/storage/ws/jobs/out.csv || fail "outro contêiner não apaga o arquivo"
|
||||
# O comando web real passa da verificação e responde liveness.
|
||||
WEB="$PREFIX-web"
|
||||
CONTAINERS+=("$WEB")
|
||||
docker run -d --name "$WEB" --network none -e ADAPTER_ENV=development -v "$VD:/app/.data" "$IMAGE" web >/dev/null
|
||||
ok=""
|
||||
for _ in $(seq 1 30); do
|
||||
if docker exec "$WEB" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[ -n "$ok" ] || { docker logs "$WEB"; fail "a web não subiu com o volume preparado"; }
|
||||
docker logs "$WEB" 2>&1 | grep -q STORAGE_NOT_WRITABLE && fail "a web reportou storage sem escrita"
|
||||
# O comando worker real passa da verificação de storage (falha depois, por falta de banco/Redis, o que não é deste teste).
|
||||
WORKER="$PREFIX-worker"
|
||||
CONTAINERS+=("$WORKER")
|
||||
docker run -d --name "$WORKER" --network none -e ADAPTER_ENV=development -e ADAPTER_JOB_MODE=worker \
|
||||
-v "$VD:/app/.data" "$IMAGE" worker >/dev/null
|
||||
sleep 6
|
||||
wlog=$(docker logs "$WORKER" 2>&1)
|
||||
docker rm -f "$WORKER" >/dev/null
|
||||
echo "$wlog" | grep -q STORAGE_NOT_WRITABLE && { echo "$wlog"; fail "o worker reportou storage sem escrita"; }
|
||||
echo "$wlog" | grep -q '\[worker\]\|DATABASE\|Redis\|ECONNREFUSED\|pglite\|migration' \
|
||||
|| { echo "$wlog"; fail "o worker não chegou ao código da aplicação"; }
|
||||
echo "ok: escrita, leitura e remoção entre contêineres; web e worker passam da verificação"
|
||||
|
||||
echo "== E. usuário não privilegiado =="
|
||||
test "$(docker exec "$WEB" id -u)" = "1001" || fail "a web não roda como 1001"
|
||||
test "$(docker exec "$WEB" sh -c 'grep CapEff /proc/1/status | cut -f2')" = "0000000000000000" || fail "a web tem capacidades"
|
||||
test "$(docker run --rm --network none "$IMAGE" exec id -u)" = "1001" || fail "o usuário padrão da imagem não é 1001"
|
||||
# prepare-storage recusa rodar sem root, então não serve de atalho para escalar.
|
||||
if docker run --rm --network none -v "$VD:/app/.data" "$IMAGE" prepare-storage >/dev/null 2>&1; then
|
||||
fail "prepare-storage aceitou rodar como 1001"
|
||||
fi
|
||||
docker rm -f "$WEB" >/dev/null
|
||||
test "$(docker image inspect "$IMAGE" --format "{{.Config.User}}")" = "adaptador" || fail "a imagem não declara USER adaptador"
|
||||
echo "ok: web e worker como 1001 sem capacidades; preparação só como root"
|
||||
|
||||
echo "== F. falha segura com armazenamento sem escrita =="
|
||||
new_volume f; VF="$VOL"
|
||||
as_root "$VF" sh -c 'chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage'
|
||||
for proc in web worker; do
|
||||
set +e
|
||||
out=$(docker run --rm --network none -e ADAPTER_ENV=development -v "$VF:/app/.data" "$IMAGE" "$proc" 2>&1)
|
||||
code=$?
|
||||
set -e
|
||||
[ "$code" -ne 0 ] || fail "$proc subiu com storage sem escrita"
|
||||
echo "$out" | grep -q STORAGE_NOT_WRITABLE || { echo "$out"; fail "$proc não explicou a falha"; }
|
||||
echo " $proc: saiu com código $code e STORAGE_NOT_WRITABLE"
|
||||
done
|
||||
# Com Supabase configurado o volume local não é usado e a verificação não bloqueia.
|
||||
SUPA="$PREFIX-supabase"
|
||||
CONTAINERS+=("$SUPA")
|
||||
docker run -d --name "$SUPA" --network none -e ADAPTER_ENV=development \
|
||||
-e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.invalid -e SUPABASE_SERVICE_ROLE_KEY=x -e SUPABASE_STORAGE_BUCKET=x \
|
||||
-v "$VF:/app/.data" "$IMAGE" web >/dev/null
|
||||
ok=""
|
||||
for _ in $(seq 1 30); do
|
||||
if docker exec "$SUPA" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
slog=$(docker logs "$SUPA" 2>&1)
|
||||
docker rm -f "$SUPA" >/dev/null
|
||||
[ -n "$ok" ] || { echo "$slog"; fail "a web não subiu com bucket configurado"; }
|
||||
echo "$slog" | grep -q STORAGE_NOT_WRITABLE && { echo "$slog"; fail "a verificação bloqueou com bucket configurado"; }
|
||||
echo "ok: falha explícita sem storage; sem bloqueio quando o bucket é o Supabase"
|
||||
|
||||
echo ""
|
||||
echo "PERMISSÕES DO ARMAZENAMENTO: OK (A, B, C, D, E, F)"
|
||||
@@ -254,6 +254,9 @@ jobs:
|
||||
http://127.0.0.1:3000/api/dev/session)" = "404"
|
||||
docker rm -f ci-web
|
||||
|
||||
- name: Permissões do armazenamento local (volume novo, existente, idempotência, falha segura)
|
||||
run: bash .github/scripts/storage-permissions.sh
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Topologia completa: web + worker + Postgres + Redis
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user