Files
emcp-tools-mapping/docs/07-SYSTEM-OPS.md
T
Claude Code 8ada367bd0 docs: mapeamento completo do EMCP Tools (11 docs, ~6070 linhas)
Especificação funcional do plugin emcp-tools v3.12.1 (msrbuilds/elementor-mcp,
GPL-2.0-or-later) via leitura directa do código-fonte em emanuelalmeida.pt.

- 00: arquitectura (bootstrap, ability registrar, dispatcher, MCP adapter)
- 01: Elementor classico (paginas, layout, widgets, templates, globals)
- 02: Elementor Atomic v4 + Gutenberg
- 03: WordPress core (conteudo, media, settings, temas)
- 04: Themer (CPT, condicoes, render, PHP templates)
- 05: Redirects + change ledger unificado (rollback)
- 06: Sandbox PHP snippets + custom widgets
- 07: Filesystem/DB/WP-CLI/Security/Performance (maior risco)
- 08: Integracoes terceiros (ACF, Meta Box, forms, SEO)
- 09: Stock images + Cloud + OAuth
- 10: Sistema de modulos + inventario Pro-only (30 classes)
- INDEX: sintese, sequencia de construcao, tabela de risco

Produzido por 10 subagentes code-explorer em paralelo + revisao cruzada de
consistencia. Cada doc inclui blueprint de replica (copiar/simplificar/omitir).
2026-08-19 06:41:04 +01:00

45 KiB
Raw Blame History

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 <timestamp>-<path-com-/-substituído-por-\->, 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/<timestamp>-<path-flat> 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>/, $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/<job-id>/ (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, WSO<versão>shell).
  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_<md5(slug)> 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 (<link rel=stylesheet> no <head> + <script> síncrono no <head> sem async/defer, warning se >5), asset_counts (info), image_lazy_loading (contagem de <img> sem loading="lazy", info), third_party (domínios de <link>/<script src> diferentes do host analisado, info).

Storage: nenhum armazenamento próprio para nenhum dos dois audits — tudo calculado ao vivo e devolvido na resposta; nada persistido em disco/BD (excepto os transients de cache do audit de software do Security Scanner, que é um documento diferente — §4.4).


Blueprint para réplica

