# Spec 05 — Segurança web e LGPD

> **Dono:** Core/Segurança + Portal/Lgpd · **Status:** Em execução · **Plano:** 17/18
> **Relaciona:** ADR-002, ADR-019, ADR-020, ADR-021, ADR-022, ADR-024, ADR-026, ADR-029, ADR-032,
> ADR-035, ADR-037; specs 04, 06, 11, 12, 14, 16
> **Objetivo:** contrato da **superfície externa** (headers, TLS, cookies, CSRF/cache, rate limit,
> upload) e do **programa de privacidade** (classificação, DSAR, retenção/legal hold, auditoria de
> acesso, DPO, incidentes, analytics privacy-first) — defesa em profundidade, **sem** terceiros por
> padrão e **sem** PII no log técnico.

## 0. Fronteira — quem é dono do quê

Este contrato **não duplica** os contratos vizinhos; ele amarra as obrigações de segurança às
decisões de cada área.

| Tema | Dono | Onde |
|---|---|---|
| Headers de borda, CSP, compressão, `ETag` de arquivos de raiz | Edge (`web`/Caddy) + WHM | **spec 16** |
| Trilha de auditoria/imutabilidade/legal hold da evidência | Core/Auditoria | **spec 14** |
| Permissões, perfis, grupo `Suporte`, ativação de módulo | Core/Acesso | **spec 11** |
| Tipos/migrações/`backup_*` | Core/Dados | **spec 12** |
| Log JSON, `X-Request-ID`, RFC 7807, OTel | Core/Observabilidade | **spec 06** |
| A11y (inclui a página de `429`/erro) | Layout/UX | **spec 04** |
| **Política de segurança web + LGPD** | **Core/Segurança + Portal/Lgpd** | **este documento** |

**Regra de ouro:** o app **não** emite CSP nem nonce (ADR-022); o edge é o único dono da CSP
(spec 16). O app é dono do **comportamento** (sessão, login, limite, DSAR, retenção) e do que
**entra e sai** do domínio.

## 1. Cabeçalhos de segurança e CSP (ADR-020/022)

- **Dono da emissão:** `web`/Caddy (spec 16); o app só adiciona defesa em profundidade
  (`EnforceSecurityHeaders`: `nosniff`, `Referrer-Policy`, `noindex`/`no-store` em área privada).
- **CSP estática e cacheável** — `default-src 'self'`, `base-uri 'self'`, `form-action 'self'`,
  `object-src 'none'`; **sem** `unsafe-inline`, **sem** `unsafe-eval`, **sem** `'nonce-'`.
  `frame-ancestors 'self'` em todo o portal, relaxado para `*` **apenas** em `/noticias/embed/*`.
- **Sem inline:** nenhum `<script>`/`<style>`/handler `on*`/`style="…"`; o JS é ESM vendorizado
  (Vite) e a CSP não precisa de hash de inline.
- **Trusted Types:** **CMS** exige `require-trusted-types-for 'script'` com a policy
  `ProseMirrorClipboard` (o ProseMirror a cria; `injectCSS:false` evita `<style>` inline do TipTap). O
  portal **não** exige (VLibras é terceiro, carregado sob demanda). Gate de fonte em
  `SecurityHeadersTest` + e2e `14`.
- **HSTS:** terminado no Apache do WHM (TLS 1.3); habilitado no deploy de produção.
- **Prova:** e2e `14-security-headers` (CSP estrita, sem CDN, **sem token/nonce por request**,
  `ETag` estável, `frame-ancestors *` só no embed).

## 2. TLS, cookies e sessão (ADR-020/032/037)

- **TLS 1.3** no Apache (AutoSSL); `URL::forceScheme('https')` quando `APP_URL` é https; cadeia de
  proxy confiável (`TrustProxies`/`TrustHosts`) para IP real e HTTPS corretos (spec 16).
- **Cookies (produção):** prefixo **`__Host-`**, `Secure`, `HttpOnly`, `SameSite=Lax`, sessão
  **criptografada** (`config/environments.php` → `config/session.php`).
