#!/usr/bin/env bash
#
# tools/security-check.sh — baseline de segurança day-zero (ADR-020/021/022).
#
# Cobre o que **não** é um comportamento de feature: rate limiting em 3 camadas, cadeia de proxy
# (IP real), cookies/sessão, debug/erros, headers do edge, limites de corpo e exposição de arquivos.
# Fonte única para o `make check` e para o `gates-selftest` (aceita uma raiz para os fixtures).
#
# Uso: tools/security-check.sh [RAIZ]
set -Eeuo pipefail

ROOT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
cd "$ROOT"

# Caminhos dos artefatos de runtime verificados aqui. São **overridable por env** porque o
# `gates-selftest` injeta a regressão no arquivo real (copia derivada na hora) em vez de
# duplicar o template inteiro num fixture — duplicar cria drift silencioso.
CADDYFILE="${SECURITY_CADDYFILE:-docker/web/Caddyfile}"
APACHE_CONF="${SECURITY_APACHE_CONF:-docker/web/app.conf}"
APACHE_SSL_CONF="${SECURITY_APACHE_SSL_CONF:-docker/web/app-ssl.conf}"
PHP_INI="${SECURITY_PHP_INI:-docker/php-fpm/php.ini}"
FPM_CONF="${SECURITY_FPM_CONF:-docker/php-fpm/zz-custom.conf}"
COMPOSE_FILE="${SECURITY_COMPOSE:-docker-compose.yml}"
PG_CONF="${SECURITY_PG_CONF:-docker/postgres/postgresql.conf}"
VALKEY_CONF="${SECURITY_VALKEY_CONF:-docker/valkey/valkey.conf}"
VALKEY_ENTRYPOINT="${SECURITY_VALKEY_ENTRYPOINT:-docker/valkey/entrypoint.sh}"
CONSOLE_FILE="${SECURITY_CONSOLE:-routes/console.php}"
DEPLOY_SCRIPT="${SECURITY_DEPLOY_SCRIPT:-scripts/update/05-build.sh}"
DRILL_SCRIPT="${SECURITY_DRILL_SCRIPT:-tools/db-restore-drill.sh}"
DOCKERFILE="${SECURITY_DOCKERFILE:-docker/php-fpm/Dockerfile}"
ADR_IMAGES="${SECURITY_ADR_IMAGES:-wiki/adr/ADR-031-imagens-bases-pinadas.md}"

rc=0

fail() { echo "security-check: $*" >&2; rc=1; }

require_file() { [ -f "$1" ] || fail "arquivo ausente: $1"; }

require_text() { # <arquivo> <texto> <descrição>
    [ -f "$1" ] || {
        fail "arquivo ausente: $1"
        return 0
    }
    grep -Fq -- "$2" "$1" || fail "$1: falta $3"
}

deny_text() { # <arquivo> <texto> <descrição>
    [ -f "$1" ] || return 0
    if grep -Fq -- "$2" "$1"; then
        fail "$1: proibido — $3"
    fi
}

deny_key() { # <arquivo> <chave> <descrição> — chave de YAML no início da linha
    # Um gate de armadilha não pode tropeçar no **comentário** que documenta a armadilha:
    # aqui só conta a chave declarada (início da linha, antes de qualquer `:`).
    [ -f "$1" ] || return 0
    if grep -qE "^[[:space:]]*${2}:" "$1"; then
        fail "$1: proibido — $3"
    fi
}

deny_code() { # <arquivo> <texto> <descrição> — ignora linhas de comentário
    # Mesmo espírito do `deny_key`: só o **código** é proibido; documentar a armadilha
    # no comentário é o comportamento correto.
    [ -f "$1" ] || return 0
    if grep -vE '^[[:space:]]*#' "$1" | grep -Fq -- "$2"; then
        fail "$1: proibido — $3"
    fi
}

require_code() { # <arquivo> <texto> <descrição> — exige o texto no CÓDIGO (não no comentário)
    [ -f "$1" ] || {
        fail "arquivo ausente: $1"
        return 0
    }
    grep -vE '^[[:space:]]*#' "$1" | grep -Fq -- "$2" || fail "$1: falta $3 (no código)"
}

compose_service_code() { # <serviço> — o bloco do serviço no Compose, **sem comentários**
    # O comentário pode citar a chave ("sem cap_drop: [ALL]") — o gate tem de olhar o
    # código, não a prosa.
    sed -n "/^  ${1}:/,/^  [a-z][a-z0-9-]*:/p" "$COMPOSE_FILE" | grep -vE '^[[:space:]]*#'
}

require_ini() { # <arquivo> <texto> <descrição> — como require_code, mas o comentário do INI é ';'
    [ -f "$1" ] || {
        fail "arquivo ausente: $1"
        return 0
    }
    grep -vE '^[[:space:]]*[;#]' "$1" | grep -Fq -- "$2" || fail "$1: falta $3 (no código)"
}

# ── 1. Aplicação: rate limiting + IP real (ADR-021) ─────────────────────────────────
require_text bootstrap/app.php 'throttleWithRedis(' 'throttleWithRedis() (store Valkey)'
require_text src/core/CoreServiceProvider.php "RateLimiter::for('open-data'" "limitador 'open-data'"
require_text src/core/CoreServiceProvider.php "RateLimiter::for('lgpd-dsar'" "limitador 'lgpd-dsar'"
require_text src/core/Internal/Http/Middleware/ThrottlePanelLogin.php 'Retry-After' 'Retry-After no login'
require_text app/Providers/AppServiceProvider.php 'TrustProxies::at(' 'TrustProxies (IP real atrás do edge)'
require_text app/Providers/AppServiceProvider.php 'TrustHosts::at(' 'TrustHosts'
require_text config/app.php "'trusted_proxies' =>" 'config trusted_proxies'
require_text config/app.php "'trusted_hosts' =>" 'config trusted_hosts'

# ── 2. Perfil de produção (APP_ENV = único interruptor) ─────────────────────────────
require_file config/environments.php
php -r 'function env($k, $d = null) { return $d; }
$p = require "config/environments.php";
$safe = $p["name"] === "production"
    && $p["debug"] === false
    && $p["force_https"] === true
    && $p["session_secure"] === true
    && $p["session_http_only"] === true
    && $p["session_same_site"] === "lax"
    && $p["security_cookies"] === true;
