--- name: seguranca-descomplicar description: Gestão do plugin próprio "Segurança Descomplicar" (seguranca-descomplicar, v1.4.2) via WP-CLI e REST API nos 7 sites do bundle CWP. Cobre os 5 módulos — Security Headers (CSP/XFO/security.txt), Wordfence Agent (endpoint REST autenticado para sites sem SSH), Login/dash (URL de login customizada), Descomplicar Monitor (telemetria para dash.descomplicar.pt), Admin Hardening (Classic Editor, emails de update, nags) — com comandos `wp seguranca-descomplicar`, options `segdesc_*`, e gotchas reais de produção. Usar quando "seguranca-descomplicar", "plugin de segurança Descomplicar", "CSP", "X-Frame-Options", "security.txt", "wordfence agent", "login dash", "/dash", "descomplicar monitor", "admin hardening", "classic editor forçado", "esconder avisos de update", "token do agente", "allowlist IP wordfence agent". --- # /seguranca-descomplicar — Plugin próprio de segurança WordPress (5 módulos) Plugin normal (não mu-plugin, decisão explícita), instalado em `wp-content/plugins/seguranca-descomplicar/`, **gerido inteiramente pela Descomplicar®** — código-fonte único, versionado em `71.Seguranca/plugin/seguranca-descomplicar/seguranca-descomplicar.php`, distribuído por SCP/cp aos 7 sites do bundle. Substitui: `wordsense-agent.php` (plugin antigo), mu-plugin `descomplicar-security-headers.php`, plugin autónomo `descomplicar-monitor`, e todos os snippets WPCode/Code Snippets de login `/dash` e admin hardening que estavam clonados byte-a-byte em 6 dos 7 sites. **Fonte:** código-fonte completo lido nesta sessão (1494 linhas) + `CONFIG-Plugins-Referencia.md` §2 (config ao vivo verificada nos 7 sites) + `index.md` (changelog de 8 sessões de produção, bugs reais e correcções). **Gerido/consumido por (ferramentas externas, fora desta skill):** `71.03-SecHeaders/tools/secheaders.py` (módulo 1) e `71.01-Wordsense/tools/wordsense.py`, transporte `http` (módulo 2). --- ## Contexto CWP — sempre obrigatório ```bash # Local (quando o path existe no filesystem do agente) wp seguranca-descomplicar --path=$PATH # Via SSH (servidor real) ssh -p 9443 root@server.descomplicar.pt \ "wp seguranca-descomplicar --path=$PATH --allow-root" ``` Namespace REST do plugin: `seguranca-descomplicar/v1` (todos os endpoints exigem `Authorization: Bearer `, token de 64 chars hex em `segdesc_agent_token`). --- ## Decision tree — qual módulo/comando usar | Preciso de | Módulo | Comando/opção | |---|---|---| | Ver/mudar CSP, XFO, security.txt | 1 | `wp seguranca-descomplicar headers status\|set` | | Diagnosticar/ajustar Wordfence sem SSH (via HTTP) | 2 | REST `seguranca-descomplicar/v1/wordfence/*` | | Ligar/mudar a URL de login custom | 3 | `wp seguranca-descomplicar login status\|enable\|disable\|slug\|block-wp-login` | | Ver estado da telemetria para o dashboard | 4 | `wp option get descomplicar_monitor_*` (sem comando CLI próprio) | | Ligar/desligar Classic Editor, emails de update, nags | 5 | `wp seguranca-descomplicar hardening status\|enable\|disable` | | Rodar o token do agente REST | — | `wp seguranca-descomplicar token --rotate` | | Restringir o REST endpoint a IPs específicos | — | `wp seguranca-descomplicar allowlist --set=IP1,IP2` | --- ## Referência de constantes (`SEGDESC_*`) Todas as constantes definidas no plugin (topo do ficheiro, antes de qualquer hook), agrupadas por função — confirma cobertura total do código-fonte (26 constantes no total). **Identificação** | Constante | Valor | Nota | |---|---|---| | `SEGDESC_VERSION` | `'1.4.2'` | Devolvida em `wordfence/status.agent_version` | | `SEGDESC_NS` | `'seguranca-descomplicar/v1'` | Namespace REST | **Options (`segdesc_*`, persistidas em `wp_options`)** — ver tabelas de cada módulo abaixo para defaults/comportamento; nomes reais: `segdesc_agent_token`, `segdesc_headers_enabled`, `segdesc_csp`, `segdesc_xfo`, `segdesc_csp_report_only`, `segdesc_security_txt`, `segdesc_ip_allowlist`, `segdesc_nginx_csp_covered`, `segdesc_login_enabled`, `segdesc_login_slug`, `segdesc_login_block_wplogin`, `segdesc_hardening_enabled`, `segdesc_hardening_classic_editor`, `segdesc_hardening_update_emails`, `segdesc_hardening_update_nags`. **Defaults/valores hardcoded (literais PHP usados como fallback, não são options)** | Constante | Valor | |---|---| | `SEGDESC_DEFAULT_LOGIN_SLUG` | `'dash'` | | `SEGDESC_DEFAULT_CSP` | CSP de enforcement (ver secção 1) | | `SEGDESC_DEFAULT_XFO` | `'SAMEORIGIN'` | | `SEGDESC_DEFAULT_CSP_REPORT_ONLY` | CSP Report-Only (ver secção 1) | | `SEGDESC_DANGEROUS_KEYS` | `serialize(['waf_status', 'isPaid', 'apiKey'])` — chaves `wfconfig` que exigem `force_dangerous=true` em `/wordfence/config` | **Rate-limit do endpoint REST** (só editável no código, sem option/wp-cli) | Constante | Valor | Nota | |---|---|---| | `SEGDESC_RATE_LIMIT_WINDOW` | `60` (segundos) | Janela do tecto geral de pedidos | | `SEGDESC_RATE_LIMIT_MAX` | `60` | Pedidos nominais/IP/janela (~30 reais, ver nota da dupla invocação) | | `SEGDESC_RATE_LIMIT_FAIL_MAX` | `10` | Falhas nominais/IP/janela antes do lockout (~5 reais) | | `SEGDESC_RATE_LIMIT_FAIL_LOCKOUT` | `900` (segundos = 15 min) | Duração do lockout por IP | --- ## 1. Security Headers — CSP, X-Frame-Options, security.txt Hook `send_headers` (envia CSP/XFO/CSP-Report-Only) + hook `init` (serve `/.well-known/security.txt`, RFC 9116). ### Options (`segdesc_*`) | Option | Default | Nota | |---|---|---| | `segdesc_headers_enabled` | `'1'` (definida na activação) | Liga/desliga o módulo inteiro | | `segdesc_csp` | `SEGDESC_DEFAULT_CSP` (definida na activação) | CSP de **enforcement** — só é enviada se `nginx_csp_covered` ≠ `'1'` | | `segdesc_xfo` | `SAMEORIGIN` (definida na activação) | Sempre enviada, independente do nginx | | `segdesc_csp_report_only` | `SEGDESC_DEFAULT_CSP_REPORT_ONLY` (fallback em código, não persistida na activação) | Sempre enviada — telemetria; único mecanismo de report em sites sem template nginx | | `segdesc_security_txt` | `''` | Sem default — vazio = ficheiro não é servido; conteúdo bruto do `.well-known/security.txt` | | `segdesc_ip_allowlist` | `''` (vazio = sem restrição) | CSV de IPs — só afecta os endpoints REST do módulo 2, não o site público | | `segdesc_nginx_csp_covered` | `'0'` | **`'1'` em sites com o template nginx aplicado** — desliga o CSP de enforcement do plugin (evita intersecção de directivas entre 2 CSPs); XFO continua sempre activo | ### Comandos ```bash # Estado wp seguranca-descomplicar headers status --path=$PATH # Actualizar CSP/XFO wp seguranca-descomplicar headers set --csp="default-src 'self'; script-src 'self' https:" --path=$PATH wp seguranca-descomplicar headers set --xfo=SAMEORIGIN --path=$PATH # security.txt a partir de ficheiro local wp seguranca-descomplicar headers set --security-txt-file=/tmp/security.txt --path=$PATH # Ligar/desligar o módulo inteiro wp seguranca-descomplicar headers set --enabled=1 --path=$PATH # Opções que o wp-cli custom NÃO cobre — usar wp option directo wp option get segdesc_nginx_csp_covered --path=$PATH wp option update segdesc_nginx_csp_covered 1 --path=$PATH # sites com template nginx aplicado wp option get segdesc_ip_allowlist --path=$PATH ``` ### CSP real em produção (v1.4.2 — permissivo por directiva, sem lista branca de domínios) ``` default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' data: https:; frame-src https:; frame-ancestors 'self'; object-src 'none' ``` Decisão explícita do utilizador (10ª sessão): uma lista branca por domínio (`frame-src youtube.com google.com ...`) é manutenção infinita — cada embed novo obriga a voltar a editar. `frame-src https:` permite qualquer iframe servido por HTTPS; o que interessa bloquear é `http://` não encriptado e `data:`/`javascript:`. `object-src 'none'` e `frame-ancestors 'self'` mantêm-se restritos (sem caso de uso legítimo / protecção anti-clickjacking deste site). **Nota de produção (16-08-2026):** na prática, no bundle CWP, CSP/XFO são geridos maioritariamente pelo **template nginx** (`71.03-SecHeaders/`, nível de servidor, sobrevive a cache/preload de plugin) nos sites que o têm aplicado — o módulo do plugin fica então limitado a `security.txt` + CSP Report-Only (telemetria, ver `segdesc_nginx_csp_covered`). Em `emanuelalmeida.pt` (piloto desde a 11ª sessão), os headers passaram a ser servidos por **Cloudflare Transform Rules** (token do plugin App for Cloudflare®) — nginx e este plugin continuam a enviar os seus próprios headers, mas ficam invisíveis ao tráfego real porque o `set` do Cloudflare se sobrepõe antes de chegar ao browser; servem só de fallback para acesso directo à origem (bypass do Cloudflare). --- ## 2. Wordfence Agent — endpoint REST autenticado (sites sem SSH) 8 rotas REST sob `seguranca-descomplicar/v1`, autenticação partilhada via `segdesc_check_auth()` (allowlist → lockout → rate-limit → Bearer token). Registadas sempre; `/wordfence/status` indica se o Wordfence está mesmo activo no site. | Rota | Método | Payload | Função | |---|---|---|---| | `/headers/status` | GET | — | Estado do módulo 1 | | `/headers/set` | POST | `csp`, `xfo`, `security_txt`, `enabled` | Ajusta módulo 1 remotamente | | `/wordfence/status` | GET | — | `wordfence_active`, `version`, `db_prefix` | | `/wordfence/query` | POST | `sql` | **Só SELECT/SHOW contra exactamente 1 tabela `wf*`** — bloqueia JOIN, subquery, UNION, INSERT/UPDATE/DELETE/DROP/etc. | | `/wordfence/config` | POST | `key`, `value`, `force_dangerous` | UPSERT em `{prefix}wfconfig`; chaves perigosas (`waf_status`, `isPaid`, `apiKey`) exigem `force_dangerous=true` | | `/wordfence/unblock-ip` | POST | `ip` | `DELETE FROM {prefix}wfblockediplog WHERE IP = INET6_ATON(ip)` | | `/wordfence/whitelist-ip` | POST | `ip`, `reason` | Acrescenta `"{ip} #{reason}"` a `neverBlockIP` em `wfconfig` | | `/wordfence/scan` | POST | — | `wfScanEngine::startScan()` se a classe existir; senão `do_action('wordfence_doScan')` (fallback cron) | **`/headers/*` — nota de proveniência:** os 2 endpoints REST de headers (`status`/`set`) foram desenhados para o transporte `http` de `71.03-SecHeaders/tools/secheaders.py` (comentário no código-fonte: "usados por secheaders.py transporte 'http' (futuro, hoje só SSH)") — presentes e funcionais, mas **ainda não consumidos em produção**: `secheaders.py` aplica correcções via SSH/wp-cli directo, não via este REST. Único consumidor real hoje é o próprio `wp seguranca-descomplicar headers`. ### Autenticação e rate-limit (importante para clientes REST) ``` Authorization: Bearer ``` - **Allowlist de IP** (opcional, `segdesc_ip_allowlist`) verificada primeiro. - **Rate-limit** por IP: 60 pedidos/60s nominal — **mas o `WP_REST_Server` invoca `permission_callback` DUAS VEZES por pedido HTTP real** (confirmado ao vivo em `carstuff.pt`), pelo que o limite prático é ~30 pedidos reais, não 60. - **Lockout por falhas de auth:** 10 falhas nominais (~5 reais) → lockout de 15 min (900s), chaveado por IP via transient. - **IP do cliente** (`segdesc_client_ip()`) só confia em `X-Forwarded-For` quando `REMOTE_ADDR` é um proxy local confiável (`127.0.0.1`/`::1`) — as portas backend do CWP (2083/2031/2095) são acessíveis directamente da internet, por isso um pedido directo ao backend usa sempre `REMOTE_ADDR`, nunca o XFF do atacante. ### Comandos WP-CLI relacionados ```bash # Ver/rodar o token de autenticação do agente wp seguranca-descomplicar token --path=$PATH wp seguranca-descomplicar token --rotate --path=$PATH # Allowlist de IP para os endpoints REST wp seguranca-descomplicar allowlist --path=$PATH wp seguranca-descomplicar allowlist --set=203.0.113.5,203.0.113.6 --path=$PATH wp seguranca-descomplicar allowlist --clear --path=$PATH ``` ### Exemplo de pedido real (curl) ```bash TOKEN=$(wp seguranca-descomplicar token --path=$PATH) curl -s https://site.pt/wp-json/seguranca-descomplicar/v1/wordfence/status \ -H "Authorization: Bearer $TOKEN" ``` --- ## 3. Login /dash — URL de login customizada e segura Substitui os antigos snippets WPCode "Alterar /wp-admin para /dash" (que deixaram de executar por `E_DEPRECATED` em PHP 8.2 — ver Gotchas). Handler em `template_redirect` prioridade 1 (antes do `redirect_canonical`, prioridade 10). ### Comportamento quando `segdesc_login_enabled = '1'` - `/` (default `dash`): mostra o form nativo de login WP se não autenticado; `wp_redirect(admin_url())` se autenticado. - `wp-login.php` directo (GET): **404** para não autenticados. O **POST** do form (submissão real do login) continua a funcionar — o bloqueio só intercepta GET. - `/wp-admin/` directo: **404** para não autenticados, excepto `admin-ajax.php` (necessário para plugins front-end). ### Hooks internos | Hook | Prioridade | Função | |---|---|---| | `init` → `segdesc_login_rewrite_rule` | default (10) | Regista `add_rewrite_rule('^/?$', 'index.php?segdesc_login=1', 'top')` — só se o módulo estiver ligado | | `query_vars` → `segdesc_login_query_var` | default (10) | Regista a query var `segdesc_login` (necessária para o WP reconhecer o parâmetro da rewrite rule) | | `template_redirect` → `segdesc_login_handle` | **1** | Handler principal — antes do `redirect_canonical` (prioridade 10) | | `init` → `segdesc_login_block_wp_login` | default (10) | Bloqueio de `wp-login.php` directo (GET) | | `init` → `segdesc_login_block_wp_admin` | default (10) | Bloqueio de `/wp-admin/` directo (excepto `admin-ajax.php`) | | `update_option_segdesc_login_slug` → `segdesc_login_flush_rules_on_slug_change` | default (10) | `flush_rewrite_rules()` automático sempre que `segdesc_login_slug` muda — **mesmo por `wp option update` directo**, não só pelo subcomando `login slug` | ### Options | Option | Default | Nota | |---|---|---| | `segdesc_login_enabled` | `'0'` | Módulo desligado por omissão — só activo onde pedido | | `segdesc_login_slug` | `'dash'` (constante `SEGDESC_DEFAULT_LOGIN_SLUG`, não persistida a menos que alterada) | Sanitizado para `[a-z0-9_-]` | | `segdesc_login_block_wplogin` | `'1'` | Bloqueia `wp-login.php` directo (GET) | ### Comandos ```bash wp seguranca-descomplicar login status --path=$PATH wp seguranca-descomplicar login enable --path=$PATH # faz flush_rewrite_rules() automático wp seguranca-descomplicar login disable --path=$PATH wp seguranca-descomplicar login slug --set=acesso --path=$PATH wp seguranca-descomplicar login block-wp-login --on --path=$PATH wp seguranca-descomplicar login block-wp-login --off --path=$PATH ``` `status` devolve `enabled`, `slug`, `block_wp_login`, `login_url` (URL completo já resolvido com `home_url()`). **Nota carstuff.pt (achado 10ª sessão, fora do âmbito deste módulo):** `/dash` naquele site serve login e `wp-login.php` redirecciona para `/dash` por um mecanismo que **não é o Módulo 3** (nunca activado em carstuff.pt até à migração completa) nem o snippet WPCode legado (sempre em draft) — candidato mais provável é o `branda-white-labeling` (login-screen custom). Login do admin funciona normalmente; não confundir com este módulo ao diagnosticar `/dash` nesse site especificamente. --- ## 4. Descomplicar Monitor — telemetria para dash.descomplicar.pt Classe `Descomplicar_Monitor` (singleton), **funde o antigo plugin autónomo `descomplicar-monitor`** — reutiliza as MESMAS options (`descomplicar_monitor_*`, prefixo diferente do resto do plugin, propositado para preservar dados na migração) e o MESMO cron (`descomplicar_monitor_cron`). Guard `is_plugin_active('descomplicar-monitor/descomplicar-monitor.php')`: só instancia se o plugin antigo não estiver activo (evita duplicar envios). **Sem comando WP-CLI próprio** — este módulo não regista `WP_CLI::add_command`. Interage-se via `wp option`, `wp eval`, ou a página de definições em **Definições → Descomplicar Monitor** (`add_options_page`, capability `manage_options`; grupo de settings `descomplicar_monitor_settings` via `register_settings`, hook `admin_init`; AJAX `descomplicar_monitor_test` / `descomplicar_monitor_send_now`, ambos com nonce próprio via `check_ajax_referer`). ### Hooks internos | Hook | Função | |---|---| | `admin_menu` → `add_admin_menu` | Regista a página **Definições → Descomplicar Monitor** | | `admin_init` → `register_settings` | Regista o grupo `descomplicar_monitor_settings` (campos `api_key`, `enabled`) | | `descomplicar_monitor_cron` → `send_monitoring_data` | Handler do cron — envia a telemetria | | `cron_schedules` → `add_cron_interval` | Regista um intervalo **próprio** chamado `twice_daily` (43200s = 12h) — **não é o `twicedaily` nativo do WordPress** (nomes diferentes, mesmo valor); `wp_schedule_event` usa este nome custom | | `wp_ajax_descomplicar_monitor_test` → `ajax_test_connection` | AJAX "Testar Conexão" | | `wp_ajax_descomplicar_monitor_send_now` → `ajax_send_now` | AJAX "Enviar Agora" | | `init` → `ensure_cron` | Reagenda `descomplicar_monitor_cron` se `wp_next_scheduled()` devolver vazio | **Sem `register_deactivation_hook`** (nenhum módulo do plugin tem) — ao desactivar o plugin, o evento `descomplicar_monitor_cron` continua agendado no wp-cron, mas o código deixa de carregar, logo o disparo do evento não faz nada (sem handler registado) — consome um ciclo de cron sem efeito, sem erro visível. Reactivar retoma o envio automaticamente, sem reconfigurar nada (options preservadas). ### Options | Option | Nota | |---|---| | `descomplicar_monitor_enabled` | `'1'`/`'0'` — sem module flag `segdesc_*` (nome legado preservado) | | `descomplicar_monitor_api_key` | Chave enviada como header `X-API-Key` | | `descomplicar_monitor_last_sent` | Timestamp `Y-m-d H:i:s`, actualizado em cada tentativa (sucesso ou falha) | | `descomplicar_monitor_last_status` | `'success'` ou a mensagem de erro | ### Dados recolhidos (POST JSON para `https://dash.descomplicar.pt/api/wp-monitor`) URL, nome, **email de admin**, versão WP/PHP/MySQL, **flag multisite**, **timestamp ISO 8601** (`current_time('c')`) e **timezone** do site, tema (+ parent), lista de plugins activos c/ versão, updates pendentes (core/plugins/themes, com bloco `counts` agregado), contagem de posts/páginas/utilizadores/comentários, estatísticas da BD (**nº de tabelas `{prefix}*`**, tamanho em MB, **prefix**, transients expirados, tamanho do autoload em KB), memory_limit, upload máximo, debug mode, SSL, cron disabled, cache habilitada, e um bloco `health` (status good/warning/critical com lista de issues: `WP_DEBUG` sem log, sem SSL, uploads sem permissão de escrita, memória PHP < 64MB). **Nota:** `get_pending_updates()` chama `wp_update_plugins()` e `wp_update_themes()` directamente antes de ler `get_plugin_updates()`/ `get_theme_updates()` — força uma verificação síncrona a `api.wordpress.org` em **cada execução do cron de 12h**, mesmo que o WordPress já tivesse um resultado em cache válido. Corre em contexto de cron (não bloqueia carregamento de página), por isso não é o mesmo problema de performance do Gotcha #3 (que era em `admin_init`/page load). ### Comandos úteis ```bash # Estado wp option get descomplicar_monitor_enabled --path=$PATH wp option get descomplicar_monitor_last_status --path=$PATH wp option get descomplicar_monitor_last_sent --path=$PATH # Forçar envio imediato (fora do cron 12h) wp eval 'echo json_encode(Descomplicar_Monitor::get_instance()->send_monitoring_data());' --path=$PATH # Confirmar que o cron está agendado (reagenda-se sozinho em cada 'init' se limpo) wp cron event list --path=$PATH --fields=hook,next_run_relative | grep descomplicar_monitor_cron ``` --- ## 5. Admin Hardening — Classic Editor, emails de update, nags Substitui 3 snippets WPCode/Code Snippets clonados byte-a-byte (`md5sum` idêntico) em 6 dos 7 sites CWP (origem: tutorial WPBeginner). O snippet de emails tinha um bug real herdado: `add_filter('auto_core_update_send_email', 'wpb_stop_auto_update_emails', ...)` referenciava uma função nunca definida (`wpb_stop_update_emails`, sem `_auto`), gerando `E_WARNING` silencioso em cada auto-update. O módulo simplifica para `__return_false` directo. ### Options | Option | Default (quando `hardening_enabled='1'`) | Nota | |---|---|---| | `segdesc_hardening_enabled` | `'0'` | Módulo desligado por omissão | | `segdesc_hardening_classic_editor` | `'1'` | `use_block_editor_for_post{,_type}` → `__return_false` | | `segdesc_hardening_update_emails` | `'1'` | `auto_{core,plugin,theme}_update_send_email` → `__return_false` | | `segdesc_hardening_update_nags` | `'1'` | Wipe de `admin_notices`/`all_admin_notices`/`network_admin_notices` (incl. avisos Wordfence) + CSS para esconder badges de contagem | ### Comandos ```bash wp seguranca-descomplicar hardening status --path=$PATH wp seguranca-descomplicar hardening enable --path=$PATH wp seguranca-descomplicar hardening enable --no-classic-editor --path=$PATH # liga o módulo mas deixa o bloco editor wp seguranca-descomplicar hardening enable --no-update-emails --path=$PATH wp seguranca-descomplicar hardening enable --no-update-nags --path=$PATH wp seguranca-descomplicar hardening disable --path=$PATH ``` ### Como o bloqueio de nags funciona de facto (decisão de negócio: agressivo) Decisão do Emanuel: bloquear TAMBÉM avisos Wordfence e visibilidade de updates pendentes — clientes reencaminhavam esses avisos/emails achando que eram falha da Descomplicar. Dois mecanismos distintos, **nunca tocando nos transients de update** (ver Gotchas #3): 1. **`in_admin_header` prioridade 999** → `unset($wp_filter['admin_notices'])` (+ `all_admin_notices` + `network_admin_notices`). Tem de ser `in_admin_header`, não `admin_init` (ver Gotchas #2). 2. **`admin_head`** → injecta `