- **PHP:** `session.use_strict_mode=1`, `expose_php=Off`, `display_errors=Off`,
  `session.cookie_*` coerentes (`docker/php-fpm/php.ini`).
- **Ciclo:** `session()->regenerate()` no login (fixação); `invalidate()` + `regenerateToken()` no
  logout; **sem** “lembrar-me” no MVP.
- **Desenvolvimento:** `APP_ENV` é o **único** switch (ADR-032); em dev as flags de `secure/encrypt`
  caem por perfil — nunca por `if` espalhado no código.

## 3. Autenticação do CMS (ADR-037)

- **Sessão nativa** sobre `cms_users` (guard `web` + provider `eloquent`), no **domínio próprio** do
  CMS (isolamento de cookie/sessão). Sem Fortify/Breeze/JWT.
- **Senha:** `PASSWORD_ARGON2ID` + `rehash_on_login`; política de força em `config/cms.php`.
- **Falha genérica:** mesma mensagem (e sem ramo de tempo) para e-mail inexistente e senha errada;
  o detalhe vai para a trilha, não para a tela.
- **Logout** só `POST` + CSRF.
- **Freio:** `throttle:cms-login` por **e-mail + IP** no Valkey; `429` **RFC 7807** +
  `Retry-After` (`ThrottlePanelLogin` + renderer).
- **Auditoria de graça:** eventos nativos → `core.login`/`core.logout`/`core.login_failed`
  (spec 14; **sem** credenciais no payload).
- **Fora de escopo:** gov.br SSO, 2FA, recuperação de senha, registro/convite — backlog (ADR-037).

## 4. Rate limiting e anti-abuso (ADR-021)

Três camadas, **sem** tocar o banco no caminho quente:

| Camada | Mecanismo | Onde |
|---|---|---|
| Borda (Apache/WHM) | `mod_reqtimeout`/`mod_qos`/`mod_security` (CRS curado), timeouts e limites de corpo | Edge (spec 16) |
| Aplicação | `RateLimiter` no **Valkey** (`throttleWithRedis`), `throttle:` por rota/usuário/IP | `bootstrap/app.php` |
| Formulários | **honeypot** + **time-trap** + janela por formulário/sessão; CSRF nativo; captcha só em último caso | `FormRequest` |

- **Limites nomeados:** `cms-login` (e-mail+IP), `lgpd-dsar` (5/min por IP), `open-data` (60/min por
  IP), `media-upload` (120/min por usuário + cota diária de 2 GiB/500 arquivos). Limites são
  **configuração**, nunca escrita no domínio.
- **Borda (F5.I3.T3):** `docker/web/app.conf` aplica `RequestReadTimeout`, `LimitRequestBody`,
  `LimitRequestLine`/`LimitRequestFields` e, se o host tiver, `mod_qos`/`mod_evasive`; o Caddy
  aplica `timeouts` e `request_body max_size 110MiB` (aceita o maior hard de mídia; o limite por
  tipo é do app — `config/media.php`/`MediaLimits`). Gate: `tools/security-check.sh` (`make check`
  + `gates-selftest`).
- **Resposta:** `429` **RFC 7807** + `Retry-After`, página acessível (spec 04), `X-Request-ID` para
  correlação; nunca vaza interno.
- **Degradação:** leitura/estático **fail-open**; autenticação e formulários mais conservadores; a
  falha do store é logada.
- **LGPD:** o anti-abuso não cria PII oculta; logs de limite têm retenção curta (ADR-019).

## 5. Cache de resposta, `ETag`/304 e CSRF (ADR-022)

**Contrato** (implementado em F5.I3.T1/T2):

- GET público de conteúdo → `Cache-Control: public, max-age=300, s-maxage=300,
  stale-while-revalidate=86400` + `ETag`/`304` (hash SHA-256 do corpo). O `ETag` só é emitido em
  `GET`: o `Response::prepare()` remove o corpo no `HEAD` antes dos middlewares, então não há hash a
  calcular (o cache real usa `GET`).