exit($safe ? 0 : 1);' \
    || fail 'config/environments.php: production sem debug=false/HTTPS/cookies seguros'

# ── 3. PHP/runtime: erros fora da resposta, sessão endurecida ───────────────────────
require_text "$PHP_INI" 'expose_php = Off' 'expose_php = Off'
require_text "$PHP_INI" 'display_errors = Off' 'display_errors = Off'
require_text "$PHP_INI" 'display_startup_errors = Off' 'display_startup_errors = Off'
require_text "$PHP_INI" 'log_errors = On' 'log_errors = On'
require_text "$PHP_INI" 'session.use_strict_mode = 1' 'session.use_strict_mode'
require_text "$PHP_INI" 'session.cookie_httponly = 1' 'session.cookie_httponly'
require_text "$PHP_INI" 'session.cookie_secure = 1' 'session.cookie_secure'
require_text "$PHP_INI" 'session.cookie_samesite = Lax' 'session.cookie_samesite'
require_text "$PHP_INI" 'session.upload_progress.enabled = Off' 'session.upload_progress.enabled = Off (CVE-2026-109619)'
require_text "$PHP_INI" 'session.use_only_cookies = 1' 'session.use_only_cookies'
require_text "$PHP_INI" 'session.use_trans_sid = 0' 'session.use_trans_sid'
require_text "$FPM_CONF" 'php_admin_flag[session.upload_progress.enabled] = off' 'trava do upload progress no pool (CVE-2026-109619)'
require_text docker/php-fpm/entrypoint.sh 'session.upload_progress.enabled=${upload_progress}' 'entrypoint falha se upload progress religar'
# O digest 8.4.26 é o runtime afetado por CVE-2026-109618/109619. O gate impede
# que um bump acidental volte para ele; o bump para 8.4.28+ continua permitido.
deny_text "$DOCKERFILE" 'sha256:1071b5609d16d60674f78eea8cf9bd7e9e79169ecb566d015c931c5ee789bb13' 'base PHP 8.4.26 vulnerável (php2shell)'
require_text docker/php-fpm/entrypoint.sh 'DISPLAY_ERRORS=Off' 'perfil production/homolog com display_errors=Off'
require_text docker/php-fpm/entrypoint.sh 'COOKIE_SECURE=1' 'perfil production/homolog com cookie_secure=1'
deny_text composer.json 'laravel-debugbar' 'debugbar (não usar em produção)'

# ── 4. Borda Apache (camada 1: volumétrico/slowloris/corpo) ─────────────────────────
# Os templates são fixtures-áveis por env (o `gates-selftest` injeta a regressão no arquivo
# real sem duplicar o template inteiro nos fixtures).
require_text "$APACHE_CONF" 'ProxyPass        /.well-known/acme-challenge !' 'ProxyPass do acme-challenge (AutoSSL)'
require_text "$APACHE_CONF" 'ProxyPass        /.well-known/pki-validation !' 'ProxyPass do pki-validation (DCV)'
# G-01: o caminho amplo esconde `/.well-known/security.txt` do app (404 em produção).
deny_text "$APACHE_CONF" 'ProxyPass        /.well-known !' 'ProxyPass /.well-known ! (amplo; esconde security.txt)'
require_text "$APACHE_CONF" 'AllowOverride None' 'AllowOverride None'
require_text "$APACHE_CONF" 'Options -Indexes' 'Options -Indexes (listagem)'
# G-04: falha segura — sem o proxy, o Apache não pode servir `.env`/`vendor/`/`storage/`.
require_text "$APACHE_CONF" 'Require all denied' 'Directory deny-by-default (falha segura do proxy)'
require_text "$APACHE_CONF" '/home/<acct>/public_html/.well-known/acme-challenge>' 'Directory do acme-challenge'
require_text "$APACHE_CONF" '/home/<acct>/public_html/.well-known/pki-validation>' 'Directory do pki-validation'
require_text "$APACHE_CONF" 'TraceEnable Off' 'TraceEnable Off'
require_text "$APACHE_CONF" 'RemoteIPHeader X-Forwarded-For' 'RemoteIPHeader'
require_text "$APACHE_CONF" 'RemoteIPInternalProxy 127.0.0.1' 'RemoteIPInternalProxy'
require_text "$APACHE_CONF" 'RequestReadTimeout' 'RequestReadTimeout (slowloris)'
require_text "$APACHE_CONF" 'LimitRequestBody' 'LimitRequestBody'
require_text "$APACHE_CONF" 'LimitRequestLine' 'LimitRequestLine'
require_text "$APACHE_CONF" 'LimitRequestFields' 'LimitRequestFields'
require_text "$APACHE_CONF" 'LimitRequestFieldSize' 'LimitRequestFieldSize'
require_text "$APACHE_CONF" 'QS_SrvMaxConnPerIP' 'mod_qos (conexões/IP)'
require_text "$APACHE_CONF" 'DOSPageCount' 'mod_evasive (requisições/IP)'

# O Apache só aceita `#` no **começo** da linha: um comentário inline vira argumento da
# diretiva e derruba o `configtest` no WHM (`whm-canary --self-test` prova o mecanismo).
for apache_file in "$APACHE_CONF" "$APACHE_SSL_CONF"; do
    if grep -nE '^[[:space:]]*[^#[:space:]].*[[:space:]]#' "$apache_file" >/dev/null 2>&1; then
        fail "comentário inline em $(basename "$apache_file") (Apache recusa a diretiva)"
    fi
done

dir_allows() { # <arquivo> <diretório> — o bloco <Directory> contém `Require all granted`
    awk -v dir="$2" '
        !inside && index($0, "<Directory " dir ">") { inside = 1; next }
        inside && /<\/Directory>/ { exit }
        inside && /Require all granted/ { found = 1 }
        END { exit(found ? 0 : 1) }
    ' "$1"
}

for dcv in '/home/<acct>/public_html/.well-known/acme-challenge' \
           '/home/<acct>/public_html/.well-known/pki-validation'; do
    dir_allows "$APACHE_CONF" "$dcv" || fail "$APACHE_CONF: $dcv sem Require all granted (DCV inalcançável)"
done

