# Security Intervention — Remediação da Auditoria (2026-08-24)

**Referência:** auditoria completa em [`security_audit_2026_08_24/`](security_audit_2026_08_24/README.md),
plano de remediação em [`20_security_audit_fixes_2026_08_24.md`](20_security_audit_fixes_2026_08_24.md).
**Execução:** 2026-08-24 · Plano `pl-fac8` (outcome: success) · Épico `sucesu-a837`.
**Status final:** todas as falhas tratáveis endereçadas, com prova de prevenção ao fim de cada seção.

---

## 1. Decisões de escopo (mantainer)

| Decisão | Detalhe |
|---|---|
| **F-5 PostgreSQL CANCELADA** | md5/trust/superuser são configuráveis apenas a nível de container/HBA — não por migrations. Pela natureza do projeto, o banco interno não foi modificado. Seed `sucesu-4dd3` fechada como *won't do*; risco aceito e documentado. |
| **2FA TOTP CANCELADA** | Nenhum módulo novo autorizado — nem implementação, nem planejamento (`sucesu-084a` fechada). Item removido por completo da wiki e do plano; T7.3 marcado como cancelado em `plan/20`. |
| **DB_PASSWORD mantida** | Integra a exclusão do Postgres (credencial interna, porta não publicada). Apenas os *fallbacks* fracos versionados foram eliminados (ver §5). |
| **CSP `'unsafe-inline'` transitório** | Conforme Non-Goal do plano: remoção via nonce/hash registrada como follow-up (`sucesu-8482`, backlog). |

---

## 2. F-1 — `/error_log` público (Média) ✅

**Causa raiz encontrada além do sintoma:** `docker/apache/Dockerfile` linha 72 definia
`error_log = "error_log"` — caminho **relativo**, resolvido contra o docroot
(`/var/www/html/public`). Qualquer erro PHP gravava um log servível por HTTP.

**Correções:**
1. `public/error_log` removido do disco.
2. `docker/apache/Dockerfile`: `error_log = /var/log/php/error.log` + criação do diretório
   com dono `www-data` (usuário do PHP-FPM):
   ```dockerfile
   echo 'error_log = /var/log/php/error.log'; \
   ...
   # Log de erros PHP FORA do docroot (gravável pelo www-data do FPM)
   RUN mkdir -p /var/log/php && chown -R www-data:www-data /var/log/php
   ```
3. Defesa em profundidade em `public/.htaccess`:
   ```apache
   RedirectMatch 403 /error_log
   ```
4. Varredura: nenhum outro `.log`/dump/backup na raiz `public/`.
5. **Revisão pós-feedback:** residual `public/web.config` (config IIS) removido do
   docroot (`git rm`) + bloqueio defensivo:
   ```apache
   RedirectMatch 403 /web\.config
   ```

**Prova de prevenção:**
```console
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:11011/error_log
403

$ curl -s -o /dev/null -w "%{http_code}" http://localhost:11011/web.config
403

$ docker compose exec app ls public/error_log public/web.config
ls: cannot access 'public/error_log': No such file or directory
ls: cannot access 'public/web.config': No such file or directory

$ docker compose exec app php -i | grep ^error_log
error_log => /var/log/php/error.log => /var/log/php/error.log
```
Mesmo que `error_log`/`web.config` reapareçam no docroot (regressão de build, restore
indevido), o Apache responde **403 antes de servir**. E os erros PHP passam a nascer fora
do docroot, em `/var/log/php/error.log` (diretório dedicado criado no Dockerfile com dono
`www-data`, confirmado no runtime da imagem reconstruída).

---

## 3. F-2 — CSP ausente (Média) ✅ *(estado final: nonce por request — ver adiante)*

**Etapa transitória** — header global em `public/.htaccess` (substituído na revisão
`sucesu-8482`; mantido aqui como histórico da primeira entrega):

```apache
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.vlibras.gov.br; style-src 'self' 'unsafe-inline' https://www.vlibras.gov.br; img-src 'self' data: https://www.vlibras.gov.br; font-src 'self' data: https://www.vlibras.gov.br; connect-src 'self' https://www.vlibras.gov.br; frame-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'"
```