- `Vary: Accept-Encoding, Cookie` — compressão **e** preferências de a11y renderizadas no SSR
  (ADR-016); **nunca** `User-Agent`. O conteúdo público **não** abre sessão: o middleware global
  `StripPublicCookies` remove o `Set-Cookie` anônimo (que vazaria session id e inviabilizaria o cache).
- Página com formulário `POST`/CSRF (ou rota `cache.private`) → `private, no-cache, no-store`;
  **nenhum** token CSRF em HTML público; corpo determinístico para o `ETag` ser estável.
- **CSP sem inline/nonce** → HTML cacheável (ADR-022); assets do Vite com hash e `immutable` no edge.
- **Sem minificação de HTML em runtime**; compressão é do edge (`.br`/`.gz` determinísticos).
- FPC (Valkey) **não adotado**: o ganho não justifica a invalidação por evento (ADR-022 §6 prevê
  descartar); o `ETag`/304 e o `stale-while-revalidate` cobrem o caso.
- **Prova:** e2e `14` (HTML público sem `nonce=`/`csrf-token`, `ETag`/304 estável, sem `Set-Cookie`) +
  `ResponseCacheTest` + gate de fonte da CSP/immutable em `SecurityHeadersTest`.

## 6. Entrada, saída e upload (ADR-020/026)

- **Entrada** por `FormRequest`/`Validation`; massa por **lista** (`validated()`), nunca `->all()`.
- **Saída** escapada (`{{ }}`); `{!! !!}` só com conteúdo **de confiança** (SVG vendorizado/AST do
  editor, ADR-028); SQL por **binding**; `json_encode(..., JSON_THROW_ON_ERROR)`.
- **Upload:** MIME **real** + tamanho + checksum; a imagem entra pelo **fluxo único** de Media
  (ADR-026) e é servida como `<picture>`; nunca se reescreve `<img>` no HTML.
- **WAF:** permanece ligado (o editor se resolve por CSP/sanitização — lição do legado).
- **CORS:** só a API de dados abertos é cross-origin (`security.open-cors` na rota,
  `Access-Control-Allow-Origin: *` **sem** credenciais, `GET/HEAD/OPTIONS`, preflight 204).
  `config/cors.php` permanece vazio — não existe CORS global. Dado aberto não carrega PII;
  qualquer outro endpoint cross-origin exige ADR.

## 7. Cadeia de suprimentos e zero CDN (ADR-002)

- **Tudo vendorizado**; as **únicas** requisições externas são **VLibras** (a11y oficial, sob
  demanda) e **Google Analytics** (opt-in no painel de suporte). Allowlist **fechada** em
  `tools/vendor-check.sh`; mudança de allowlist exige **ADR**.
- **Licenças permissivas** (MIT/ISC/Apache-2.0/BSD/OFL/CC0); copyleft forte **bloqueado** em código,
  imagem e `Process::run`; exceção estreita (MPL `lightningcss`, build-only). Gates
  `licenses-check.php` + `assets-licenses-check.php` (relatório único `THIRD_PARTY_LICENSES`, uma seção por dono).
- **Lockfiles commitados**, `composer audit`/`npm audit`, SBOM (CycloneDX) no build; segredos só por
  env/secret manager (nunca no repo).

## 8. LGPD — programa de privacidade (ADR-019)

### 8.1 Classificação (dirige cifra · trilha · retenção)

`config/lgpd.php` — **público · interno · pessoal · sensível**. Dado **pessoal/sensível** usa cast
`encrypted` (cifra em repouso), entra na trilha quando lido no CMS e tem prazo de retenção.
**Regra:** PII **nunca** no log técnico (ADR-024); o dado pessoal de DSAR é cifrado na coluna.

### 8.2 Direitos do titular — DSAR (`Portal/Lgpd`)

