# 07 — System Ops: Filesystem, Base de Dados, WP-CLI, Security Scanner, Performance Analyzer Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em `emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`), 19-08-2026. Cruzado com `docs/00-ARQUITECTURA.md` (arquitectura geral, cadeia de arranque, `emcp_tools_register_ability()`) e `skill://emcp-tools` (postura de segurança ao vivo nos 3 sites do ecossistema, mecanismo do deny-list incremental). **Estes são os grupos de MAIOR RISCO do plugin.** Todas as 6 tools de filesystem que mutam estado, todas as 6 de base de dados que mutam estado, e as 4 de WP-CLI (incluindo as duas de só leitura, `get-wp-cli-job`/`list-wp-cli-jobs`) fazem parte dos 131 slugs desligados por omissão em `emanuelalmeida.pt`/`starter.descomplicar.pt` (ver `skill://emcp-tools` §2.1/§2.2). Security Scanner e Performance Analyzer são as únicas duas ferramentas deste documento que ficam **activas por omissão** — são estritamente de leitura, nunca escrevem nada. Todos os grupos deste documento registam-se **sempre** (não dependem de Elementor activo, nem de nenhum módulo opcional) — são chamados directamente em `EMCP_Tools_Ability_Registrar::register_groups()`, fora de qualquer bloco condicional `if ( class_exists(...) )` ou `if ( $elementor_active )`. A única coisa que os torna "inúteis" num site com config por omissão é estarem no deny-list aplicado por `EMCP_Tools_Plugin::filter_disabled_tools()` (ver doc 00 §3). --- ## 1. Filesystem **Classe de abilities:** `EMCP_Tools_Filesystem_Abilities` (`includes/abilities/class-filesystem-abilities.php`) **Condição de registo:** sempre activo (chamado sem guarda em `register_groups()`). **`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )` — mesmo as de leitura, porque `read-file`/`search-files` podem expor segredos de config de outros ficheiros do site. | Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive | |---|---|---|---| | `read-file` | `path` (string, **required**), `offset` (int, 1-based), `limit` (int) | Lê um ficheiro dentro de `ABSPATH`. Limite de 5 MB (`MAX_READ_BYTES`); recusa binários (devolve `{binary:true}` em vez do conteúdo); recusa `wp-config.php` (`is_read_protected`); suporta slice por linhas via `offset`/`limit`. | readonly / não destrutivo | | `list-directory` | `path` (opt, default raiz), `recursive` (bool, profundidade máx. 5, cap 2000 entradas) | Lista entradas (nome/path/tipo/size/mtime) de um directório dentro de `ABSPATH`. | readonly | | `search-files` | `query` (string, **required**), `path` (opt), `extensions` (array de string), `max_results` (int, default 200, tecto 500) | Grep de substring (case-sensitive) recursivo por uma árvore; ignora ficheiros >5 MB e `wp-config.php`; devolve `{file, line, text}` capado a 300 chars por match. | readonly | | `write-file` | `path`, `content` (ambos **required**) | Cria ou sobrescreve um ficheiro. Faz backup do existente primeiro; recusa `wp-config.php`/`.htaccess`; limite 5 MB (`MAX_WRITE_BYTES`); requer `writes_allowed()` (capability `edit_files` + `!DISALLOW_FILE_EDIT`); invalida OPcache se `.php`; grava no change ledger unificado. | **não readonly, destrutivo** — **desligado por omissão** | | `edit-file` | `path`, `old_string`, `new_string` (**required**), `replace_all` (bool) | Substituição exacta de string (`old_string` deve corresponder exactamente uma vez, a menos que `replace_all`). Backup, mesmo gate de escrita, mesma protecção de ficheiros, mesmo registo no ledger. | **destrutivo** — **desligado por omissão** | | `delete-file` | `path` (**required**), `confirm` (bool) | Apaga um ficheiro. Exige `confirm:true`; backup antes de apagar; mesmo gate de escrita e protecção. | **destrutivo** — **desligado por omissão** | ### Guard: `EMCP_Tools_Filesystem_Guard` Ficheiro: `includes/class-filesystem-guard.php`. Comentário do próprio ficheiro: *"This is the security boundary for the filesystem tools. resolve_path() is the one chokepoint that makes 'inside the WordPress install only' true."* Constantes: `MAX_READ_BYTES = 5242880` (5 MB), `MAX_WRITE_BYTES = 5242880` (5 MB), `BACKUP_DIR = 'emcp-fs-backups'`. - **`resolve_path( string $path, ?string $root = null )`** — o chokepoint único. `$root` por omissão `ABSPATH` (parâmetro só existe para testes). Rejeita path vazio ou com byte NUL. Detecta se é absoluto (começa por `/`, `\`, ou `C:\`-like via regex). Constrói o candidato (absoluto tal-e-qual, ou `rtrim($root) . '/' . ltrim($path)`). Faz `realpath()`; se o próprio alvo não existir ainda (caso de escrita nova), resolve o **directório pai** com `realpath()` e reconstrói `parent/basename`. Compara o prefixo do caminho resolvido contra `realpath($root)`: tem de ser **exactamente igual** ou começar por `root . DIRECTORY_SEPARATOR` — nunca um simples `strpos`, para evitar que `/var/www/site-evil` passe por prefixo de `/var/www/site`. Devolve `WP_Error('outside_root')` se escapar. - **`is_protected( string $abs )`** — lista de basenames **escrita/eliminação**-protegidos: `wp-config.php`, `.htaccess` (case-insensitive), filtrável via `emcp_tools_fs_protected_paths`. - **`is_read_protected( string $abs )`** — lista de basenames **leitura**-protegidos: **só** `wp-config.php` — `.htaccess` fica de fora porque não é um segredo, comentário explícito no código: *"wp-config.php carries the DB credentials and auth salts; .htaccess is not a secret so it stays readable."* Filtrável via `emcp_tools_fs_read_protected_paths` — um admin pode adicionar `.env`, ficheiros de chave, etc. **Nota importante:** as duas listas são deliberadamente diferentes (escrita ⊃ leitura) — não é o mesmo array reutilizado. - **`backup_name( rel, timestamp )`** — pure: nome de ficheiro sanitizado `-`, caracteres fora de `A-Za-z0-9._-` viram `-`. - **`is_utf8( content )`** — pure: byte NUL ou falha em `preg_match('//u', $content)` → binário. - **`check_writes( can_edit_files, disallow_file_edit )`** — pure: combina a capability `edit_files` com a constante `DISALLOW_FILE_EDIT`. - **`writes_allowed()`** — wrapper live: `current_user_can('edit_files') && !(defined(DISALLOW_FILE_EDIT) && DISALLOW_FILE_EDIT)`. - **`to_relative( abs )`** — inverso de `resolve_path` para display/log (relativo a `ABSPATH`, slashes normalizados). - **`backup( abs )`** — copia o ficheiro-alvo para `wp-content/uploads/emcp-fs-backups/-` **antes** de qualquer write/edit/delete. Cria `.htaccess` (`Require all denied`) + `index.html` vazio no directório de backups na primeira utilização (bloqueia acesso web directo aos backups). Devolve `''` quando o ficheiro-fonte ainda não existe (é uma criação, não um overwrite) — nesse caso o rollback ficheiro-a-ficheiro é do tipo `'file-create'` em vez de `'file-backup'`. - **`log()`** — `@deprecated 3.10.0`, no-op. Auditoria foi migrada para o change ledger unificado (`EMCP_Tools_Change_Log`/`EMCP_Tools_Change_Recorder::record_file()`, documentado no doc 05) — `class-filesystem-abilities.php::record_fs_change()` monta a entrada do ledger com `rollback` do tipo `file-backup`/`file-create` e chama o recorder directamente. Efeito colateral extra em cada escrita/edição/eliminação de `.php`: `invalidate_php_opcache()` chama `opcache_invalidate($abs, true)` — sem isto, o pedido seguinte podia executar bytecode cached obsoleto em vez do ficheiro recém-alterado. **Storage:** backups em `wp-content/uploads/emcp-fs-backups/` (protegido de acesso web via `.htaccess`). Registo de mudanças no change ledger unificado (ver doc 05) — nenhum log próprio separado. --- ## 2. Base de dados directa **Classe de abilities:** `EMCP_Tools_Database_Abilities` (`includes/abilities/class-database-abilities.php`) **Condição de registo:** sempre activo. **`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )`. | Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive | |---|---|---|---| | `list-tables` | (sem input) | Lista tabelas via `information_schema.TABLES` — nome, `table_rows` estimado, tamanho em bytes (`data_length + index_length`). | readonly | | `describe-table` | `table` (string, **required**) | Valida o nome contra `EMCP_Tools_Database_Guard::valid_table()` e corre `DESCRIBE`; devolve colunas/tipos/keys. | readonly | | `query` | `sql` (string, **required**), `limit` (int, default/tecto `MAX_ROWS=1000`) | Corre SQL de leitura validado por `is_read_only_sql()`. Recusa também leitura das tabelas `users`/`usermeta` mesmo em modo `SELECT` (`query_touches_protected`), apontando para `list-users`/`get-user` em vez disso. | readonly | | `insert-row` | `table`, `data` (objecto) — ambos **required** | `$wpdb->insert()` parametrizado. Recusa tabelas protegidas. Regista no ledger (`rollback.type = db-before-image`, `op = insert`). | **destrutivo** — **desligado por omissão** | | `update-rows` | `table`, `data`, `where` (todos objecto) — **required** | Exige `where` não-vazio (nunca um UPDATE sem condição). Captura `before_image()` (snapshot das linhas afectadas, cap 500) antes de `$wpdb->update()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** | | `delete-rows` | `table`, `where` (**required**), `confirm` (bool) | Exige `confirm:true` + `where` não-vazio. `before_image()` antes de `$wpdb->delete()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** | ### Guard: `EMCP_Tools_Database_Guard` Ficheiro: `includes/class-database-guard.php`. Comentário: *"is_read_only_sql() is the safety boundary for the flexible read path."* Constantes: `MAX_ROWS = 1000`, `BEFORE_IMAGE_CAP = 500`. - **`normalize_sql( string $sql )`** — pure, scanner char-a-char (não regex — evita problemas de backtracking/ReDoS em SQL longo). Substitui todo o comentário (`--`, `#`, `/* */`) por um espaço, todo o literal de string (`'...'`/`"..."`, com escapes de backslash e duplicação de quote reconhecidos) por `''`, e todo o identificador entre backticks por `` `` ``. Não trata `/*! ... */` (comentários executáveis do MySQL) — esses são rejeitados **antes** mesmo de chamar `normalize_sql`. - **`is_read_only_sql( string $sql )`** — a gate real, em cadeia: 1. Se `$sql` contém `/*!` → rejeita de imediato. Comentário: *"MySQL executes the body of /*! ... *​/ executable comments, so we cannot safely strip-and-trust."* 2. `normalize_sql()` + `trim()`. 3. Multi-statement: qualquer `;` que não seja o único carácter final (depois de `rtrim`) → rejeita. 4. Vectores de acesso a ficheiros — regex `/\b(into\s+outfile|into\s+dumpfile|load_file\s*\(|load\s+data\b)/i`. Nota deliberada no código: **sem `\b` a fechar** — porque `load_file(` termina em `(`, e `(` seguido de outro não-word-char não tem word boundary; um `\b` final deixaria passar `LOAD_FILE` por engano. 5. Primeira palavra tem de ser uma de `SELECT/SHOW/DESCRIBE/DESC/EXPLAIN/WITH`. 6. Denylist da frase inteira: `INSERT|UPDATE|DELETE|REPLACE|MERGE|DROP|TRUNCATE|ALTER|CREATE|RENAME|GRANT|REVOKE|HANDLER|CALL|LOCK|UNLOCK|PREPARE|EXECUTE|INTO` — como comentários/literais já foram removidos por `normalize_sql`, isto só apanha keywords reais (não texto dentro de uma string). - **`valid_table( string $table )`** — nomes de tabela **não podem ser parametrizados** em SQL (`?`/`%s` só serve para valores), por isso resolve contra `SHOW TABLES` ao vivo e devolve o nome real exacto (preserva case) ou `WP_Error('unknown_table')`. - **`table_is_protected`/`is_protected( $table )`** — tabelas protegidas por omissão: `$wpdb->users`, `$wpdb->usermeta`, filtrável via `emcp_tools_db_protected_tables`. - **`query_touches_tables`/`query_touches_protected( $sql )`** — o análogo do lado da leitura: remove backticks, normaliza (comentários/strings fora), e testa se algum nome de tabela protegida aparece como identificador real com word boundaries (`wp_users_backup` **não** corresponde a `wp_users`). Aplicado ao tool `query` para bloquear leitura directa de password hashes/tokens de sessão via `SELECT * FROM wp_users`. - **`before_image( $table, $where )`** — `SELECT *` com condições de igualdade AND (parametrizado via `$wpdb->prepare`), `LIMIT 500`, chamado antes de update/delete. - **`log()`** — `@deprecated 3.10.0`, no-op; ledger via `EMCP_Tools_Change_Recorder::record_db()`. **Storage:** nenhum armazenamento próprio — escreve directamente nas tabelas alvo via `$wpdb`; before-images e rollback vão para o change ledger unificado (blob store para snapshots grandes, ver doc 05). --- ## 3. WP-CLI (execução + jobs assíncronos) **Classe de abilities:** `EMCP_Tools_WPCLI_Abilities` (`includes/abilities/class-wpcli-abilities.php`) **Condição de registo:** sempre activo. **Todas as 4 tools deste grupo fazem parte dos 131 slugs desligados por omissão** (`skill://emcp-tools` §2.1) — incluindo as duas de puro leitura (`get-wp-cli-job`/`list-wp-cli-jobs`), ao contrário de outros grupos onde read/write têm gates separadas. Provavelmente porque estas duas só fazem sentido em conjunto com `run`/`dispatch`. **`permission_callback` (todas as 4):** `current_user_can( 'manage_options' )`. Comentário do cabeçalho do ficheiro, citado por ser exactamente o resumo de risco correcto: *"Risk notice. WP-CLI is powerful and, via the shell path, is effectively command execution. The tool is confined by a command blocklist (no eval, eval-file, shell, raw db query, config writes, package install, or arbitrary PHP flags), ships disabled-by-default, is admin-gated, and audit-logs runs."* | Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive | |---|---|---|---| | `run-wp-cli` | `command` (string, **required**, sem `wp` inicial), `timeout` (int, default 60, tecto 300) | Corre um comando WP-CLI síncrono. Devolve stdout/stderr/exit_code. Executa **in-process** (`WP_CLI::runcommand`) se este pedido já corre dentro de um processo WP-CLI (transporte stdio), ou via **shell** (`proc_open` com um binário `wp` configurado) se ligado por HTTP. | **destrutivo** (por defeito de anotação — pode invocar qualquer subcomando não bloqueado) — **desligado por omissão** | | `dispatch-wp-cli` | `command` (**required**), `timeout` (int, default 900, conselho até 86400) | Corre como job **detached** em background (para migrações/bulk tasks longas). Requer o caminho shell disponível — não é possível fazer detach de um comando in-process. Devolve `job_id`. | **destrutivo** — **desligado por omissão** | | `get-wp-cli-job` | `job_id` (string, **required**) | Devolve estado do job (running/completed/failed), exit_code, stdout/stderr (tail capado). | readonly (anotação) — **mesmo assim desligado por omissão** | | `list-wp-cli-jobs` | (sem input) | Lista jobs recentes (metadata apenas, sem stdout/stderr completos). | readonly (anotação) — **mesmo assim desligado por omissão** | Cada execução (`run` e `dispatch`) é registada no change ledger, mas **não é reversível** — comentário explícito no código: *"not reversible — commands have no before-image"*. ### Validator: `EMCP_Tools_WPCLI_Validator` Ficheiro: `includes/wpcli/class-wpcli-validator.php`. **A gate de segurança do grupo inteiro.** Comentário do cabeçalho: *"Args are always passed to the runner as an array (never interpolated into a shell string), so metacharacters in values are inert; this validator blocks the command surface that would let an operator run arbitrary PHP, raw SQL, or arbitrary shell."* - **`BLOCKED_COMMANDS`** = `eval`, `eval-file`, `shell`, `server` — comandos WP-CLI que dão execução de PHP arbitrário, shell interactivo, ou arrancam um servidor web embutido. - **`BLOCKED_SUBCOMMANDS`** (pares `comando subcomando`) = `db query`, `db cli`, `db import`, `db export`, `db reset`, `db drop`, `db clean`, `config set`, `config delete`, `config edit`, `package install`, `package update`, `package uninstall`, `cli update`, `cli cmd-dump`, `cli info`. Note-se: `db query`/`db cli`/etc. é redundante em parte com a gate própria da ability `database` (§2), mas é defesa em profundidade — um agente não pode contornar o guard de SQL via WP-CLI. - **`BLOCKED_FLAG_PREFIXES`** = `--exec`, `--require` (carregam PHP arbitrário), `--path`, `--ssh`, `--http` (retargeting do WP-CLI para outro install/servidor — poderia escapar do site actual), `--prompt` (ficaria pendurado à espera de input interactivo), `--user=0`. - **`validate( $command )`**: `trim()`; rejeita `\r`/`\n` (anti-injecção de linha); remove prefixo `"wp "` tolerado; tokeniza; varre TODOS os tokens contra os prefixos de flag bloqueados (`stripos`, case-insensitive, por prefixo — não exact-match); identifica a "command word" = primeiro token não-flag (não começa por `-`) e o subcommand = segundo token não-flag; rejeita se o comando isolado ou o par comando+subcomando estiverem nas listas. Todas as três listas são filtráveis (`emcp_tools_wpcli_blocked_commands`, `_blocked_subcommands`, `_blocked_flags`). - **`tokenize( $command )`**: tokenizador consciente de aspas (single/double quotes). Fora de aspas, um backslash é **literal** (friendly para paths Windows tipo `C:\wp\wp-cli.phar`). Dentro de aspas duplas, só `\"` e `\\` são escapes reconhecidos; dentro de aspas simples, nada é escapado. Devolve `WP_Error('wpcli_unterminated_quote')` se uma aspa não fechar. ### Runner: `EMCP_Tools_WPCLI_Runner` Ficheiro: `includes/wpcli/class-wpcli-runner.php`. `OUTPUT_CAP = 262144` (256 KB) por stream, truncado com `"…[output truncated]"`. - **`is_cli_context()`** — `defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI')` — true se este pedido já corre dentro de um processo WP-CLI (o caso normal deste ecossistema, que liga via SSH+STDIO a `wp mcp-adapter serve`, ver doc 00 e `skill://emcp-tools`). - **`base_command()`** — o binário `wp` configurado, ordem de prioridade: constante `EMCP_TOOLS_WPCLI_COMMAND` > option `emcp_tools_wpcli_command` > filtro `emcp_tools_wpcli_command`. - **`shell_available()`** — `proc_open` existe, `base_command()` não vazio, e `proc_open` não está em `disable_functions` do php.ini. - **`run( $command, $timeout=60 )`** — valida via `Validator::validate()`; se `is_cli_context()` → `run_in_process()` usa `WP_CLI::runcommand($cmd, ['return'=>'all','exit_error'=>false,'launch'=>false,'parse'=>false])` — corre **dentro do mesmo processo PHP**, sem `fork`/`exec`. Senão, se `shell_available()` → `run_shell()`. - **`run_shell()`** — `proc_open($argv_array, ...)`. Comentário do código, crucial: *"PHP 7.4+: an array command is executed WITHOUT a shell — arguments are passed verbatim, so no metacharacter can be interpreted."* `$argv = base_argv() + tokens + ['--path=' . ABSPATH, '--no-color']`. Poll não-bloqueante (`stream_set_blocking(false)`), timeout com `proc_terminate($proc, 9)` (SIGKILL) se ultrapassar deadline (`timed_out=true`, `exit_code=124`), drena pipes no fim. ### Jobs assíncronos: `EMCP_Tools_WPCLI_Jobs` Ficheiro: `includes/wpcli/class-wpcli-jobs.php`. `KEEP = 50` job dirs mantidos (mais antigos são apagados por `prune()`, por `filemtime`). - **`dir()`** — `wp-content/uploads/emcp-wpcli-jobs/`, criado + protegido na primeira utilização com `.htaccess` (`Require all denied\nDeny from all`) + `index.php` silencioso. - **Estrutura por job** (`/`, `$id = gmdate('Ymd-His') . '-' . substr(md5(uniqid()),0,6)`): - `meta.json` — `{id, command, timeout, status, created, started, finished, exit_code, user}`; `status` transita `queued → running → (completed|failed)`. - `stdout.log` / `stderr.log` — streams capturados. - `run.sh` (POSIX) ou `run.bat` (Windows) — **launcher gerado**, com o comando completo já montado via `escapeshellarg()` por token (`implode(' ', array_map('escapeshellarg', $argv))`) — a escaping fica de fora do `proc_open`/`popen` de spawn. - `exit_code` — ficheiro escrito **pelo launcher** quando o comando termina — é a fonte de verdade para o estado terminal (não um polling do processo pai). - **`dispatch( $command, $timeout=900 )`** — requer `shell_available()` (impossível fazer detach de algo já in-process); valida; `prune()`; grava `meta.json` inicial; `spawn()` lança o launcher **detached** — POSIX: `proc_open(['sh','-c', 'nohup sh run.sh > /dev/null 2>&1 &'], ...)`; Windows: `popen('cmd /c start /B "" cmd /c run.bat', 'r')`. O processo pai **não espera** — retorna `job_id` de imediato com `status='running'`. - **`get( $id )`** — sanitiza `$id` (regex `[^a-z0-9-]` removido); lê `meta.json`; deriva o estado terminal do ficheiro `exit_code` se existir (`0 = completed`, `!=0 = failed`); devolve `tail()` do stdout/stderr (capado a `OUTPUT_CAP`, prefixo `"…[output truncated]"` — mostra o **fim** do log, não o início). - **`all()`** — todos os jobs (`glob(GLOB_ONLYDIR)`), ordenados por `created` desc, sem stdout/stderr completos. - **`spawn()`** — vale a pena ler: gera o `run.sh`/`run.bat` completo primeiro (incluindo redirecção de stdout/stderr/exit_code), depois só lança um shell trivial que executa esse ficheiro — separa completamente a lógica de "o que corre" da lógica de "como fica detached". **Storage:** `wp-content/uploads/emcp-wpcli-jobs//` (protegido de acesso web). --- ## 4. Security & Malware Scanner **Classe de abilities:** `EMCP_Tools_Security_Abilities` (`includes/abilities/class-security-abilities.php`) **Condição de registo:** sempre activo. **`scan-security` está ACTIVA por omissão** (não faz parte do deny-list — a única categoria deste documento com essa distinção, junto com `analyze-performance`). **`permission_callback`:** `current_user_can( 'manage_options' )`. | Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive | |---|---|---|---| | `scan-security` | `checks` (array de enum `malware`/`integrity`/`hardening`/`software`, opt — omitir corre as 4), `deep` (bool, default false), `max_files` (int, default 2000, tecto 20000), `max_seconds` (int, default 20, tecto 120) | Corre até 4 audits e devolve `{summary:{score 0-100, grade A-F, counts}, sections:{malware,integrity,hardening,software}, scan_meta, top_recommendations}`. `deep=false` cobre só `uploads/` + plugins activos + tema activo; `deep=true` cobre toda a `wp-content/` (mais lento). | **readonly, destructive=false, idempotent=true — activa por omissão** | ### Orchestrator: `EMCP_Tools_Security_Scanner` Ficheiro: `includes/security/class-security-scanner.php`. `CRITICAL_WEIGHT=20`, `WARNING_WEIGHT=5`, `CATEGORY_CRIT_CAP=60` (o penalty de criticals **satura a 60 por categoria** — impede que uma categoria sozinha com muitos criticals leve o score a zero), `TOP_RECS=8`. - **Construção LAZY dos 4 audits** — só instanciados na primeira `scan()` que de facto precisa deles. Decisão de design explícita (comentário completo citado em §4.1 abaixo, ligado ao **issue #100**): registar a tool **não pode** instanciar o motor de audit, porque o registo de abilities corre em **cada** carregamento de página de admin e **cada** pedido REST. - **`resolve_checks( $requested )`** — pure: normaliza para o subset válido em ordem canónica; vazio/tudo-inválido → todos os 4. - **`scan( $input )`** — corre os checks pedidos, agrega findings, `summarize()`. - **`summarize( $findings )`** — pure: conta por `status` (critical/warning/pass/info); penalty **por categoria** — `cat_crit_pen[cat] = min(60, soma_de_20_por_cada_critical_nessa_categoria)`; `score = 100 - soma(penalties_por_categoria) - (nº_warnings * 5)`, clamp `[0,100]`; `grade` A(≥90)/B(≥80)/C(≥70)/D(≥60)/F(resto). - **`group_by_category`** — agrupa em 4 secções fixas. - **`rank_recommendations`** — críticos primeiro, depois warnings, corta a 8, formato `"[label] recomendação"`. ### Value object: `EMCP_Tools_Security_Finding` Ficheiro: `includes/security/class-security-finding.php`. Uma única factory pure: `make( id, category, label, status, value, message, recommendation='' )` → array uniforme. `status` ∈ `pass|warning|critical|info`; `recommendation` deve ser não-vazio quando `status != 'pass'`. ### 4.1 Audit — Malware (`EMCP_Tools_Security_Malware_Audit`) Ficheiro: `includes/security/class-security-malware-audit.php`. `MAX_FILE_BYTES=2MB` (ficheiros maiores são saltados), `MAX_LINE_BYTES=64KB` (cap por linha alimentada às regex — guarda anti-ReDoS/backtrack-limit; o comentário explica: linhas longas fazem `preg_match` devolver `false` silenciosamente ao atingir `pcre.backtrack_limit`, mascarando um hit real), `MAX_FILES=2000`/`CEILING=20000`, `TIME_BUDGET=20s`/`CEILING=120s`, `SNIPPET_LEN=120`, `MAX_FINDINGS_PER_FILE=5`. - **`scan_code( code, relpath, in_uploads )`** — pure: corre 5 regras de assinatura linha a linha (ver abaixo); nunca devolve mais de 5 achados por ficheiro; o `value` de cada finding é `{location: "path:line", snippet}` — **nunca o conteúdo completo do ficheiro**. - **`is_misplaced_php( relpath )`** — PHP executável dentro de `uploads/` (extensões `php/phtml/php3-7/phps/pht`). - **`is_trivial_php( code )`** — pure, usa `token_get_all()` para distinguir um `index.php` "Silence is golden" (só `T_OPEN_TAG`/`CLOSE_TAG`/`WHITESPACE`/`COMMENT`/`DOC_COMMENT`) de PHP com código real — evita falsos positivos em guardas de directório vazias. - **`is_excluded( relpath, prefixes )`** — exclui a própria pasta de instalação do plugin (via `EMCP_TOOLS_DIR`) + o directório sandbox gerido (`EMCP_Tools_Sandbox_Paths::relative_base()`), para o scanner não se auto-detectar como malware. - **`run( deep, max_files, max_seconds )`** — `scan_roots(deep)`: `false` → `uploads/` + plugins **activos** + tema activo/pai; `true` → toda a `wp-content/`. Percorre com `RecursiveIteratorIterator` + `FOLLOW_SYMLINKS`, mas **valida que o caminho resolvido continua dentro de `ABSPATH`** (bloqueia escape de symlink). Ficheiro PHP executável sob `uploads/` com código real (não trivial) gera um achado crítico dedicado `malware_uploads_php` **antes** de correr as 5 regras normais. **🏆 O achado mais importante deste ficheiro (citação literal, comentário do autor):** As assinaturas de malware (`eval`, `assert`, `create_function`, `base64_decode`, `gzinflate`, `system`, `exec`, `shell_exec`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSO`, etc.) **NÃO estão escritas de forma literal no ficheiro-fonte** — estão fragmentadas em `signature_tokens()` como concatenações (`'ev' . 'al'`), reunidas em runtime via `expand()`/`strtr()`. Comentário do autor no código: > *"This class is a malware scanner, so its rules have to name the exact functions and > webshell handles that host-level scanners (Imunify360, maldet, Wordfence, ModSecurity) hunt > for. Spelled out intact, this file reads as a c99-style webshell and gets quarantined or > zeroed in place. `require_once` then still succeeds (the path exists) but the class is never > declared, which used to fatal every wp-admin page and REST request (issue #100)."* > *"Splitting the tokens means no intact signature ever sits on disk. The patterns compiled > below are byte-identical to the originals, so detection behaviour is unchanged. Keep any new > signature split the same way."* Este é o motivo directo por trás **tanto** da construção lazy no orchestrator (§4, issue #100) **como** desta fragmentação de tokens: um scanner de malware do **próprio host** identificava literalmente este ficheiro como um webshell e colocava-o em quarentena/zerava-o, partindo o site inteiro (a classe deixava de existir mas o `require_once` continuava a "ter sucesso" silenciosamente — o registo de abilities engolia a excepção mas o servidor MCP ficava sem esta tool). **As 5 regras de assinatura:** 1. `malware_eval_obfuscation` (critical) — `eval`/`assert`/`create_function` envolvendo um decoder (`base64_decode`/`gzinflate`/`gzuncompress`/`str_rot13`/`strrev`/`convert_uudecode`). 2. `malware_request_eval` (critical) — `eval`/`assert`/`system`/`exec`/`passthru`/ `shell_exec`/`popen`/`proc_open` recebendo directamente `$_GET`/`POST`/`REQUEST`/`COOKIE`/`SERVER` — o backdoor RCE clássico. 3. `malware_command_exec` (warning; **critical se `in_uploads`**) — qualquer chamada de `shell_exec`/`passthru`/`proc_open`/`popen`/`system`/`exec` isolada. 4. `malware_webshell_marker` (critical) — strings de webshells conhecidos (`FilesMan`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSOshell`). 5. `malware_long_base64` (warning) — blob de 260+ chars base64-like (payload escondido). ### 4.2 Audit — Integrity (`EMCP_Tools_Security_Integrity_Audit`) Ficheiro: `includes/security/class-security-integrity-audit.php`. - **`diff( checksums, hasher )`** — pure: compara o manifesto de checksums oficial do wordpress.org (md5 por ficheiro core-relative) contra o hash real via `hash_equals()` (timing-safe); ficheiro em falta → `warning('integrity_missing')`; hash não bate → `critical('integrity_modified')`. - **`run()`** — usa a função core `get_core_checksums($wp_version, $locale)` (`wp-admin/includes/update.php`); **exclui tudo em `wp-content/`** (não faz parte dos checksums oficiais de core). Se a API estiver inacessível (offline) devolve **um único** finding `info` com `api.ok=false` — degrada graciosamente em vez de falhar todo o scan. ### 4.3 Audit — Hardening (`EMCP_Tools_Security_Hardening_Audit`) Ficheiro: `includes/security/class-security-hardening-audit.php`. `FETCH_TIMEOUT=8s`. 7 checks, cada `evaluate_*()` pure, `run()` gathers live + **UM** loopback GET reutilizado para dois checks (headers + generator meta): | Check | Pass | Warning/Critical | |---|---|---| | `harden_file_edit` | `DISALLOW_FILE_EDIT` definido true | editor de ficheiros do admin ligado | | `harden_debug_display` | `WP_DEBUG_DISPLAY` off | `warning` se on em produção; `info` noutro ambiente | | `harden_admin_user` | sem user `admin` | `username_exists('admin')` | | `harden_xmlrpc` | XML-RPC desligado | `xmlrpc.php` existe + filtro `xmlrpc_enabled` true | | `harden_https` | scheme de `home_url()` é `https` | qualquer outro | | `harden_security_headers` | X-Frame-Options + X-Content-Type-Options + Strict-Transport-Security + Content-Security-Policy todos presentes | falta pelo menos um (via 1 loopback GET a `home_url('/')`) | | `harden_version_disclosure` | sem `readme.html` nem meta `generator` no HTML | qualquer um presente | ### 4.4 Audit — Software (`EMCP_Tools_Security_Software_Audit`) Ficheiro: `includes/security/class-security-software-audit.php`. `MAX_ABANDONED_LOOKUPS=30` (orçamento de chamadas `plugins_api` **ao vivo** por scan), `ABANDONED_CACHE_TTL=43200` (12h, transient por-slug). Checks: core desactualizado, plugins/temas desactualizados (um finding por item), plugins inactivos (contagem — `info`, não `warning`), **plugins abandonados/removidos do directório wordpress.org** — via `plugins_api('plugin_information', ['slug'=>$slug])` e o campo `$info->closed`. O resultado (`closed`/`open`) fica em cache num transient `emcp_sec_abandoned_` durante 12h; um `WP_Error` (plugin premium não no wp.org, ou API em baixo) **não é marcado nem colocado em cache** — retenta no próximo scan. Slugs já em cache não contam para o orçamento de 30 chamadas ao vivo — só chamadas realmente feitas são limitadas. --- ## 5. Performance Analyzer **Classe de abilities:** `EMCP_Tools_Performance_Abilities` (`includes/abilities/class-performance-abilities.php`) **Condição de registo:** sempre activo. **`analyze-performance` está ACTIVA por omissão** (igual a `scan-security`). **`permission_callback`:** `current_user_can( 'manage_options' )`. | Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive | |---|---|---|---| | `analyze-performance` | `url` (uri, opt — página deste site, hosts externos rejeitados), `post_id` (int, opt — ignorado se `url` definido), `include_page_fetch` (bool, default true — `false` corre só server/DB), `deep_assets` (bool, **reservado, ainda não implementado**) | Sem `url`/`post_id` analisa a frontpage. Devolve `{target, summary{score,grade,counts}, sections, page_fetch, top_recommendations}`. | **readonly, destructive=false, idempotent=true — activa por omissão** | ### Orchestrator: `EMCP_Tools_Performance_Analyzer` Ficheiro: `includes/performance/class-performance-analyzer.php`. `CRITICAL_WEIGHT=15`, `WARNING_WEIGHT=4`, `TOP_RECS=8` (pesos diferentes do Security Scanner — mais leve, **sem** cap por categoria). - **`analyze( $input )`** — `resolve_target()` primeiro (`url`|`post_id`|frontpage); corre **sempre** o server audit; corre o page audit condicionalmente (`include_page_fetch`); combina findings; `summarize()` + `group_by_category()`. - **`resolve_target( $input )`** — valida que `url` está no **mesmo host** que `home_url()` via `validate_same_host()` (pure) — protecção anti-SSRF/anti-scan-de-outro-site logo à entrada. - **`summarize( $findings )`** — pure: `score = 100 - (critical*15) - (warning*4)`, **sem** cap por categoria (diferente do Security Scanner); grade A-F igual. - **`group_by_category`** — 5 secções fixas: `server`, `database`, `config`, `page`, `assets`. ### Value object: `EMCP_Tools_Performance_Finding` Ficheiro: `includes/performance/class-performance-finding.php`. Idêntico em forma ao `Security_Finding` — factory pure `make(id, category, label, status, value, message, recommendation='')`. ### 5.1 Audit — Server (`EMCP_Tools_Performance_Server_Audit`) Ficheiro: `includes/performance/class-performance-server-audit.php`. **Todo in-process, sem HTTP.** `MIN_MEMORY_BYTES=128MB`, `AUTOLOAD_WARN=1MB`/`CRIT=3MB`, `PLUGIN_WARN_COUNT=40`, `REVISIONS_WARN_COUNT=1000`, `TOP_TABLES=5`, `TOP_AUTOLOAD_OPTIONS=5`. 11 checks pure+live: **versão PHP** (≥8.2 pass, ≥8.0 warning, senão critical), **memory_limit** (≥128MB pass, `-1`=ilimitado=pass, senão warning), **OPcache** activo, **object cache persistente** (`wp_using_ext_object_cache()`), **biblioteca de imagem** (Imagick ou GD), **WP_DEBUG** em produção (warning) vs outros ambientes (info), **contagem de plugins activos** (>40 → warning), **revisões de posts** (>1000 → warning, `COUNT(*) WHERE post_type='revision'`), **backlog de cron** overdue >5min (via `_get_cron_array()`), **tamanho de opções autoload** (`SUM(LENGTH(option_value)) WHERE autoload IN ('yes','on','auto')` — nota: cobre as **3 variantes** de valor da coluna `autoload`, não só `'yes'`; devolve top 5 maiores), **tamanho da base de dados** (`SUM(data_length+index_length)` de `information_schema.TABLES`, top 5 tabelas maiores). ### 5.2 Audit — Page (`EMCP_Tools_Performance_Page_Audit`) Ficheiro: `includes/performance/class-performance-page-audit.php`. **1 loopback HTTP fetch + parsing DOM. Não executa JS** — análise de HTML/headers, não Core Web Vitals reais. `FETCH_TIMEOUT=10s`, `MAX_HTML_BYTES=2MB` (cap de parsing), `MAX_REDIRECTS=3`, `RESPONSE_WARN_MS=800`, `HTML_WARN_BYTES=500KB`, `RENDER_BLOCK_WARN=5`. - **`fetch( $url, $timeout=10 )`** — segue redirects **manualmente** (`redirection=0` em cada `wp_remote_get`, loop até `MAX_REDIRECTS`) **em vez de** delegar no `redirection` nativo do `wp_remote_get`. Motivo: cada hop precisa de ser revalidado contra o host de origem (`safe_redirect_target()`) — um redirect para outro host é **recusado**, não seguido. - **`safe_redirect_target( location, current_url, origin_host )`** — resolve `Location` relativo contra o URL actual (scheme+host), depois testa se o host de destino == `origin_host` (case-insensitive); devolve `''` se sair do host. **Guarda anti-SSRF explícita**: um atacante não pode fazer o site "chutar" outro host arbitrário através de um redirect 301/302 configurado maliciosamente numa página que este tool analisa. - **`analyze( $fetched, $deep_assets )`** — pure. Se o fetch falhou → 1 finding `warning` e degrada graciosamente (server/DB continuam reportados normalmente). Se OK: `http_status` (200=pass), `response_time` (>800ms=warning), `html_size` (>500KB=warning), `compression` (gzip/br em `content-encoding`), `cache_headers` (Cache-Control/Expires/X-Cache presentes), depois `parse_dom()` com `DOMDocument` (`libxml_use_internal_errors` para engolir HTML malformado) + `asset_findings()`: **render-blocking** (`` no `` + `