Pontos técnicos:
- A única origem externa real do portal é o widget VLibras. Mapeamento dos blade sources
  (`base-struct/vlibras.blade.php`) mostrou host **`www.vlibras.gov.br`** — o rascunho do
  plano citava `vlibras.gov.br`; CSP faz match exato de host, então foi corrigido.
- `'unsafe-inline'` presente em `script-src`/`style-src` é **transitório** (scripts inline
  dos `@push('scripts')`: ~6 blocos só na home). Sem ele o JS do portal quebra; a evolução
  para nonce está registrada (`sucesu-8482`) — era Non-Goal declarado do plano.
- Diretivas estruturais já ativas: `frame-ancestors 'self'` (anti-clickjacking redundante ao
  XFO), `object-src 'none'`, `base-uri`/`form-action 'self'` (anti-injection de form/base).

**Prova de prevenção:**
```console
$ curl -skI https://localhost:10443/ | grep -i content-security-policy
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'
https://www.vlibras.gov.br; style-src ... ; object-src 'none'

$ for p in / /noticias /busca /cms/login /regionais /agenda; do curl \
    -s -o /dev/null -w "$p %{http_code}\n" http://localhost:11011$p; done
/              200
/noticias      200
/busca         200
/cms/login     200
/regionais     200
/agenda        200
```
Qualquer XSS futuro fica sem execução de script de origem arbitrária (só `'self'` e o
VLibras oficial), sem `<object>/<embed>` e sem mudança de `base-uri`.

**Revisão pós-feedback — X-XSS-Protection:** adicionado com valor `"0"`:
```apache
# Header legado/deprecated: "0" é a recomendação atual (OWASP/MDN) — o auditor XSS
# dos browsers foi removido e podia introduzir XSS; CSP é o controle real.
Header always set X-XSS-Protection "0"
```
```console
$ curl -sI http://localhost:11011/ | grep -i x-xss
X-XSS-Protection: 0
```
Fecha o achado informativo da suíte (header ausente) seguindo a orientação atual:
o auditor legado foi descontinuado pelos browsers e podia, ele próprio, introduzir XSS;
a proteção real contra XSS é a CSP da seção.

**Revisão pós-feedback (sucesu-8482) — CSP com nonce por request** *(commit `a70625e`)*:
o `'unsafe-inline'`
foi **removido do `script-src`**. Implementação em `V2/Core/Middleware/CspMiddleware.php`,
registrado como middleware **global mais externo que o `PageCache`** (`app/Http/Kernel.php`),
com três propriedades de desenho:

1. *Nonce único por request* gerado com CSPRNG (`random_bytes(16)`), injetado via
   `preg_replace_callback` apenas em `<script>` inline (sem `src`, sem `nonce`) do HTML
   final — cobre os 114 blocos inline dos blades e qualquer view futura, sem edição manual;
2. *Consistência com PageCache:* como o middleware é externo ao boundary de cache,
   TODO corpo servido (MISS ou HIT) recebe injeção fresca e um header cujo `'nonce-'`
   casa exatamente com ele — o par nunca descasada (prova abaixo);
3. A CSP estática foi removida do `.htaccess` (duas policies fariam interseção restritiva).

Política resultante: `script-src 'self' 'nonce-{n}' https://www.vlibras.gov.br https://cdn.jsdelivr.net`.
Resíduos legados documentados: `script-src-attr 'unsafe-inline'` (66 handlers `onclick=`
herdados) e `style-src 'unsafe-inline'`.

**Correção colateral (bug pré-existente exposto pelo console):** o widget VLibras carregava
de `www.vlibras.gov.br/app/vlibras-plugin.js`, que hoje responde `text/plain` com conteúdo
divergente do hash SRI pinado — sob `nosniff`, o browser recusa e o widget caía no fallback
"temporariamente indisponível". Migrado para a distribuição oficial
`cdn.jsdelivr.net/gh/spbgovbr-vlibras/vlibras-portal@v7.5.0` com SRI recalculado
(`sha384-Siw0QRYjkcU0...`), e host adicionado à allowlist da CSP.