- **Tipos:** acesso · correção · eliminação · portabilidade.
- **Registro:** `lgpd.dsar_requests`; `name`/`email`/`description`/`response_note` **cifrados**;
  `protocol` (`DSAR-AAAA-…`), `status`, `due_at` (`LGPD_DSAR_DUE_DAYS`, default 15), `legal_hold`.
- **Fluxo:** formulário público (`/lgpd`, nativo, sem JS) → protocolo na confirmação; atendimento no
  CMS (`/lgpd` no domínio do painel, permissões `lgpd.{ver|editar}`; `lgpd.aprovar` para legal hold).
- **Auditoria explícita:** `lgpd.submit_dsar` na abertura (o portal não gera trilha ambiente); a
  **leitura** no CMS entra por `audit.access` (`*lgpd*` — spec 14).
- **Anti-abuso:** `throttle:lgpd-dsar` + honeypot + time-trap (ADR-021).
- **Prova:** `LgpdTest` (cifra em repouso, prazo, permissão, retenção/legal hold, 429).

### 8.3 Retenção e `legal hold`

- **Porta do Core:** `Core\Contracts\RetentionRegistry` + `Ports\RetentionTarget`; cada módulo
  registra seu alvo no `register()`. Comando agendado **`retention:expunge`** (mensal).
- **Nunca `DROP` de histórico** (ADR-017): o alvo `lgpd-dsar` **anonimiza** o dado pessoal após o
  prazo (`LGPD_DSAR_RETENTION_DAYS`, default 365) preservando protocolo/atendimento; respeita
  **`legal_hold`**.
- **Superfície de gestão:** no CMS (`/lgpd/{protocolo}` → **Aplicar/Liberar legal hold**, permissão
  `lgpd.aprovar`), auditada como `lgpd.legal_hold` (o evento explícito usa o mesmo nome do span/log).
- **Integridade:** `type`/`status` têm `CHECK` no banco (migration aditiva); a retenção pagina com
  `chunkById` — não carrega o lote inteiro em memória.
- **Evidência (auditoria):** `audit:expunge` por partição, preservando `core_audit_legal_holds`
  (spec 14).
- **Determinismo:** `Clock` injetado; `--dry-run` disponível.

### 8.4 Auditoria de acesso a dado pessoal

Quem viu dado pessoal, quando: `audit.access` no CMS (`*dsar*`/`*lgpd*`/`*audit*`/`*export*`/
`*download*`), com ator **snapshot**, `request_id`/`trace_id`, `metadata.url` e `integrity_hash`
BLAKE3 (spec 14). O portal **não** gera trilha individual.

### 8.5 Transparência e DPO

- **`/politica-de-privacidade`**: direitos, classificação, retenção, medição e canal do **DPO**
  (nome/e-mail/telefone em `config/lgpd.php`); indexável.
- **`security.txt`** aponta `Policy:` para a política; o **rodapé** do portal linka a página.
- **Base legal/ROPA/DPIA:** o vocabulário de classificação já existe; **ROPA como tabela** e **DPIA**
  formal seguem **pendentes** (plano 17) — não são pré-requisito do fluxo DSAR.

### 8.6 Incidentes e ANPD

Runbook `wiki/runbooks/anpd-incidentes.md`: detectar/conter, avaliar risco, preservar evidência
(`legal hold`), comunicar **ANPD** e titulares (LGPD art. 48), corrigir com teste de regressão.
A evidência vive na trilha (spec 14), não em documento solto.

### 8.7 Analytics privacy-first

- **Sem terceiros por padrão**; a medição é agregada e sem identificação pessoal. **Google Analytics
  é opt-in** (ID no painel de suporte) — a única exceção externa da ADR-002.
- **Sem cookies não essenciais** por padrão → **sem banner**; se um dia houver, o banner é acessível
  e a política explica (ADR-019).
- A página de privacidade reflete o estado real (`lgpd.analytics.third_party`).

## 9. PII em log e telemetria