acme="$(grep -nF 'ProxyPass        /.well-known/acme-challenge !' "$APACHE_CONF" | head -1 | cut -d: -f1 || true)"
pki="$(grep -nF 'ProxyPass        /.well-known/pki-validation !' "$APACHE_CONF" | head -1 | cut -d: -f1 || true)"
app="$(grep -nE '^[[:space:]]*ProxyPass[[:space:]]+/[[:space:]]' "$APACHE_CONF" | head -1 | cut -d: -f1 || true)"

if [ -z "$acme" ] || [ -z "$pki" ] || [ -z "$app" ] || [ "$acme" -ge "$app" ] || [ "$pki" -ge "$app" ]; then
    fail "$APACHE_CONF: ProxyPass de acme-challenge/pki-validation precisa vir antes de ProxyPass /"
fi

# G-03/G-05: o gêmeo SSL é um arquivo distinto (proto + HSTS); o Caddy nunca emite HSTS.
require_file "$APACHE_SSL_CONF"
require_text "$APACHE_SSL_CONF" 'RequestHeader set X-Forwarded-Proto "https"' 'X-Forwarded-Proto no escopo SSL'
require_text "$APACHE_SSL_CONF" 'Strict-Transport-Security "max-age=31536000; includeSubDomains"' 'HSTS (1 ano + includeSubDomains)'
deny_text "$APACHE_SSL_CONF" 'includeSubDomains; preload' 'HSTS com preload antes de todos os subdomínios em HTTPS'
deny_text "$CADDYFILE" 'Strict-Transport-Security' 'HSTS emitido pelo Caddy (dono: Apache/app-ssl.conf)'

# ── 5. Borda Caddy (headers, timeouts, limite de corpo, cPanel) ─────────────────────
require_text "$CADDYFILE" 'trusted_proxies static private_ranges' 'trusted_proxies'
require_text "$CADDYFILE" 'trusted_proxies_strict' 'trusted_proxies_strict'
require_text "$CADDYFILE" 'timeouts {' 'timeouts'
require_text "$CADDYFILE" 'read_header 5s' 'timeout read_header'
require_text "$CADDYFILE" 'idle 5m' 'timeout idle'
require_text "$CADDYFILE" 'request_body {' 'request_body'
require_text "$CADDYFILE" 'max_size 110MiB' 'limite de corpo (110MiB = maior hard de mídia)'
require_text "$CADDYFILE" 'X-Content-Type-Options nosniff' 'X-Content-Type-Options'
require_text "$CADDYFILE" 'Referrer-Policy strict-origin-when-cross-origin' 'Referrer-Policy'
require_text "$CADDYFILE" 'Permissions-Policy' 'Permissions-Policy'
require_text "$CADDYFILE" 'payment=(), usb=()' 'Permissions-Policy payment/usb'
require_text "$CADDYFILE" 'X-Frame-Options SAMEORIGIN' 'X-Frame-Options'
require_text "$CADDYFILE" 'header @embed -X-Frame-Options' 'XFO removido no embed'
require_text "$CADDYFILE" 'X-XSS-Protection "0"' 'X-XSS-Protection: 0'
require_text "$CADDYFILE" 'Cross-Origin-Opener-Policy same-origin' 'COOP'
require_text "$CADDYFILE" 'Cross-Origin-Resource-Policy same-origin' 'CORP (app)'
require_text "$CADDYFILE" 'Cross-Origin-Resource-Policy cross-origin' 'CORP (api)'
require_text "$CADDYFILE" 'Content-Security-Policy' 'CSP'
require_text "$CADDYFILE" 'https://www.vlibras.gov.br' 'CSP permite VLibras'
require_text "$CADDYFILE" 'https://www.googletagmanager.com' 'CSP permite GA (script)'
require_text "$CADDYFILE" 'https://*.google-analytics.com' 'CSP permite GA (beacon)'
require_text "$CADDYFILE" 'max-age=31536000, immutable' 'assets imutáveis'
require_text "$CADDYFILE" '@forbidden path' 'bloqueio de dotfiles/*.ini/*.log'
require_text "$CADDYFILE" 'hide .user.ini .htaccess error_log' 'hide dos artefatos do cPanel'

# ── 5b. Cadeia de timeout do upload (D0) ───────────────────────────────────────────
# O defeito medido: o Caddy cortava o upload em ~10 s (`read_body 10s`) e o limite de
# 110 MiB declarado em três camadas (ADR-026) era inalcançável. Subir um prazo sem os
# outros **troca o sintoma** (504 → HTTP 100) sem resolver — por isso este gate confere a
# **ORDEM** dos prazos entre as 4 camadas, não só a presença deles.
#
# A janela de 300 s não é chutada: o maior upload legítimo é PDF 100 MiB (ADR-026) e o
# cliente lento legítimo (500 KiB/s) precisa de ≈225 s para entregá-lo ⇒ 300 s com folga.
# O `MinRate=500` do Apache é o **piso anti-drip** (bytes/s), não a taxa legítima: ele
# mata quem envia <500 B/s, mas não limita quem envia 500 KiB/s.
#
# Invariantes (todas em segundos):
#   (1) apache ≥ caddy.read_body ≥ php.max_input_time        — o corpo atravessa as 3
#   (2) caddy.write ≥ caddy.read_body                          — a resposta tem a mesma janela
#   (3) caddy.fastcgi.read_timeout ≥ fpm.request_terminate_timeout ≥ max_input+max_exec
conf_num() { # <arquivo> <char-de-comentário> <regex-da-linha> <regex-do-valor> → primeiro número
    [ -f "$1" ] || return 0
    sed -E "s@[[:space:]]*$2.*\$@@" "$1" 2>/dev/null \
        | grep -E "$3" 2>/dev/null \
        | head -1 \
        | grep -oE "$4" 2>/dev/null \
        | head -1 \
        | grep -oE '[0-9]+' \
        | head -1 || true
}

caddy_read_body="$(conf_num "$CADDYFILE" '#' 'read_body[[:space:]]+[0-9]+s' 'read_body[[:space:]]+[0-9]+s')"
caddy_read_body_idle="$(conf_num "$CADDYFILE" '#' 'read_body_idle[[:space:]]+[0-9]+s' 'read_body_idle[[:space:]]+[0-9]+s')"
caddy_write="$(conf_num "$CADDYFILE" '#' 'write[[:space:]]+[0-9]+s' 'write[[:space:]]+[0-9]+s')"
caddy_write_idle="$(conf_num "$CADDYFILE" '#' 'write_idle[[:space:]]+[0-9]+s' 'write_idle[[:space:]]+[0-9]+s')"
caddy_fcgi_read="$(conf_num "$CADDYFILE" '#' 'read_timeout[[:space:]]+[0-9]+s' 'read_timeout[[:space:]]+[0-9]+s')"
# O `timeout=` do ProxyPass exige **espaço** antes: sem isso, `connectiontimeout=5` (que
# contém a substring "timeout=") seria lido no lugar do prazo real. E a linha é filtrada
# antes, senão o `127` da URL seria o primeiro número do match.
apache_proxy="$(conf_num "$APACHE_CONF" '#' \
    '^[[:space:]]*ProxyPass[[:space:]]+/[[:space:]]+http' \
    '[[:space:]]timeout=[0-9]+')"