**Provas de prevenção:**
```console
# MISS: todo <script> inline carrega o nonce exato do header
$ curl -s -D h -o b http://localhost:11011/noticias
inline: 5 | com nonce do header: 5 | script-src contém unsafe-inline: NÃO

# HIT de cache: par corpo/header continua casado (nonce re-injetado no corpo cacheado)
$ curl -s -D h -o b http://localhost:11011/        # 2ª chamada => X-Cache-Status: HIT
HIT nonce no corpo: 6/6

# Guardas automatizadas (PHPUnit):
test_adds_nonce_to_inline_scripts_and_sets_matching_header   ✓
test_does_not_touch_scripts_with_src_or_existing_nonce       ✓
test_generates_fresh_nonce_per_request                       ✓
test_portal_page_scripts_carry_header_nonce                  ✓
test_cached_hit_still_pairs_fresh_nonce_with_body            ✓
```
Um XSS injetado via `<script>alert(1)</script>` armazenado (em campo de conteúdo,
parâmetro refletido ou payload em cache) **não executa**: sem `src`, exige o nonce vigente
do request, que o atacante não conhece e que muda a cada resposta.

Nota: os bloqueios reportados no console com origem `646af246-…`/`cdn.jsdelivr.net`
sem relacionamento com a página eram de **extensão do browser** do avaliador (nenhuma
referência existe no HTML servido); os 404 de imagens (`favicon.png`, variantes WebP,
`news-default.jpg`) são lacuna de conteúdo pré-existente, fora do escopo desta CSP.

**Verificação em browser limpo (Playwright/Chromium, `tests-e2e/scripts/csp-probe.cjs`):**
páginas `/` e `/noticias`, com abertura do widget VLibras — **zero violações de CSP**
e **zero `<script>` inline sem nonce no DOM**. Duas exceções tratadas:

1. `frame-src 'self' → 'self' https://www.vlibras.gov.br` *(commit `97ebc04`, seed
   `sucesu-c838`)*: o plugin VLibras renderiza a UI de tradução num iframe do próprio host
   gov.br; sem isso o botão do widget não abria. Guarda automatizada nova no teste feature.
2. Telemetria PostHog (`us*.i.posthog.com`) embutida no plugin VLibras: **bloqueada por
   design** (script-src/connect-src sem o host) — decisão consciente; a função do widget
   (tradução) é independente da telemetria.

**3ª rodada de console — TipTap/esm.sh (vendorizado, não permitido):** o console do
avaliador mostrou imports de `esm.sh/@tiptap/*` bloqueados. Causa real e primeira-party: o
serviço **ContentRichEditor** importava TipTap direto do CDN em runtime
(`_bundle-header.js` → `content-rich-editor-bundle.js`), quebrando ~10 formulários do CMS
desde a 1ª entrega da CSP e violando a regra do projeto "assets locais, sem CDN".
**Resolução:** TipTap **vendorizado localmente** em `public/assets/vendor/tiptap/
tiptap-2.0.0.mjs` (bundle ESM único, 797KB, deps prosemirror inline com instância única,
build esbuild pinado @2.0.0); bundle do editor passa a importar do vendor local com
cache-buster no blade; `esm.sh` **não** entrou na allowlist. Prova em probe autenticado:
`/cms/unidades-regionais/nova` com **zero violações, zero hosts externos** (`externalScriptHosts: []`)
e editor montado (`.ProseMirror` presente + log de imports concluído).

**Diagnóstico dos relatos de console do avaliador:** o "script inline bloqueado" com hash
estável `sha256-9BUhOJl7…` e origem UUID (`646af246-…`) **não parte da aplicação** — probe
em Chromium limpo (sem extensões) reproduz zero violações inline nas mesmas páginas, e
nenhuma referência ao hash/host existe no HTML servido nem no código: assinatura de script
injetado por extensão do browser. Os 404 de imagens (`favicon.png`, variantes WebP,
`news-default.jpg`) são lacuna de conteúdo pré-existente, fora do escopo da CSP.