Copiar quase 1:1 (código já correcto, difícil de reproduzir sem reintroduzir bugs)

  1. EMCP_Tools_Filesystem_Guard::resolve_path() — o chokepoint de confinamento a ABSPATH. A lógica de comparação de prefixo (=== OU strpos($real, $root . DIRECTORY_SEPARATOR) === 0, nunca um strpos simples) evita a classe de bugs "/var/www/site-evil passa como prefixo de /var/www/site". O tratamento de caminhos que ainda não existem (resolver o pai com realpath para permitir criar um ficheiro novo) é subtil e vale a pena copiar tal-e-qual.
  2. A distinção is_protected (write) vs is_read_protected (read) — não são a mesma lista. .htaccess pode ser lido mas não escrito/apagado; wp-config.php não pode nem sequer ser lido. Uma réplica que use uma única lista "protected" para tudo está a proteger a menos (deixa ler segredos) ou a mais (bloqueia leitura de .htaccess, que é inofensiva e útil de auditar).
  3. Padrão "backup antes de escrever, directório de backups bloqueado a acesso web" — .htaccess Require all denied + index.html vazio no directório de backups, gerado on-demand na primeira escrita. Simples e eficaz; copiar tal-e-qual.
  4. EMCP_Tools_Database_Guard::normalize_sql() + is_read_only_sql() — o scanner char-a-char que neutraliza comentários/strings/identificadores antes de testar keywords é sofisticado e já tem dois bugs de "quase-passou" documentados directamente nos comentários do código (o truque /*! do MySQL, e o cuidado de não pôr \b a fechar no regex de LOAD_FILE(). Reimplementar do zero arrisca reintroduzir exactamente estes dois bugs já corrigidos aqui.
  5. valid_table() a resolver contra SHOW TABLES ao vivo em vez de confiar no input — nomes de tabela não podem ser parametrizados em SQL preparado, por isso a única forma segura de os validar é confirmá-los contra uma lista real.
  6. EMCP_Tools_WPCLI_Validator — a combinação de blocklist de comandos/subcomandos/flags + tokenizador consciente de aspas (com as regras específicas de escaping por tipo de aspa, e o comportamento "backslash literal fora de aspas" para paths Windows) é bem pensada e testada. Copiar a estrutura (3 listas filtráveis + tokenizer) quase tal-e-qual.
  7. O princípio "argv array, nunca string interpolada" em WPCLI_Runner::run_shell() e WPCLI_Jobs::spawn() — mesmo quando o spawn() monta uma string via implode(' ', array_map('escapeshellarg', $argv)) para o ficheiro launcher, cada TOKEN individual passa primeiro por escapeshellarg(); nunca há concatenação directa de input do utilizador numa string de shell. Esta é a regra arquitectural mais importante de todo este documento — qualquer tool nova que precise de invocar um processo externo deve seguir o mesmo padrão.
  8. A técnica de fragmentação de assinaturas do malware scanner (signature_tokens() + expand()) — o achado mais interessante de todo o ficheiro: se uma réplica alguma vez escrever o seu próprio scanner de malware, tem de aplicar a mesma técnica ou arrisca ser confundida com o próprio malware que procura, por scanners de host (Imunify360, maldet, Wordfence). Ver citação completa em §4.1.
  9. Performance_Page_Audit::fetch() / safe_redirect_target() — seguir redirects manualmente em vez de delegar no parâmetro redirection do wp_remote_get, revalidando o host em cada hop. Este padrão é reutilizável para qualquer futura tool "analisar um URL deste site" (ex. um crawler de sitemap) — nunca confiar que o WordPress core segue redirects de forma segura para este caso de uso.

Simplificar

  1. O caminho "shell" completo de WP-CLI (binário wp configurável via option/constante, proc_open HTTP, jobs em background detached) é infraestrutura pesada que só é necessária quando o servidor MCP é alcançado por HTTP puro (sem contexto WP-CLI). Neste ecossistema, os 3 servidores MCP já ligam via SSH+STDIO a wp mcp-adapter serve (ver doc 00 e skill://emcp-tools) — ou seja, is_cli_context() é sempre verdadeiro e o caminho run_in_process() cobre 100% da utilização real. Uma réplica focada neste padrão de deployment pode eliminar inteiramente o caminho shell + jobs detached (WPCLI_Runner::run_shell, toda a classe WPCLI_Jobs) e ficar só com WP_CLI::runcommand(..., launch=false) — muito menos superfície de ataque e código para manter, sem perder funcionalidade no cenário real de uso.
  2. Security_Finding/Performance_Finding são factories triviais de um único método — copiar tal-e-qual sem redesenho, não há aqui nada a simplificar mais.
  3. O cap CATEGORY_CRIT_CAP=60 por categoria no Security Scanner é razoável mas arbitrário — uma réplica pode adoptar o mesmo valor sem re-derivar a heurística, ou simplesmente usar o scoring mais simples do Performance Analyzer (sem cap) se a diferenciação por categoria não for um requisito.

Deixar de fora (ou adiar)

  1. deep_assets no Performance Analyzer — está no schema como "reserved", nunca chegou a ser implementado neste build. Não vale a pena reservar espaço para uma feature que o próprio vendor não implementou em 12 versões (since 3.0.0).

Gotchas não óbvios a não esquecer

  • Invalidação de OPcache após escrever/apagar .php (opcache_invalidate($abs, true)) — é fácil esquecer este passo numa réplica e ter bugs "a alteração não teve efeito" reportados como falsos negativos de teste.
  • O deny-list incremental (doc 00 §8) é a camada EXTERNA de defesa; os guards deste documento são a camada INTERNA. Mesmo que uma réplica não implemente um deny-list configurável, TODAS as gates internas aqui documentadas (confinamento de path, validação de SQL, blocklist de WP-CLI, confirm:true obrigatório em deletes) devem ser mantidas — são a última linha de defesa se o deny-list for mal configurado (ver o achado crítico de descomplicar.pt no skill://emcp-tools §8.2, onde o deny-list externo falhou completamente e só as gates internas — que continuam a existir mas não bastam sozinhas quando a tool está "activa" — teriam impedido o pior).
  • get-wp-cli-job/list-wp-cli-jobs são readonly e mesmo assim ficam desligados por omissão em bloco com run/dispatch — é uma escolha deliberada de UX/segurança (não faz sentido dar acesso a resultados de jobs sem dar acesso a criá-los), mas quebra o padrão "read sempre activo, write desligado" que domina o resto do plugin. Documentar esta excepção explicitamente numa réplica para não ser "corrigida" por engano.
  • A construção lazy dos audits de segurança não é só uma optimização de performance — é uma medida de resiliência anti-fatal-error (issue #100). Qualquer réplica que registe abilities em cada wp_abilities_api_init (que corre em TODO pedido admin/REST) deve tratar a construção de qualquer dependência pesada/frágil da mesma forma: lazy, e sempre com um try/catch a envolver o registo global (ver EMCP_Tools_Ability_Registrar::register_all() no doc 00, que já faz exactamente isto ao nível do registrador inteiro).

Fonte

Leitura directa (19-08-2026) de: includes/abilities/class-filesystem-abilities.php, includes/class-filesystem-guard.php, includes/abilities/class-database-abilities.php, includes/class-database-guard.php, includes/abilities/class-wpcli-abilities.php, includes/wpcli/class-wpcli-runner.php, includes/wpcli/class-wpcli-validator.php, includes/wpcli/class-wpcli-jobs.php, includes/abilities/class-security-abilities.php, includes/security/class-security-scanner.php, includes/security/class-security-malware-audit.php, includes/security/class-security-hardening-audit.php, includes/security/class-security-software-audit.php, includes/security/class-security-integrity-audit.php, includes/security/class-security-finding.php, includes/abilities/class-performance-abilities.php, includes/performance/class-performance-analyzer.php, includes/performance/class-performance-server-audit.php, includes/performance/class-performance-page-audit.php, includes/performance/class-performance-finding.php.

Cruzado com includes/abilities/class-ability-registrar.php (condições de registo — todos os 5 grupos deste documento registam-se sem guarda condicional, fora do bloco if ($elementor_active)) e com docs/00-ARQUITECTURA.md + skill://emcp-tools (contexto de arquitectura geral e postura de deny-list ao vivo nos 3 sites do ecossistema).