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).
45 KiB
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.$rootpor omissãoABSPATH(parâmetro só existe para testes). Rejeita path vazio ou com byte NUL. Detecta se é absoluto (começa por/,\, ouC:\-like via regex). Constrói o candidato (absoluto tal-e-qual, ourtrim($root) . '/' . ltrim($path)). Fazrealpath(); se o próprio alvo não existir ainda (caso de escrita nova), resolve o directório pai comrealpath()e reconstróiparent/basename. Compara o prefixo do caminho resolvido contrarealpath($root): tem de ser exactamente igual ou começar porroot . DIRECTORY_SEPARATOR— nunca um simplesstrpos, para evitar que/var/www/site-evilpasse por prefixo de/var/www/site. DevolveWP_Error('outside_root')se escapar.is_protected( string $abs )— lista de basenames escrita/eliminação-protegidos:wp-config.php,.htaccess(case-insensitive), filtrável viaemcp_tools_fs_protected_paths.is_read_protected( string $abs )— lista de basenames leitura-protegidos: sówp-config.php—.htaccessfica 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 viaemcp_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 deA-Za-z0-9._-viram-.is_utf8( content )— pure: byte NUL ou falha empreg_match('//u', $content)→ binário.check_writes( can_edit_files, disallow_file_edit )— pure: combina a capabilityedit_filescom a constanteDISALLOW_FILE_EDIT.writes_allowed()— wrapper live:current_user_can('edit_files') && !(defined(DISALLOW_FILE_EDIT) && DISALLOW_FILE_EDIT).to_relative( abs )— inverso deresolve_pathpara display/log (relativo aABSPATH, slashes normalizados).backup( abs )— copia o ficheiro-alvo parawp-content/uploads/emcp-fs-backups/<timestamp>-<path-flat>antes de qualquer write/edit/delete. Cria.htaccess(Require all denied) +index.htmlvazio 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 comrollbackdo tipofile-backup/file-createe 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 chamarnormalize_sql.is_read_only_sql( string $sql )— a gate real, em cadeia:- Se
$sqlcontém/*!→ rejeita de imediato. Comentário: "MySQL executes the body of /! ... / executable comments, so we cannot safely strip-and-trust." normalize_sql()+trim().- Multi-statement: qualquer
;que não seja o único carácter final (depois dertrim) → rejeita. - 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\ba fechar — porqueload_file(termina em(, e(seguido de outro não-word-char não tem word boundary; um\bfinal deixaria passarLOAD_FILEpor engano. - Primeira palavra tem de ser uma de
SELECT/SHOW/DESCRIBE/DESC/EXPLAIN/WITH. - 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 pornormalize_sql, isto só apanha keywords reais (não texto dentro de uma string).
- Se
valid_table( string $table )— nomes de tabela não podem ser parametrizados em SQL (?/%ssó serve para valores), por isso resolve contraSHOW TABLESao vivo e devolve o nome real exacto (preserva case) ouWP_Error('unknown_table').table_is_protected/is_protected( $table )— tabelas protegidas por omissão:$wpdb->users,$wpdb->usermeta, filtrável viaemcp_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_backupnão corresponde awp_users). Aplicado ao toolquerypara bloquear leitura directa de password hashes/tokens de sessão viaSELECT * 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 viaEMCP_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(parescomando 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 abilitydatabase(§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 tipoC:\wp\wp-cli.phar). Dentro de aspas duplas, só\"e\\são escapes reconhecidos; dentro de aspas simples, nada é escapado. DevolveWP_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 awp mcp-adapter serve, ver doc 00 eskill://emcp-tools).base_command()— o bináriowpconfigurado, ordem de prioridade: constanteEMCP_TOOLS_WPCLI_COMMAND> optionemcp_tools_wpcli_command> filtroemcp_tools_wpcli_command.shell_available()—proc_openexiste,base_command()não vazio, eproc_opennão está emdisable_functionsdo php.ini.run( $command, $timeout=60 )— valida viaValidator::validate(); seis_cli_context()→run_in_process()usaWP_CLI::runcommand($cmd, ['return'=>'all','exit_error'=>false,'launch'=>false,'parse'=>false])— corre dentro do mesmo processo PHP, semfork/exec. Senão, seshell_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 comproc_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.phpsilencioso.- 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};statustransitaqueued → running → (completed|failed).stdout.log/stderr.log— streams capturados.run.sh(POSIX) ourun.bat(Windows) — launcher gerado, com o comando completo já montado viaescapeshellarg()por token (implode(' ', array_map('escapeshellarg', $argv))) — a escaping fica de fora doproc_open/popende 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 )— requershell_available()(impossível fazer detach de algo já in-process); valida;prune(); gravameta.jsoninicial;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 — retornajob_idde imediato comstatus='running'.get( $id )— sanitiza$id(regex[^a-z0-9-]removido); lêmeta.json; deriva o estado terminal do ficheiroexit_codese existir (0 = completed,!=0 = failed); devolvetail()do stdout/stderr (capado aOUTPUT_CAP, prefixo"…[output truncated]"— mostra o fim do log, não o início).all()— todos os jobs (glob(GLOB_ONLYDIR)), ordenados porcreateddesc, sem stdout/stderr completos.spawn()— vale a pena ler: gera orun.sh/run.batcompleto 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 porstatus(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];gradeA(≥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; ovaluede cada finding é{location: "path:line", snippet}— nunca o conteúdo completo do ficheiro.is_misplaced_php( relpath )— PHP executável dentro deuploads/(extensõesphp/phtml/php3-7/phps/pht).is_trivial_php( code )— pure, usatoken_get_all()para distinguir umindex.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 (viaEMCP_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 awp-content/. Percorre comRecursiveIteratorIterator+FOLLOW_SYMLINKS, mas valida que o caminho resolvido continua dentro deABSPATH(bloqueia escape de symlink). Ficheiro PHP executável sobuploads/com código real (não trivial) gera um achado crítico dedicadomalware_uploads_phpantes 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_oncethen 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:
malware_eval_obfuscation(critical) —eval/assert/create_functionenvolvendo um decoder (base64_decode/gzinflate/gzuncompress/str_rot13/strrev/convert_uudecode).malware_request_eval(critical) —eval/assert/system/exec/passthru/shell_exec/popen/proc_openrecebendo directamente$_GET/POST/REQUEST/COOKIE/SERVER— o backdoor RCE clássico.malware_command_exec(warning; critical sein_uploads) — qualquer chamada deshell_exec/passthru/proc_open/popen/system/execisolada.malware_webshell_marker(critical) — strings de webshells conhecidos (FilesMan,c99shell,r57shell,b374k,phpspy,WSO<versão>shell).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 viahash_equals()(timing-safe); ficheiro em falta →warning('integrity_missing'); hash não bate →critical('integrity_modified').run()— usa a função coreget_core_checksums($wp_version, $locale)(wp-admin/includes/update.php); exclui tudo emwp-content/(não faz parte dos checksums oficiais de core). Se a API estiver inacessível (offline) devolve um único findinginfocomapi.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 queurlestá no mesmo host quehome_url()viavalidate_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=0em cadawp_remote_get, loop atéMAX_REDIRECTS) em vez de delegar noredirectionnativo dowp_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 )— resolveLocationrelativo 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 findingwarninge degrada graciosamente (server/DB continuam reportados normalmente). Se OK:http_status(200=pass),response_time(>800ms=warning),html_size(>500KB=warning),compression(gzip/br emcontent-encoding),cache_headers(Cache-Control/Expires/X-Cache presentes), depoisparse_dom()comDOMDocument(libxml_use_internal_errorspara engolir HTML malformado) +asset_findings(): render-blocking (<link rel=stylesheet>no<head>+<script>síncrono no<head>semasync/defer, warning se >5), asset_counts (info), image_lazy_loading (contagem de<img>semloading="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)
EMCP_Tools_Filesystem_Guard::resolve_path()— o chokepoint de confinamento aABSPATH. A lógica de comparação de prefixo (===OUstrpos($real, $root . DIRECTORY_SEPARATOR) === 0, nunca umstrpossimples) evita a classe de bugs "/var/www/site-evilpassa como prefixo de/var/www/site". O tratamento de caminhos que ainda não existem (resolver o pai comrealpathpara permitir criar um ficheiro novo) é subtil e vale a pena copiar tal-e-qual.- A distinção
is_protected(write) vsis_read_protected(read) — não são a mesma lista..htaccesspode ser lido mas não escrito/apagado;wp-config.phpnã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). - Padrão "backup antes de escrever, directório de backups bloqueado a acesso web" —
.htaccess Require all denied+index.htmlvazio no directório de backups, gerado on-demand na primeira escrita. Simples e eficaz; copiar tal-e-qual. 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\ba fechar no regex deLOAD_FILE(). Reimplementar do zero arrisca reintroduzir exactamente estes dois bugs já corrigidos aqui.valid_table()a resolver contraSHOW TABLESao 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.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.- O princípio "argv array, nunca string interpolada" em
WPCLI_Runner::run_shell()eWPCLI_Jobs::spawn()— mesmo quando ospawn()monta uma string viaimplode(' ', array_map('escapeshellarg', $argv))para o ficheiro launcher, cada TOKEN individual passa primeiro porescapeshellarg(); 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. - 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. Performance_Page_Audit::fetch()/safe_redirect_target()— seguir redirects manualmente em vez de delegar no parâmetroredirectiondowp_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
- O caminho "shell" completo de WP-CLI (binário
wpconfigurável via option/constante,proc_openHTTP, 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 awp mcp-adapter serve(ver doc 00 eskill://emcp-tools) — ou seja,is_cli_context()é sempre verdadeiro e o caminhorun_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 classeWPCLI_Jobs) e ficar só comWP_CLI::runcommand(..., launch=false)— muito menos superfície de ataque e código para manter, sem perder funcionalidade no cenário real de uso. Security_Finding/Performance_Findingsão factories triviais de um único método — copiar tal-e-qual sem redesenho, não há aqui nada a simplificar mais.- O cap
CATEGORY_CRIT_CAP=60por 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)
deep_assetsno 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:trueobrigató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 dedescomplicar.ptnoskill://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-jobssão readonly e mesmo assim ficam desligados por omissão em bloco comrun/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 umtry/catcha envolver o registo global (verEMCP_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).