### Resumo das entregas da F-2

| Etapa | Conteúdo | Commit | Seed |
|---|---|---|---|
| 1ª entrega (plano/20 T4) | CSP estática em `.htaccess` + X-XSS-Protection `0` | `d050e0b` | — |
| 2ª (nonce, `sucesu-8482`) | `CspMiddleware` global, `script-src` sem `'unsafe-inline'`, VLibras → jsdelivr@v7.5.0 com SRI recalculado | `a70625e` | `sucesu-8482` |
| 3ª (console do avaliador) | `frame-src` para iframe gov.br; PostHog bloqueado por design; probe Playwright versionado | `97ebc04` | `sucesu-c838` |
| 4ª (console: TipTap/esm.sh) | TipTap **vendorizado** (`public/assets/vendor/tiptap/tiptap-2.0.0.mjs`); `esm.sh` não permitido; CMS autenticado com zero hosts externos | ver commit atual | — |

---

## 4. F-3 — Canal IA sem restrição de rede + token duradouro (Alta/dev) ✅

O middleware `V2/Core/Middleware/CmsAiToken.php` já tinha as guardas corretas (fail-closed,
`hash_equals`, mínimo 48 chars, bloqueio em produção) mas operava com **allowlist vazia**
(= qualquer IP) e token fixo antigo.

**Descoberta crítica de rede:** com port publishing Docker, requisições originadas no próprio
host chegam ao PHP com IP do **gateway da rede compose** (NAT/SNAT), nunca `127.0.0.1`
(evidência: access log do Apache mostrando `172.25.0.1` para `curl localhost`). Allowlist só
com loopback tornaria o canal inutilizável ou, pior, levaria alguém a "abrir tudo".

**Correções:**
1. Subnet fixada no `docker-compose.yml` → gateway determinístico:
   ```yaml
   networks:
     portal_sucesu_network:
       driver: bridge
       ipam:
         config:
           - subnet: 10.199.0.0/24
             gateway: 10.199.0.1
   ```
2. `.env`: `V2_CMS_AI_ALLOWED_IPS=127.0.0.1,::1,10.199.0.1` (loopback + gateway NAT).
3. Token rotacionado: novo `openssl rand -hex 32` (64 hex chars); antigo morto.
4. Procedimento de rotação + armadilha do NAT documentados em `wiki/v2/acesso-ia-cms.md`.
5. Guardas existentes intocadas (Non-Goal respeitado).

**Prova de prevenção** (matriz de acesso real):
```console
# Host local (IP 10.199.0.1 na allowlist) com token NOVO válido:
$ curl -s -o /dev/null -w "%{http_code}" -H "X-CMS-AI-Token: $TOKEN" http://localhost:11011/cms
200

# Origem EXTERNA ao host (container vizinho, IP 10.199.0.x fora da allowlist)
# MESMO COM TOKEN VÁLIDO:
$ docker run --rm --network portal_sucesu_portal_sucesu_network curlimages/curl \
    -H "X-CMS-AI-Token: $TOKEN" -s -o /dev/null -w "%{http_code}" http://app/cms
403

# Token inválido (qualquer origem):
$ curl -s -o /dev/null -w "%{http_code}" -H "X-CMS-AI-Token: <64-hex-errado>" http://localhost:11011/cms
403
```
Vazamento do token do `.env` deixou de ser exploração remota: fora das origens listadas o
canal devolve 403 **antes mesmo** da comparação do token. Em produção a guarda dura em código
(`APP_ENV === 'production'`) mantém o canal impossível de ativar.

---

## 5. F-4 — Credenciais fracas/hardcoded (Média) ✅

**Correções:**
1. Senha do fundador rotacionada **no banco via Sentinel** (script bootstrap Laravel,
   hash verificado com `password_verify`) e atualizada apenas no `.env` (fora do git).