php_input="$(conf_num "$PHP_INI" ';' 'max_input_time[[:space:]]*=' 'max_input_time[[:space:]]*=[[:space:]]*[0-9]+')"
php_exec="$(conf_num "$PHP_INI" ';' 'max_execution_time[[:space:]]*=' 'max_execution_time[[:space:]]*=[[:space:]]*[0-9]+')"
fpm_terminate="$(conf_num "$FPM_CONF" ';' 'request_terminate_timeout[[:space:]]*=' 'request_terminate_timeout[[:space:]]*=[[:space:]]*[0-9]+s')"

# Presença (o gate de ordem abaixo não substitui a presença: valor ausente ≠ valor zero).
require_text "$CADDYFILE" 'read_body 300s' 'janela de leitura do corpo (300s)'
require_text "$CADDYFILE" 'read_body_idle 30s' 'idle de leitura (anti-drip)'
require_text "$CADDYFILE" 'write 300s' 'janela de escrita da resposta (300s)'
require_text "$CADDYFILE" 'write_idle 30s' 'idle de escrita'
require_text "$CADDYFILE" 'read_timeout 360s' 'read_timeout do fastcgi (360s)'
require_text "$APACHE_CONF" 'timeout=300' 'ProxyPass timeout=300'
require_text "$PHP_INI" 'max_input_time = 300' 'max_input_time (300s)'
# `max_execution_time` é orçamento de **CPU** do script: ele **não** resolve upload lento.
# Subir os dois juntos esconderia o sintoma com o worker preso mais tempo.
require_text "$PHP_INI" 'max_execution_time = 30' 'max_execution_time (30s, CPU)'
require_text "$FPM_CONF" 'request_terminate_timeout = 360s' 'request_terminate_timeout (360s)'
require_text "$FPM_CONF" 'ping.path = /ping' 'ping.path (healthcheck real do FPM)'
require_text "$FPM_CONF" 'ping.response = pong' 'ping.response'
# Gate **negativo**: o endpoint de status do pool não pode existir (vaza o estado do FPM).
deny_text "$FPM_CONF" 'pm.status_path' 'pm.status_path exposto (status do pool)'
deny_text "$CADDYFILE" 'fpm-status' 'rota de status do FPM exposta no edge'

if [ -z "$apache_proxy" ] || [ -z "$caddy_read_body" ] || [ -z "$php_input" ] \
    || [ -z "$caddy_write" ] || [ -z "$caddy_fcgi_read" ] || [ -z "$fpm_terminate" ] \
    || [ -z "$php_exec" ] || [ -z "$caddy_read_body_idle" ] || [ -z "$caddy_write_idle" ]; then
    fail 'cadeia de timeout: prazo ausente (verifique Caddyfile, app.conf, php.ini e zz-custom.conf)'
else
    # (1) o corpo atravessa Apache → Caddy → PHP, nessa ordem de folga.
    if [ "$apache_proxy" -lt "$caddy_read_body" ] || [ "$caddy_read_body" -lt "$php_input" ]; then
        fail "cadeia de upload fora de ordem: apache=$apache_proxy ≥ caddy.read_body=$caddy_read_body ≥ php.max_input_time=$php_input"
    fi
    # (2) a resposta tem a mesma janela do corpo.
    if [ "$caddy_write" -lt "$caddy_read_body" ]; then
        fail "cadeia de upload: caddy.write=$caddy_write < caddy.read_body=$caddy_read_body (o corpo passa, a resposta não)"
    fi
    # (3) o edge espera o FPM terminar; o FPM mata o worker acima do pior caso do PHP.
    if [ "$caddy_fcgi_read" -lt "$fpm_terminate" ]; then
        fail "cadeia de upload: caddy.fastcgi.read_timeout=$caddy_fcgi_read < fpm.request_terminate_timeout=$fpm_terminate (o edge desiste antes do FPM)"
    fi
    if [ "$fpm_terminate" -lt "$((php_input + php_exec))" ]; then
        fail "cadeia de upload: fpm.request_terminate_timeout=$fpm_terminate < max_input_time+max_execution_time=$((php_input + php_exec)) (o worker morre antes do PHP)"
    fi
    # Piso da janela: 110 MiB ÷ 500 KiB/s ≈ 225 s ⇒ 300 s. Abaixo disso, o upload
    # legítimo do PDF 100 MiB é cortado de novo (o defeito que o D0 corrigiu).
    if [ "$caddy_read_body" -lt 300 ]; then
        fail "cadeia de upload: caddy.read_body=$caddy_read_body < 300s (janela mínima para 110 MiB a 500 KiB/s)"
    fi
fi

# ── 5c. Storage (S3/RustFS): sinal útil e readiness real (D1) ───────────────────────
# `RUSTFS_OBS_LOGGER_LEVEL=error` deixava o storage **mudo** (nenhum evento de scanner
# nem de durability). E o healthcheck tinha que apontar para o **readiness**: `/health` é
# liveness (o serviço responde antes de conseguir servir) e `/cluster/health` devolve 403.
require_text "$COMPOSE_FILE" 'RUSTFS_OBS_LOGGER_LEVEL: "info"' 'log do storage em nível info'
# Gate **negativo**: no 1.0.1 a `RUSTFS_OBS_USE_STDOUT` **silencia** o stdout (medido:
# 60+ linhas sem a var, 0 com ela). No 1.0.2 o "honor" da variável foi corrigido e o A/B
# medido deu **95 linhas nos dois casos** — ela não muda mais nada, mas continua sendo uma
# armadilha de versão. A ausência é o estado explícito: o log vem do stdout por padrão.
deny_key "$COMPOSE_FILE" 'RUSTFS_OBS_USE_STDOUT' 'RUSTFS_OBS_USE_STDOUT (silenciava o stdout no 1.0.1; no 1.0.2 é inócua)'
require_text "$COMPOSE_FILE" 'http://127.0.0.1:9000/health/ready' 'healthcheck do storage no readiness'