- `pii_redact()` mascara CPF/CNPJ/e-mail/telefone/token/segredo; `log_event`/span carregam apenas
  **`scalar|null`** e **nenhum** PII (ADR-024).
- ID de correlação e ator da **auditoria** são exceções previstas (evidência, spec 14); o log técnico
  é efêmero e não recebe payload pessoal.
- Erros → **RFC 7807** (JSON/API) ou HTML acessível, com `trace_id`; nunca stack/PII na tela.

## 10. Modelo de ameaça leve (STRIDE)

| Ameaça | Superfície | Mitigação |
|---|---|---|
| **S**poofing | login CMS, sessão | Argon2id, sessão regenerada, `__Host-`, domínio isolado |
| **T**ampering | upload, DSAR, auditoria | MIME real + checksum, cast `encrypted`, trilha imutável + BLAKE3 |
| **R**epudiation | mutações do CMS | `AuditWriter` via única + `GlobalAuditSubscriber` (spec 14) |
| **I**nformation disclosure | PII em log/API/erro | `pii_redact`, cast `encrypted`, API sem PII, RFC 7807 |
| **D**enial of service | formulários, login, API | 3 camadas de rate limit no Valkey + borda (ADR-021) |
| **E**levation of privilege | permissões, `Suporte` | deny-by-default, `{module}.{ação}`, 404 no grupo reservado (spec 11) |

## 11. Mapa de código

| Artefato | Caminho |
|---|---|
| Headers do edge / CSP | `docker/web/Caddyfile` (spec 16) |
| Hardening do PHP | `docker/php-fpm/php.ini` · `docker/php-fpm/zz-custom.conf` |
| Cookies/sessão | `config/session.php` · `config/environments.php` |
| Headers de defesa no app | `src/core/Internal/Http/Middleware/EnforceSecurityHeaders.php` |
| Login do CMS | `src/core/Area/Cms/**` · `ThrottlePanelLogin.php` |
| Rate limit | `src/core/CoreServiceProvider.php` (`RateLimiter::for`) · `bootstrap/app.php` |
| RFC 7807 + `Retry-After` | `src/core/Internal/Exceptions/ProblemDetails*Renderer.php` |
| DSAR/LGPD | `src/modules/Portal/Lgpd/**` · `config/lgpd.php` |
| Retenção | `src/core/Internal/Retention/**` · `Core\Contracts\RetentionRegistry` |
| Auditoria / legal hold | `src/core/Internal/Audit/**` (spec 14) |
| PII em log | `pii_redact()` (observabilidade) |
| Zero CDN / licenças | `tools/vendor-check.sh` · `tools/{licenses-check,assets-licenses-check}.php` |
| Runbook ANPD | `wiki/runbooks/anpd-incidentes.md` |

## 12. Verificação (gates)

| O que | Comando / teste |
|---|---|
| Headers/CSP/ETag público | e2e `14-security-headers` |
| A11y do alto contraste e do 429 | e2e `05-preferencias-a11y` · `04-axe-wcag` |
| LGPD ponta a ponta | e2e `13-lgpd` · `LgpdTest` |
| Login/limite/sessão | `CmsAuthTest` · `ProblemDetailsTest` |
| Trilha/legal hold | `AuditTrailTest` · `AuditRetentionTest` (spec 14) |
| Zero CDN / licenças | `tools/vendor-check.sh` · `make check` (`check-vendor`) |
| Tudo junto | `make check E2E=1` **2×** verde |

## 13. Pendências e fora de escopo

- **FPC (Valkey):** não adotado (ADR-022 §6 — descartável sem ganho demonstrado).
- **Backlog (ADR-019):** ROPA como tabela, DPIA formal, base legal por tratamento, transferência
  internacional/subprocessadores, gov.br SSO, 2FA, recuperação de senha.
- **Nunca sem ADR:** allowlist externa, CSP permissiva/`unsafe-inline`, captcha como primeira linha,
  minificação de HTML em runtime, telemetria de terceiros por padrão.