2. Fallbacks fracos purgados de arquivos **versionados**:
   - `config/v2.php`: `'password' => env('V2_FOUNDER_PASSWORD', 'changeme')` → fallback `''`;
   - `.env.example`: `DB_PASSWORD=secret` e `V2_FOUNDER_PASSWORD=changeme` → placeholders com instrução de geração;
   - `docker-compose.yml` (2 ocorrências): `${DB_PASSWORD:-secret}` →
     `${DB_PASSWORD:?DB_PASSWORD obrigatoria no .env}` (**fail-closed**: compose recusa subir sem credencial);
   - `tests-e2e/tests/cms-news-creation.spec.ts`: `|| 'changeme'` → `throw` se `V2_FOUNDER_PASSWORD` ausente.
3. `.env` confirmado fora do git; `.env.example` sem valores reais.

**Prova de prevenção:**
```console
$ PW=$(grep ^V2_FOUNDER_PASSWORD= .env | cut -d= -f2)
$ TOKEN=$(grep ^V2_CMS_AI_TOKEN= .env | cut -d= -f2)
$ echo "founder em git: $(git ls-files | xargs grep -l "$PW" | wc -l) | token IA em git: $(git ls-files | xargs grep -l "$TOKEN" | wc -l) | .env rastreado: $(git ls-files .env | wc -l)"
founder em git: 0 | token IA em git: 0 | .env rastreado: 0
```
Nenhum segredo atual existe em arquivo versionado; ambientes novos **não sobem** sem
credenciais fortes explícitas (`:?` do compose) e nenhum código contém default fraco.

---

## 6. F-6 — Cookie de sessão sem `Secure` (Baixa) ✅

**Correção:** `SESSION_SECURE_COOKIE=true` no `.env` + padronização do acesso dev pelo
endpoint TLS já publicado (`https://localhost:10443`).

**Prova de prevenção:**
```console
$ curl -skI https://localhost:10443/cms/login | grep -i "set-cookie: sucesu"
set-cookie: sucesu_nacional_session=...; path=/; secure; httponly; samesite=lax
```
Com `Secure`, o browser **nunca reenvia** o cookie de sessão em HTTP puro — downgrade e
interceptação LAN deixam de render a sessão. Três guardas de regressão que embutiam a
política antiga ("Secure=false em dev") foram atualizadas à nova postura:
`SessionConfigTest` (unit), `SessionCookieSecureRegressionTest` e
`InstituicaoCmsStoreCsrfTest` (feature — fluxo CSRF completo segue íntegro sob `secure=true`,
prova de que o incidente sucesu-02c1 não retorna).

---

## 7. F-7 — Superfície admin pública (Informativa) ✅ (dentro do escopo autorizado)

- **Anti-enumeração (T7.1):** verificado no código — mensagem única genérica
  `"Usuário ou senha incorreto(s)."` para usuário inexistente **ou** senha errada
  (`V2/Core/Controllers/Auth/LoginCmsController.php`). Mensagens específicas (sem role /
  desativado) só ocorrem **pós-credenciais válidas**, quando enumeração já não é possível.
- **Throttle (T7.2):** checkpoint Sentinel ativo por padrão — limites efetivos documentados
  em `wiki/v2/hardening-login-cms.md`: 5 tentativas por IP **e** por login field em janela
  de 15 min, com ban global progressivo (10→1 min … 60→32 min).
- **2FA (T7.3):** cancelado pelo maintainer (ver §1).

**Prova de prevenção (throttle):** as tentativas excedentes lançam `ThrottlingException`
tratada como falha genérica de login — sem diferenciar usuário existente — travando o vetor
de brute force pela própria biblioteca de autenticação.

---

## 8. EXTRA — Tokens de sessão deriváveis/reutilizáveis (fixação de sessão) ✅

Investigação dirigida pelo maintainer. Diagnóstico:
- O ID de sessão é gerado com CSPRNG (`Str::random(40)` em `Illuminate\Session\Store`) e o
  cookie trafega cifrado (AES-256-CBC + HMAC, IV aleatório por emissão) — **não derivável**.