# ── 5d. Toda variável do Compose tem default funcional; o `.env` fica curto (D2) ────
# Decisão do dono: o `.env` é **identidade + segredos**, e nada mais. Todo o resto
# tem default no próprio Compose (`${VAR:-padrão}`) — o clone novo sobe sem
# preencher nada. O gate garante que nenhuma variável fique **sem destino**: sem
# default (`:-`, `-` ou `:?`), o `docker compose` falha com "variable is not set".
# `${VAR-...}` é aceito porque há um caso legítimo em que **vazio** é valor: o
# `MOUNT_LABEL` desliga o `:Z` do bind mount no podman (`:-` trataria vazio como
# "não definido" e religaria o relabel).
for var in $(grep -oE '\$\{[A-Z_][A-Z0-9_]*' "$COMPOSE_FILE" 2>/dev/null | sed 's/\${//' | sort -u); do
    if grep -qE "\\\$\{${var}(:-|:\?|-)" "$COMPOSE_FILE"; then
        continue
    fi
    fail "docker-compose.yml usa \${${var}} sem default funcional (use \${${var}:-padrao}, \${${var}-padrao} ou \${${var}:?mensagem})"
done

# Gate **negativo**: `docker-compose.prod.yml` é proibido (ADR-032 — `APP_ENV` é o
# ÚNICO interruptor; config congelada no build quebra o axioma do artefato único).
for file in "$DEPLOY_SCRIPT" "$DRILL_SCRIPT"; do
    deny_code "$file" 'docker-compose.prod.yml' "docker-compose.prod.yml (artefato por ambiente; ADR-032)"
done

# ── 5e. Imagem única e cadeia de suprimentos (ADR-031/ADR-001) ──────────────────────
# O ADR-031 diz "`queue` reusa a mesma imagem do `php-fpm`". O Compose tinha **dois**
# blocos `build:` ⇒ duas imagens de 878 MB cada.
php_image="$(sed -n '/^  php-fpm:/,/^  [a-z]/p' "$COMPOSE_FILE" | grep -E '^[[:space:]]+image:' | head -1 | sed -E 's/.*image:[[:space:]]*"?([^"]*)"?/\1/')"
queue_image="$(sed -n '/^  queue:/,/^  [a-z]/p' "$COMPOSE_FILE" | grep -E '^[[:space:]]+image:' | head -1 | sed -E 's/.*image:[[:space:]]*"?([^"]*)"?/\1/')"
if [ -z "$php_image" ] || [ -z "$queue_image" ]; then
    fail 'php-fpm/queue sem `image:` declarado (uma imagem só — ADR-031)'
elif [ "$php_image" != "$queue_image" ]; then
    fail "php-fpm ('$php_image') e queue ('$queue_image') usam imagens diferentes (ADR-031: uma imagem só)"
fi
if sed -n '/^  queue:/,/^  [a-z]/p' "$COMPOSE_FILE" | grep -qE '^[[:space:]]+build:'; then
    fail 'o serviço queue tem `build:` próprio (duas imagens — ADR-031)'
fi
# O build do deploy é **um só**: `php-fpm` (o queue reusa a imagem). O script novo monta
# os argumentos em `build_args`; nenhum outro serviço pode ser alvo de `build`.
if ! grep -qE 'build_args\+?=\(php-fpm\)' "$DEPLOY_SCRIPT" &&
    ! grep -qE 'build --no-cache php-fpm$' "$DEPLOY_SCRIPT"; then
    fail 'o build do deploy não constrói apenas o php-fpm (o queue reusa a imagem — ADR-031)'
fi
if grep -qE 'build[^#]*\b(queue|web|postgres|valkey|storage)\b' "$DEPLOY_SCRIPT"; then
    fail 'o build do deploy constrói outro serviço além do php-fpm (ADR-031)'
fi

# PECL **pinado** (ADR-001): um `pecl install` com nome literal deixa o build pegar
# qualquer versão. A forma pinada é `ARG PECL_<NOME>=<versão>` + o install usando a
# variável. Cada extensão vai em **um** install (o PECL pula pacotes do meio de uma
# chamada múltipla — medido no build).
mapfile -t pecl_args < <(grep -E '^ARG PECL_[A-Z_]+=' "$DOCKERFILE")
if [ "${#pecl_args[@]}" -eq 0 ]; then
    fail 'Dockerfile sem ARG PECL_* (as extensões PECL sumiram ou perderam a pinagem)'
fi
for arg in "${pecl_args[@]}"; do
    if [ -z "${arg#*=}" ]; then
        fail "ARG PECL sem versão: ${arg%%=*} (ADR-001: build reprodutível)"
    fi
done
while IFS= read -r line; do
    pkg="$(printf '%s' "$line" | sed -E 's/.*pecl install[[:space:]]*//' | sed -E 's/[;].*$//' | tr -d "\"' ")"
    case "$pkg" in
        '$'*|/*) ;; # variável ou arquivo local (baixado e verificado por checksum)
        *) fail "pecl install com nome literal (sem versão pinada): $pkg (ADR-001)" ;;
    esac
done < <(grep -E 'pecl install' "$DOCKERFILE" | grep -vE '^[[:space:]]*#')
# O checksum do pacote PECL também é conferido (o `pecl install` sozinho não garante o byte).
require_code "$DOCKERFILE" 'PECL_SHA_APCU' 'checksum do tarball PECL (apcu)'
require_code "$DOCKERFILE" 'PECL_SHA_REDIS' 'checksum do tarball PECL (redis)'
require_code "$DOCKERFILE" 'PECL_SHA_IMAGICK' 'checksum do tarball PECL (imagick)'
require_code "$DOCKERFILE" 'PECL_SHA_PCOV' 'checksum do tarball PECL (pcov)'
# Download do BLAKE3 com **checksum** (o commit pinado não garante o byte a byte).
require_code "$DOCKERFILE" 'sha256sum -c' 'verificação de checksum do download (BLAKE3)'
require_code "$DOCKERFILE" 'BLAKE3_SHA256' 'SHA-256 do tarball do BLAKE3 declarado'
# O build não pode deixar o source do PHP nem o cache do PECL na imagem de runtime.
require_code "$DOCKERFILE" 'docker-php-source delete' 'remoção do source do PHP (imagem menor)'

# Todo digest do Compose e do Dockerfile tem de estar na **matriz do ADR-031** (a matriz
# é parte do ADR: mudá-la sem atualizar o ADR é o mesmo que não pinar).
for digest in $(grep -ohE '@sha256:[0-9a-f]{64}' "$COMPOSE_FILE" "$DOCKERFILE" | sort -u); do
    if ! grep -qF "$digest" "$ADR_IMAGES"; then
        fail "digest $digest não está na matriz do ADR-031"
    fi
done

# ── 5f. Postgres conforme o ADR-007 (D4) ───────────────────────────────────────────
# ADR-007 §43 exige `wal_compression=on`, `max_wal_size` e `checkpoint_timeout` — os
# TRÊS faltavam no conf. O gate confere o **valor**, não só a presença: um
# `wal_compression = off` presente no arquivo é exatamente o defeito.
require_pg_param() { # <arquivo> <parâmetro> <valor> <descrição>
    [ -f "$1" ] || {
        fail "arquivo ausente: $1"
        return 0
    }
    grep -Eq "^[[:space:]]*${2}[[:space:]]*=[[:space:]]*${3}([[:space:]]|$)" "$1" \
        || fail "$1: ${2} deve ser ${3} — $4"
}

require_pg_param "$PG_CONF" 'wal_compression' 'on' 'ADR-007: WAL comprimido (pglz)'
require_pg_param "$PG_CONF" 'max_wal_size' '1GB' 'ADR-007: teto do WAL entre checkpoints'
require_pg_param "$PG_CONF" 'checkpoint_timeout' '15min' 'ADR-007: intervalo máximo entre checkpoints'
require_pg_param "$PG_CONF" 'min_wal_size' '128MB' 'piso do WAL recycled entre checkpoints'
require_pg_param "$PG_CONF" 'checkpoint_completion_target' '0\.9' 'checkpoint espalhado (sem estourar o prazo)'

# Diagnóstico (ADR-024): as 4 diretivas que respondem "onde travou / o que vazou para
# disco / como foi o checkpoint / quanto o autovacuum gastou".
require_pg_param "$PG_CONF" 'log_lock_waits' 'on' 'lock wait visível no log'
require_pg_param "$PG_CONF" 'log_temp_files' '10MB' 'temp file acima de 10 MB visível'
require_pg_param "$PG_CONF" 'log_checkpoints' 'on' 'checkpoint visível no log'
require_pg_param "$PG_CONF" 'log_autovacuum_min_duration' '1s' 'autovacuum lento visível'

# Gate **negativo**: `log_connections`/`log_disconnections` são ruído (volume alto na
# cota do WHM, SK-10) e information redundante — a rastreabilidade vem do log do app.
deny_pg_param() { # <arquivo> <parâmetro> <descrição>
    [ -f "$1" ] || return 0
    if grep -Eq "^[[:space:]]*${2}[[:space:]]*=" "$1"; then
        fail "$1: ${2} proibido — $3"
    fi
}
deny_pg_param "$PG_CONF" 'log_connections' 'ruído de conexão (o log técnico do app cobre)'
deny_pg_param "$PG_CONF" 'log_disconnections' 'ruído de conexão (o log técnico do app cobre)'

# `/dev/shm` do container tem de ser ≥ o `shared_buffers` do conf: o PG usa memória
# compartilhada **dinâmica** (/dev/shm) nos workers paralelos e falha com "could not
# resize shared memory segment" quando o segmento é menor (o default do Docker é 64 MB).
pg_buffers_mb="$(sed -n 's/^[[:space:]]*shared_buffers[[:space:]]*=[[:space:]]*\([0-9]\+\)MB.*/\1/p' "$PG_CONF" | head -1)"
pg_shm_mb="$(sed -n 's/^[[:space:]]*shm_size:[[:space:]]*\([0-9]\+\)mb.*/\1/p' "$COMPOSE_FILE" | head -1)"
if [ -z "$pg_buffers_mb" ] || [ -z "$pg_shm_mb" ]; then
    fail "shm_size/shared_buffers não legíveis (conf: '${pg_buffers_mb:-?}', compose: '${pg_shm_mb:-?}') — o shm_size é obrigatório no serviço postgres"
elif [ "$pg_shm_mb" -lt "$pg_buffers_mb" ]; then
    fail "compose: shm_size=${pg_shm_mb}mb < shared_buffers=${pg_buffers_mb}mb (o worker paralelo não consegue redimensionar o segmento)"
fi

# ── 5g. Observabilidade do edge e do cache (D6) ────────────────────────────────────
# O `logging` do Docker estava configurado, mas o **Caddy não logava**: não existia uma
# única linha de acesso na stack. O log agora é JSON, e o `filter` apaga o que não pode
# estar em log (o cookie de sessão do CMS é o bearer).
require_code "$CADDYFILE" 'output stdout' 'access log do Caddy no stdout'
require_code "$CADDYFILE" 'format filter' 'access log do Caddy em JSON (filter)'
require_code "$CADDYFILE" 'wrap json' 'access log do Caddy em JSON'
require_code "$CADDYFILE" 'request>headers>Cookie delete' 'Cookie apagado do log do edge'
require_code "$CADDYFILE" 'request>headers>Authorization delete' 'Authorization apagado do log do edge'
require_code "$CADDYFILE" 'resp_headers>Set-Cookie delete' 'Set-Cookie apagado do log do edge'
require_code "$CADDYFILE" 'resp_headers>X-Request-Id rename request_id' 'X-Request-ID do log do edge renomeado (resp_headers.request_id; correlação pelo value)'
# Gate **negativo**: o path do `filter` **não** pode começar com `>`. O parser do
# Caddyfile guarda a chave como está e o runtime não faz o strip — o `delete` vira
# no-op silencioso e a credencial volta ao log (medido: com `>`, o Cookie continuava
# na linha; sem, sumiu). O exemplo da documentação do Caddy usa o `>` — não copie.
deny_text "$CADDYFILE" '>request>headers>' 'path de filter com o ">" à frente (o delete vira no-op)'
deny_text "$CADDYFILE" '>resp_headers>' 'path de filter com o ">" à frente (o delete vira no-op)'

# Valkey: log em logfmt, PII mascarada, lazyfree e diagnóstico.
require_code "$VALKEY_CONF" 'log-format logfmt' 'log do Valkey em logfmt'
require_code "$VALKEY_CONF" 'log-timestamp-format iso8601' 'timestamp do Valkey em ISO-8601'
require_code "$VALKEY_CONF" 'hide-user-data-from-log yes' 'PII mascarada no log do Valkey (ADR-019)'
require_code "$VALKEY_CONF" 'lazyfree-lazy-eviction yes' 'lazyfree na evicção'
require_code "$VALKEY_CONF" 'lazyfree-lazy-expire yes' 'lazyfree na expiração'
require_code "$VALKEY_CONF" 'lazyfree-lazy-server-del yes' 'lazyfree no DEL'
require_code "$VALKEY_CONF" 'latency-monitor-threshold 100' 'monitor de latência do Valkey'
require_code "$VALKEY_CONF" 'tcp-backlog 511' 'fila de conexões do Valkey'
# `maxmemory`/`volatile-lru` já existiam (a premissa do catálogo estava errada): a
# evicção sem teto derruba o container por OOM.
require_code "$VALKEY_CONF" 'maxmemory 256mb' 'teto de memória do Valkey'
require_code "$VALKEY_CONF" 'maxmemory-policy volatile-lru' 'política de evicção do Valkey'

# Segredo do Valkey **fora do argv**: `--requirepass <senha>` punha a senha do cache,
# da sessão e da fila no `ps` do host (legível por qualquer usuário). O entrypoint
# renderiza o conf de runtime e o servidor sobe sem a senha em nenhum argv.
deny_code "$COMPOSE_FILE" '--requirepass' 'a senha do Valkey no argv (exposta no ps do host)'
deny_code "$COMPOSE_FILE" 'valkey-cli -a' 'a senha do Valkey no argv do healthcheck'
require_code "$COMPOSE_FILE" 'VALKEYCLI_AUTH=' 'healthcheck do Valkey com a senha no environment'
require_text "$VALKEY_ENTRYPOINT" 'requirepass' 'entrypoint do Valkey renderiza o requirepass no conf de runtime'
require_text "$VALKEY_ENTRYPOINT" 'exec /usr/local/bin/docker-entrypoint.sh valkey-server' 'o entrypoint da imagem continua fazendo o drop de privilégio'

# `allow_url_fopen = Off`: stream de URL vira SSRF (ADR-020). O exportador OTLP, o
# IndexNow e o cliente HTTP do Laravel usam curl — que não é afetado.
require_ini "$PHP_INI" 'allow_url_fopen = Off' 'allow_url_fopen desligado (SSRF)'

# ── 5h. Isolamento de rede e hardening de container (D5) ───────────────────────────
# O `web` é o único serviço exposto: ele **não** pode alcançar o banco, o cache nem o
# storage. A topologia é conferida em `tools/network-check.sh` (estática + runtime);
# aqui ficam os **hardening** por serviço.
#
# `no-new-privileges` em todos: nenhum processo ganha privilégio por setuid.
for svc in web php-fpm queue postgres valkey storage; do
    if ! compose_service_code "$svc" | grep -q 'no-new-privileges:true'; then
        fail "${svc} sem 'no-new-privileges:true' (ADR-020: nenhum processo ganha privilégio por setuid)"
    fi
done

# `cap_drop: [ALL]` nos serviços que rodam non-root e não precisam de capability.
# Gate **negativo** para o Postgres: o entrypoint da imagem faz `chown`/`chmod` no
# PGDATA como root e **precisa** de CAP_CHOWN (medido: sem ela o container não sobe).
for svc in web php-fpm queue valkey storage; do
    if ! compose_service_code "$svc" | grep -q 'cap_drop: \[ALL\]'; then
        fail "${svc} sem 'cap_drop: [ALL]' (ADR-020: o container roda sem capability)"
    fi
done
if compose_service_code postgres | grep -q 'cap_drop:'; then
    fail 'postgres com cap_drop: o entrypoint precisa de CAP_CHOWN (chown do PGDATA) — o container não sobe'
fi
# Exceção medida: o binário do Caddy tem capability de **arquivo**
# (`cap_net_bind_service=ep`); com `cap_drop: [ALL]` o exec é negado
# ("operation not permitted") — o kernel exige a capability do arquivo no permitted set.
if ! compose_service_code web | grep -q 'cap_add: \[NET_BIND_SERVICE\]'; then
    fail 'web sem cap_add: [NET_BIND_SERVICE] — o binário do Caddy tem capability de arquivo e o exec falha sem ela'
fi

# Teto de processos e de descritores.
for svc in web php-fpm queue; do
    if ! compose_service_code "$svc" | grep -q 'pids_limit:'; then
        fail "${svc} sem pids_limit (o container pode forkar sem teto)"
    fi
    if ! compose_service_code "$svc" | grep -q 'nofile:'; then
        fail "${svc} sem ulimits.nofile (o default do sistema estoura com muitas conexões)"
    fi
done

# Encerramento gracioso: `init: true` (o tini é o PID 1 e reapea zumbi) e a janela de
# parada **maior** que o timeout do worker — se a janela fosse menor, o `stop` mataria
# um job no meio (o invariante, não o número solto).
for svc in php-fpm queue; do
    if ! compose_service_code "$svc" | grep -q 'init: true'; then
        fail "${svc} sem 'init: true' (o PID 1 não reapea zumbi)"
    fi
done
queue_grace="$(compose_service_code queue | sed -n 's/^[[:space:]]*stop_grace_period:[[:space:]]*\([0-9]*\)s.*/\1/p' | head -1)"
worker_timeout="$(compose_service_code queue | sed -n 's/.*--timeout=\([0-9]*\).*/\1/p' | head -1)"
if [ -z "$queue_grace" ] || [ -z "$worker_timeout" ]; then
    fail 'o serviço queue precisa de stop_grace_period e o worker precisa de --timeout (o invariante é grace > timeout)'
elif [ "$queue_grace" -le "$worker_timeout" ]; then
    fail "queue: stop_grace_period=${queue_grace}s <= --timeout=${worker_timeout}s (o stop mata o job no meio)"
fi

# O worker: sem `--timeout` o default (60 s) mata um job legítimo de 90 s **antes** de
# ele terminar; sem `--backoff` o job que estourou o tempo volta imediatamente (3×).
queue_cmd="$(compose_service_code queue | grep -E '^[[:space:]]+command:' | head -1)"
if ! printf '%s' "$queue_cmd" | grep -q -- '--timeout=120'; then
    fail 'o worker da fila precisa de --timeout=120 (o default 60 s mata job legítimo)'
fi
if ! printf '%s' "$queue_cmd" | grep -q -- '--backoff=3'; then
    fail 'o worker da fila precisa de --backoff=3 (sem backoff o job falha 3× seguidas)'
fi

# O worker é o único serviço de produção sem healthcheck: ele precisa de um.
if ! compose_service_code queue | grep -q 'healthcheck:'; then
    fail 'o serviço queue não tem healthcheck (o único de produção sem prova de vida)'
fi

# A poda de `failed_jobs` é agendada (a tabela cresce a cada job que estourou o timeout).
require_code "$CONSOLE_FILE" 'queue:prune-failed' 'poda de failed_jobs agendada'

# ── 6. Exposição de arquivos ────────────────────────────────────────────────────────
[ ! -e public/.env ] || fail 'public/.env exposto (o docroot é public/)'
# G-06: o `.env` da raiz não pode ser legível por outro usuário do host (o deploy aplica 600).
if [ -f "$ROOT/.env" ]; then
    env_mode="$(stat -c '%a' "$ROOT/.env" 2>/dev/null || stat -f '%Lp' "$ROOT/.env" 2>/dev/null || echo '?')"
    [ "$env_mode" = "600" ] || fail ".env com permissão ${env_mode} (esperado 600)"
fi

# ── 7. Borda **efetiva** (o que o container `web` está servindo) ────────────────────
# O grep das seções 4/5 valida o arquivo; se o container estiver com um Caddyfile antigo
# (ex.: CSP sem Trusted Types), o arquivo novo passa e só o e2e pegaria. No caminho dourado
# `tools/check.sh` passa a URL/hosts do edge em execução; fixtures de `gates-selftest` (sem
# `SECURITY_EDGE_URL`) seguem apenas estáticas.
if [ -n "${SECURITY_EDGE_URL:-}" ]; then
    # O edge pode estar trocando de container durante o check; 3 tentativas separam
    # indisponibilidade transitória de configuração antiga (sem Trusted Types).
    edge_headers() {
        local attempt headers
        for attempt in 1 2 3; do
            headers="$(curl -fsSI --max-time 5 -H "Host: $1" "${SECURITY_EDGE_URL%/}$2" 2>/dev/null || true)"
            if [ -n "$headers" ]; then
                printf '%s' "$headers"
                return 0
            fi
            sleep 1
        done
        return 1
    }

    portal_headers="$(edge_headers "${SECURITY_PORTAL_HOST:-localhost}" '/')" || true
    cms_headers="$(edge_headers "${SECURITY_CMS_HOST:-cms.localhost}" '/login')" || true

    if [ -z "$portal_headers" ]; then
        fail "borda efetiva: portal indisponível após 3 tentativas"
    elif ! printf '%s' "$portal_headers" | grep -Fiq 'content-security-policy:'; then
        fail "borda efetiva: portal sem Content-Security-Policy (container antigo?)"
    elif printf '%s' "$portal_headers" | grep -Fiq 'unsafe-inline'; then
        fail "borda efetiva: CSP do portal com unsafe-inline"
    else
        printf '%s' "$portal_headers" | grep -Fiq 'x-frame-options: SAMEORIGIN' \
            || fail 'borda efetiva: portal sem X-Frame-Options SAMEORIGIN'
        printf '%s' "$portal_headers" | grep -Fiq 'payment=(), usb=()' \
            || fail 'borda efetiva: portal sem Permissions-Policy payment/usb'
        printf '%s' "$portal_headers" | grep -Fiq 'x-xss-protection: 0' \
            || fail 'borda efetiva: portal sem X-XSS-Protection: 0'
        printf '%s' "$portal_headers" | grep -Fiq 'https://www.vlibras.gov.br' \
            || fail 'borda efetiva: CSP do portal sem VLibras'
        printf '%s' "$portal_headers" | grep -Fiq 'https://www.googletagmanager.com' \
            || fail 'borda efetiva: CSP do portal sem Google Analytics'
    fi

    if ! printf '%s' "$cms_headers" | grep -Fiq "require-trusted-types-for 'script'"; then
        fail "borda efetiva: CSP do CMS sem Trusted Types (container com Caddyfile antigo)"
    fi
    printf '%s' "$cms_headers" | grep -Fiq 'x-frame-options: SAMEORIGIN' \
        || fail 'borda efetiva: CMS sem X-Frame-Options SAMEORIGIN'

    embed_headers="$(edge_headers "${SECURITY_PORTAL_HOST:-localhost}" '/noticias/embed/test' || true)"
    if [ -z "$embed_headers" ]; then
        fail 'borda efetiva: embed indisponível'
    elif printf '%s' "$embed_headers" | grep -Fiq '^x-frame-options:'; then
        fail 'borda efetiva: embed com X-Frame-Options (quebraria o iframe)'
    fi

    # Assets do Vite: o HTML público tem de apontar para um asset servido como `immutable`.
    # Sem isso o cache do browser fica sem a garantia do ADR-022 (e o Caddyfile pode ter
    # regredido para `max-age` curto sem o grep estático perceber).
    portal_body="$(curl -fsS --max-time 5 -H "Host: ${SECURITY_PORTAL_HOST:-localhost}" \
        "${SECURITY_EDGE_URL%/}/" 2>/dev/null || true)"
    asset="$(printf '%s' "$portal_body" \
        | grep -oE '/build/assets/[A-Za-z0-9._/-]+\.(js|css)' | head -1 || true)"

    if [ -z "$asset" ]; then
        fail 'borda efetiva: portal sem asset do Vite no HTML'
    else
        asset_headers="$(edge_headers "${SECURITY_PORTAL_HOST:-localhost}" "$asset" || true)"
        if ! printf '%s' "$asset_headers" | grep -Fiq 'immutable'; then
            fail "borda efetiva: $asset sem Cache-Control immutable"
        fi
    fi
fi

if [ "$rc" -ne 0 ]; then
    echo 'security-check: FALHOU' >&2
    exit 1
fi

echo 'security-check: OK'