- O problema real: **nenhum ponto do app regenerava o ID nos ciclos de autenticação.**
  `Sentinel::authenticate()` não passa pelo `SessionGuard` do framework (que regenera), e
  `Sentinel::logout()` faz apenas `forget()` da chave dele. Resultado: um ID capturado
  pré-login continuava **válido e autenticado após o login** (fixação), e o ID autenticado
  sobrevivia ao logout (reuso).

**Correção** em `V2/Core/Controllers/Auth/LoginCmsController.php`:
```php
// Login OK (antes do redirect):
$request->session()->regenerate();

// Falha pós-auth (sem role / desativado):
Sentinel::logout();
$request->session()->invalidate();

// Logout:
Sentinel::logout();
$request->session()->invalidate();
```

**Prova de prevenção** — guarda automatizada nova
(`tests/Feature/V2/Core/SessionRegenerationRegressionTest.php`, driver `file` + cookie de
sessão carregado entre requests, CSRF real):
```text
test_login_regenerates_session_id          → pré-auth ID ≠ pós-login ID
test_login_without_role_invalidates_session→ ID usado no POST morre na rejeição
test_logout_invalidates_session_id         → ID autenticado ≠ pós-logout ID + sem user
OK (3 tests, 10 assertions)
```
Um atacante que fixe/furte um ID antes da autenticação recebe um **novo ID** no momento do
login; e um ID roubado já autenticado **morre** no primeiro logout. Regressão dessas linhas
quebra a suíte.

---

## 9. Verificação final consolidada

| Critério (plan/20) | Resultado |
|---|---|
| `/error_log` → 403/404 e fora do docroot | ✅ 403 |
| `Content-Security-Policy` presente (:10443 e :11011) sem quebrar páginas | ✅ |
| Canal IA: externo c/ token válido → 403; token antigo → 403; host c/ novo → 200 | ✅ |
| Zero segredos em arquivos versionados (grep repo) | ✅ |
| `Set-Cookie` sessão com `Secure` via HTTPS | ✅ `secure; httponly; samesite=lax` |
| Login CMS sem enumeração de usuários | ✅ mensagem única |
| PHPUnit V2 Unit / V2 Feature sem regressão | ✅ 500 OK / 242 OK (1 skip pré-existente) |

Cache invalidado após as mudanças (`composer clear-page-cache` → 5 chaves `laravel:page:*`
removidas + view clear). Containers recriados com build novo (Dockerfile/rede).

### Pendências conscientes (risco aceito / backlog)

| Item | Situação |
|---|---|
| F-5 Postgres (md5/superuser/TLS) | Risco aceito pelo maintainer — rede interna, porta não publicada |
| 2FA TOTP | Cancelado pelo maintainer |
| CSP `script-src-attr 'unsafe-inline'` (66 handlers legados) e `style-src 'unsafe-inline'` | Resíduos documentados; migração exige refactor de UI — backlog |

### Revisão pós-feedback (2026-08-24, tarde)

| # | Feedback | Tratamento |
|---|----------|-----------|
| 1 | Correções não commitadas | **Commitado** (código, testes, wiki; `plan/` permanece não-rastreado como já estava) |
| 2 | CSP com `'unsafe-inline'` em script-src | **Resolvido** — nonce por request via `CspMiddleware` (ver §3); resíduos: `script-src-attr` (handlers legados) e `style-src` |
| 3 | F-5 Postgres | Ignorado por instrução (decisão mantida) |
| 4 | `/web.config` ainda 200 | Removido do docroot + `RedirectMatch 403 /web\.config` → **403** (prova na §2) |
| 5 | X-XSS-Protection ausente | Adicionado `"0"` conforme OWASP/MDN (deprecated; CSP é o controle real) — prova na §3 |
| 6 | Doc drift error_log | Era drift de **código**: Dockerfile apontava `/var/log/apache2/php-error.log`; corrigido para `/var/log/php/error.log`, imagem reconstruída e runtime confirmado via `php -i` (prova na §2) |
