Uma auditoria de segurança de site cabe em uma hora se você fizer seis verificações: se o servidor entrega arquivos internos (.git, .env, dumps do banco de dados, cópias do wp-config.php), se o núcleo e os plugins estão atualizados, se os arquivos do núcleo batem com os checksums oficiais, se há cabeçalhos de segurança e TLS moderno, quais portas estão acessíveis de fora e o que um atacante consegue descobrir sobre os administradores do seu WordPress. Abaixo estão os comandos de cada verificação, a saída real de um site de teste em que deixamos todos esses erros de propósito, um script pronto e uma tabela com o que fazer em cada caso.
Como testamos
Em 10 de novembro de 2026, em uma bancada de testes: contêiner Ubuntu 24.04.5, nginx 1.24.0 na frente do Apache 2.4.58, PHP-FPM 8.3.6, MariaDB 10.11.14, WordPress 6.4.3 da árvore de build oficial e Akismet 5.3.1. Criamos nós mesmos cada problema descrito abaixo e rodamos as verificações a partir de um namespace de rede separado, que faz o papel de uma máquina externa: curl, openssl, nmap 7.94, testssl.sh 3.2, WP-CLI. A bancada não acessa o wordpress.org, então salvamos as respostas da API (checksums, versões) no mesmo dia e as conferimos com a API ao vivo por SHA-256. Depois corrigimos cada item e repetimos a auditoria. Documentação da OWASP, do WordPress e do MDN verificada em 10.11.2026. Este é o comportamento de um servidor de teste, não dados dos servidores da RichHost.
O que você precisa e quanto tempo leva
Para um site em hospedagem compartilhada, você precisa de um terminal com curl e openssl no seu computador e de acesso SSH à conta para rodar o WP-CLI. Na sua própria VPS, acrescente nmap e testssl.sh (ambos estão nos repositórios do Ubuntu). Teste apenas sites e servidores que são seus: escanear sistemas de terceiros sem permissão viola os termos da maioria dos provedores e, em muitos países, a lei.
O tempo de máquina é pequeno. Na bancada, o script abaixo concluiu todas as verificações externas e do lado do servidor em 2,6 segundos, o nmap em 12 portas levou 16,7 segundos e o testssl.sh com checagem de protocolos e cabeçalhos, 8,2 segundos. A hora vai para a parte humana: ler a saída, decidir o que é um problema real, corrigir e verificar de novo. Um plano aproximado:
- 0-15 minutos: arquivos expostos e listagem de diretórios;
- 15-25 minutos: versões do núcleo e dos plugins, integridade dos arquivos do núcleo;
- 25-35 minutos: cabeçalhos de segurança e TLS;
- 35-45 minutos: login do admin, xmlrpc.php, enumeração de usuários, portas (VPS);
- 45-60 minutos: correções e segunda rodada.
1. Arquivos expostos: .git, .env, dumps do banco de dados, cópias do wp-config.php
Este é o grupo mais perigoso, porque cada achado entrega senhas na hora. O OWASP Top 10:2025 coloca esses erros em A02 Security Misconfiguration, e o guia de testes da OWASP trata deles no WSTG-CONF-04: cópias antigas, arquivos compactados e arquivos de editor que o servidor devolve como texto puro. Veja o que nosso site de teste mostrava a qualquer visitante:
$ curl -s https://cw30.test/.git/config | grep url url = https://deploy:EXAMPLE-NOT-A-REAL-TOKEN@git.example.com/shop/site.git $ curl -s https://cw30.test/.env | head -4 APP_ENV=production DB_NAME=cw30wp DB_USER=cw30wp DB_PASSWORD=Bench-Db-Pass-30 $ curl -s https://cw30.test/wp-config.php~ | grep DB_ define( 'DB_NAME', 'cw30wp' ); define( 'DB_USER', 'cw30wp' ); define( 'DB_PASSWORD', 'Bench-Db-Pass-30' ); ... $ curl -s https://cw30.test/backup.sql | grep -o "'shopadmin','[^']*'" 'shopadmin','$P$BT2r3qe6F1lZggW8biwjFwAefa.6Bz0' $ curl -s https://cw30.test/wp-content/uploads/2026/11/ | grep -o 'href="[^"]*"' href="/wp-content/uploads/2026/" href="customers-export.csv" href="invoice-0001.pdf"
Repare no wp-config.php~: o wp-config.php original é executado como PHP e não mostra nada, mas o editor salvou uma cópia com til como texto puro, e o servidor a devolveu com a senha do banco de dados. Uma pasta .git pública permite que qualquer pessoa reconstrua o código e pegue o token da URL do repositório. O dump do banco contém os hashes das senhas dos administradores.
A principal lição da bancada: não dá para adivinhar de fora um arquivo compactado com nome aleatório. O script testou nomes comuns (backup.zip, site.zip) e não achou nada, enquanto site-backup-2026-11-01.zip só apareceu no find do lado do servidor:
== 1. Exposed files and directory listing: https://cw30.test [FAIL] /.git/HEAD is public (HTTP 200, content matches) [FAIL] /.env is public (HTTP 200, content matches) [FAIL] /wp-config.php~ is public (HTTP 200, content matches) [FAIL] /backup.sql is public (HTTP 200, content matches) [ OK ] /backup.zip not found [ OK ] /site.zip not found ... == 5. Server side: /srv/cw30/www [FAIL] risky files in webroot: /srv/cw30/www/.env /srv/cw30/www/wp-config.php~ /srv/cw30/www/phpinfo.php /srv/cw30/www/.git /srv/cw30/www/site-backup-2026-11-01.zip /srv/cw30/www/backup.sql
Listagem de diretórios
Se uma pasta sem index.php mostra uma página “Index of”, qualquer pessoa vê todos os arquivos dela. Na bancada, /wp-content/uploads/2026/11/ guardava uma fatura e uma exportação de clientes. A verificação é simples: abra /wp-content/uploads/ e /wp-includes/ no navegador. A correção é uma linha, Options -Indexes.
2. Núcleo e plugins desatualizados
De fora, a versão do WordPress aparece na meta tag generator e nos parâmetros ?ver= das folhas de estilo carregadas. As versões dos plugins às vezes aparecem no readme.txt (linha Stable tag), mas nem sempre: o Akismet bloqueia seus arquivos de texto com um .htaccess próprio, e a bancada respondeu 403. O WP-CLI no servidor é mais confiável:
$ curl -s https://cw30.test/ | grep -o '<meta name="generator"[^>]*>' <meta name="generator" content="WordPress 6.4.3" /> $ wp core check-update version update_type package_url 6.4.14 minor https://downloads.w.org/release/wordpress-6.4.14.zip 7.1.4 major https://downloads.wordpress.org/release/wordpress-7.1.4.zip $ wp plugin list --update=available name status update version update_version auto_update akismet inactive available 5.3.1 5.7.2 off
Em 10.11.2026, a API oficial do WordPress listava 7.1.4 como versão atual e 6.4.14 como a última correção do ramo 6.4. Um plugin inativo continua sendo um risco: os arquivos dele ficam no servidor e podem ser acessados diretamente. Se você não precisa de um plugin, exclua-o (wp plugin delete) em vez de apenas desativá-lo. Confira também a versão do PHP na sua hospedagem: um ramo antigo do PHP também deixa de receber correções de segurança.
3. Integridade dos arquivos do núcleo: wp core verify-checksums
O comando baixa do wordpress.org os checksums MD5 da sua versão e os compara com os arquivos no disco. Na bancada, acrescentamos uma linha ao wp-login.php e um arquivo a mais em wp-includes:
$ wp core verify-checksums Warning: File doesn't verify against checksum: wp-login.php Warning: File should not exist: wp-includes/class-wp-cache-helper.php Error: WordPress installation doesn't verify against checksums. $ wp core update /srv/cw30/src/wordpress-7.1.4.zip Success: WordPress updated successfully. $ wp core verify-checksums Warning: File should not exist: wp-includes/class-wp-cache-helper.php Success: WordPress installation verifies against checksums.
A segunda metade da saída importa mais que a primeira. A atualização do núcleo trocou o wp-login.php modificado por uma cópia limpa, mas o arquivo a mais ficou, e mesmo assim o comando exibiu Success. O aviso File should not exist não faz a verificação falhar, então leia a saída inteira, não só a última linha. Examinamos o arquivo desconhecido e o movemos para uma pasta de quarentena fora do site; depois disso a verificação passou sem avisos. Para plugins existe um comando equivalente, wp plugin verify-checksums --all, mas ele só cobre plugins do diretório do wordpress.org.
4. Cabeçalhos de segurança e TLS
$ curl -sI https://cw30.test/ HTTP/1.1 200 OK Server: nginx/1.24.0 (Ubuntu) Content-Type: text/html; charset=UTF-8 Link: <https://cw30.test/wp-json/>; rel="https://api.w.org/" $ testssl --quiet --color 0 -p -h https://cw30.test TLS 1 offered (deprecated) TLS 1.1 offered (deprecated) TLS 1.2 offered (OK) TLS 1.3 offered (OK): final Strict Transport Security not offered Server banner nginx/1.24.0 (Ubuntu)
O mínimo para um site WordPress: Strict-Transport-Security (o navegador deixa de usar HTTP sem criptografia para o site), X-Content-Type-Options: nosniff, proteção contra incorporação em frames via X-Frame-Options ou a diretiva CSP frame-ancestors, e Referrer-Policy. Uma Content-Security-Policy completa para WordPress com plugins precisa ser ajustada para cada site, então comece pela parte segura (frame-ancestors, object-src, base-uri) e implante o resto via Content-Security-Policy-Report-Only. Segundo o MDN, os navegadores ignoram HSTS recebido por HTTP, e a lista de preload exige pelo menos um ano e includeSubDomains; por isso, só adicione includeSubDomains quando todos os subdomínios funcionarem em HTTPS.
Depois das correções (o bloco do .htaccess está mais abaixo), a bancada respondeu:
$ curl -sI https://cw30.test/ | grep -iE 'server|strict|x-content|x-frame|referrer|content-security' Server: nginx Strict-Transport-Security: max-age=31536000 X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN Referrer-Policy: strict-origin-when-cross-origin Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self' $ openssl s_client -connect cw30.test:443 -servername cw30.test -tls1 -cipher 'DEFAULT:@SECLEVEL=0' </dev/null 2>/dev/null | grep 'Cipher is' New, (NONE), Cipher is (NONE)
Na hospedagem compartilhada, quem controla os protocolos TLS é o provedor: se o testssl.sh mostrar TLS 1.0 ou 1.1, fale com o suporte. Na sua própria VPS é uma linha no nginx, ssl_protocols TLSv1.2 TLSv1.3;.
5. Portas abertas: nmap de fora (VPS)
Escaneie a partir de outra máquina, não do próprio servidor: ss -tlnp no servidor mostra em que os processos estão escutando, não o que o firewall deixa passar. Na bancada, o banco de dados e o backend Apache estavam escutando por engano no endereço externo:
$ nmap -Pn -sT -sV -p 21,22,25,80,443,3306,3330,5432,6379,8080,8303,8443 10.30.30.1 80/tcp open http nginx 1.24.0 (Ubuntu) 443/tcp open ssl/http nginx 1.24.0 (Ubuntu) 3330/tcp open mysql MySQL 5.5.5-10.11.14-MariaDB-0ubuntu0.24.04.1 8303/tcp open http Apache httpd 2.4.58 ((Ubuntu)) (other ports closed) Nmap done: 1 IP address (1 host up) scanned in 16.64 seconds # after bind-address=127.0.0.1, Listen 127.0.0.1:8303, server_tokens off 80/tcp open http nginx 443/tcp open ssl/http nginx 3330/tcp closed mcs-calypsoicf 8303/tcp closed unknown
Uma porta de banco de dados aberta mais a senha do .env significa acesso direto aos dados, uma das vulnerabilidades em servidores mais comuns. A regra é simples: só 80, 443 e SSH devem ficar expostas à internet, e o SSH deve aceitar apenas chaves SSH. Para verificar todas as portas, rode nmap -Pn -p- your_IP; é mais lento, mas não deixa nada passar.
6. Admin do WordPress: usuários, xmlrpc.php, limite de tentativas de login
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://cw30.test/?author=1'
301 https://cw30.test/author/shopadmin/
$ curl -s https://cw30.test/wp-json/wp/v2/users | grep -o '"slug":"[^"]*"'
"slug":"editor1"
"slug":"shopadmin"
$ curl -s -H 'Content-Type: text/xml' --data '<methodCall><methodName>system.listMethods</methodName></methodCall>' https://cw30.test/xmlrpc.php | grep -c '<string>'
80
$ for i in $(seq 20); do curl -s -o /dev/null -w '%{http_code}\n' -d 'log=shopadmin&pwd=wrong$i' https://cw30.test/wp-login.php; done | sort | uniq -c
20 200
# after the fixes: ?author=1 403, /wp-json/wp/v2/users 404, /xmlrpc.php 403
# the same 20 wrong logins:
4 200
16 429
O login do administrador ficou visível em dois segundos, o xmlrpc.php aceitava 80 métodos (incluindo system.multicall, que permite a um atacante testar muitas senhas em uma única requisição), e o servidor aceitou 20 senhas erradas seguidas sem nenhum atraso. A OWASP classifica isso como enumeração de contas (WSTG 4.3.4) e bloqueio fraco (WSTG 4.4.3).
Uma surpresa da bancada: depois de bloquearmos ?author=1 e o endpoint REST, os logins continuavam visíveis pelos links de autor na página inicial, pelo sitemap de usuários e pelo oEmbed:
$ curl -s https://cw30.test/ | grep -o "/author/[a-z0-9]*/" | sort -u
/author/editor1/
/author/shopadmin/
$ curl -s -o /dev/null -w "%{http_code}\n" https://cw30.test/wp-sitemap-users-1.xml
200
$ wp user update shopadmin --user_nicename=shop-team
Success: Updated user 1.
Portanto, a correção de verdade é fazer o slug público do autor ser diferente do login (--user_nicename), mais um pequeno mu-plugin que esconde a lista de usuários de requisições anônimas e remove o sitemap de autores. Em uma VPS, o nginx limita as tentativas de login (limit_req: após a correção, 16 de 20 tentativas receberam o status 429), ou o Fail2Ban faz isso a partir do log. Na hospedagem compartilhada, instale um plugin que limite as tentativas de login e ative a autenticação em dois fatores para os administradores.
As correções que aplicamos
Na hospedagem compartilhada, tudo vai no arquivo .htaccess na raiz do site. Coloque o bloco acima da seção do WordPress e, depois de salvar, confira a página inicial, um post, a página de login e os downloads de arquivos: na bancada, todos retornaram 200, e o filtro por autor na lista de posts do painel continuou funcionando.
# BEGIN audit hardening
Options -Indexes
# hidden files and folders (.git, .env), except .well-known
RedirectMatch 404 /\.(?!well-known/)
# dumps, archives, editor backups
<FilesMatch "(\.(sql|gz|zip|tar|7z|rar|bak|old|orig|save|swp|log)|~)$">
Require all denied
</FilesMatch>
<FilesMatch "^(phpinfo|info|test)\.php$">
Require all denied
</FilesMatch>
<Files xmlrpc.php>
Require all denied
</Files>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/wp-admin/
RewriteCond %{QUERY_STRING} (^|&)author=[0-9]+ [NC]
RewriteRule ^ - [F]
</IfModule>
<IfModule mod_headers.c>
Header always set Strict-Transport-Security "max-age=31536000"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "frame-ancestors 'self'; object-src 'none'; base-uri 'self'"
</IfModule>
# END audit hardening
O bloco nega o acesso, mas não remove os arquivos. Tire dumps, arquivos compactados e a pasta .git da raiz web e mantenha permissões de arquivos rígidas no wp-config.php (600 ou 640). O mu-plugin para os usuários (arquivo wp-content/mu-plugins/hide-rest-users.php):
<?php
/* Plugin Name: Hide REST user list from anonymous visitors */
add_filter( 'rest_endpoints', function ( $endpoints ) {
if ( ! is_user_logged_in() ) {
unset( $endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
}
return $endpoints;
} );
// no users sitemap (wp-sitemap-users-1.xml)
add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {
return 'users' === $name ? false : $provider;
}, 10, 2 );
Na sua própria VPS, acrescente as configurações do nginx e do banco de dados:
# nginx, http {} level
limit_req_zone $binary_remote_addr zone=wplogin:1m rate=6r/m;
server_tokens off;
# server {} with listen 443 ssl
ssl_protocols TLSv1.2 TLSv1.3;
location = /wp-login.php {
limit_req zone=wplogin burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8303; # your backend
}
# MariaDB/MySQL (my.cnf): database only on loopback
bind-address = 127.0.0.1
O script de auditoria
O script executa todas as verificações externas deste artigo e, com --wp-path, também as do lado do servidor (arquivos de risco na raiz web, arquivos PHP em uploads, versões e checksums via WP-CLI). Ele não altera nada no site; --login-test 10 envia 10 logins com falha para um usuário que não existe. Na bancada, ele relatou 14 FAIL e 8 WARN antes das correções e 0 FAIL e 1 WARN depois (a versão na meta tag generator).
Rode como bash site-audit.sh https://your-site --login-test 10; no servidor, acrescente --wp-path ~/public_html (o caminho depende do painel de controle).
#!/usr/bin/env bash
# site-audit.sh: quick read-only security audit of YOUR OWN website.
# Usage: ./site-audit.sh https://example.com [--wp-path /path/to/webroot] [--login-test 10]
# Needs: bash, curl, openssl. Optional: wp-cli (for --wp-path checks).
# Run it only against sites you own or are allowed to test.
set -u
URL="${1:?usage: $0 https://example.com [--wp-path DIR] [--login-test N]}"; shift
URL="${URL%/}"; HOST="${URL#*://}"; HOST="${HOST%%/*}"; HOST="${HOST%%:*}"
WPPATH=""; LOGINTEST=0; WP="${WP:-wp}"
while [ $# -gt 0 ]; do
case "$1" in
--wp-path) WPPATH="$2"; shift 2;;
--login-test) LOGINTEST="$2"; shift 2;;
*) echo "unknown option $1"; exit 2;;
esac
done
FAIL=0; WARN=0; T0=$(date +%s)
ok() { printf '[ OK ] %s\n' "$*"; }
warn() { printf '[WARN] %s\n' "$*"; WARN=$((WARN+1)); }
fail() { printf '[FAIL] %s\n' "$*"; FAIL=$((FAIL+1)); }
C="curl -sS -k --max-time 15 -A site-audit/1.0"
echo "== 1. Exposed files and directory listing: $URL"
TMP=$(mktemp); trap 'rm -f "$TMP"' EXIT
check_path() { # path regex
local code
code=$($C -o "$TMP" -w '%{http_code}' "$URL$1" 2>/dev/null)
if [ "$code" = "200" ] && head -c 300000 "$TMP" | grep -aqE "$2"; then fail "$1 is public (HTTP 200, content matches)"; return; fi
[ "$code" = "200" ] && warn "$1 answers 200 but content does not match, check manually" || ok "$1 -> $code"
}
check_path /.git/HEAD '^ref: '
check_path /.git/config '\[core\]'
check_path /.env '^[A-Z_]+='
for f in wp-config.php~ wp-config.php.bak wp-config.php.old wp-config.php.save wp-config.php.swp .wp-config.php.swp wp-config.txt; do
check_path "/$f" 'DB_PASSWORD|DB_NAME'
done
for f in backup.sql dump.sql db.sql database.sql wp-content/backup.sql; do
check_path "/$f" 'CREATE TABLE|INSERT INTO|MySQL dump|MariaDB dump'
done
for f in backup.zip site.zip www.zip public_html.zip backup.tar.gz; do
sig=$($C -r 0-3 "$URL/$f" 2>/dev/null | od -An -tx1 | tr -d ' \n')
case "$sig" in 504b0304*|1f8b*) fail "/$f is a downloadable archive";; *) ok "/$f not found";; esac
done
for f in phpinfo.php info.php test.php php_info.php; do check_path "/$f" 'phpinfo\(\)|PHP Version'; done
check_path /wp-content/debug.log 'PHP (Notice|Warning|Fatal|Deprecated)'
for d in /wp-content/uploads/ /wp-content/plugins/ /wp-includes/; do
$C -o "$TMP" "$URL$d" 2>/dev/null
if grep -qi '<title>Index of' "$TMP"; then fail "directory listing is on: $d"; else ok "no listing: $d"; fi
done
echo "== 2. HTTP security headers"
H=$($C -o /dev/null -D - "$URL/" | tr -d '\r')
hdr() { printf '%s\n' "$H" | grep -i "^$1:" | head -1; }
[ -n "$(hdr strict-transport-security)" ] && ok "$(hdr strict-transport-security)" || fail "no Strict-Transport-Security (HSTS)"
[ -n "$(hdr content-security-policy)" ] && ok "CSP present" || warn "no Content-Security-Policy"
hdr x-content-type-options | grep -qi nosniff && ok "X-Content-Type-Options: nosniff" || fail "no X-Content-Type-Options: nosniff"
if [ -n "$(hdr x-frame-options)" ] || hdr content-security-policy | grep -qi frame-ancestors; then ok "clickjacking protection set"; else fail "no X-Frame-Options or CSP frame-ancestors"; fi
[ -n "$(hdr referrer-policy)" ] && ok "$(hdr referrer-policy)" || warn "no Referrer-Policy"
hdr server | grep -q '[0-9]' && warn "version disclosed: $(hdr server)"
[ -n "$(hdr x-powered-by)" ] && warn "version disclosed: $(hdr x-powered-by)"
echo "== 3. TLS"
code=$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' --max-time 10 "http://$HOST/" 2>/dev/null)
case "$code" in 30[18]\ https://*) ok "HTTP redirects to HTTPS";; *) warn "HTTP answer: $code";; esac
if err=$(curl -sS -o /dev/null --max-time 10 "https://$HOST/" 2>&1); then ok "certificate chain verifies"; else fail "TLS error: $err"; fi
for p in tls1 tls1_1; do
if echo | openssl s_client -connect "$HOST:443" -servername "$HOST" -"$p" -cipher 'DEFAULT:@SECLEVEL=0' >/dev/null 2>&1; then
fail "server accepts legacy protocol $p"; else ok "$p refused"; fi
done
END=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -n "$END" ]; then
DAYS=$(( ($(date -d "$END" +%s) - $(date +%s)) / 86400 ))
[ "$DAYS" -lt 14 ] && warn "certificate expires in $DAYS days" || ok "certificate valid for $DAYS more days"
fi
echo "== 4. WordPress exposure"
GEN=$($C "$URL/" | grep -o '<meta name="generator" content="WordPress [^"]*"' | head -1)
[ -n "$GEN" ] && warn "version in HTML: $GEN"
loc=$($C -o /dev/null -w '%{http_code} %{redirect_url}' "$URL/?author=1")
case "$loc" in 30?\ */author/*) warn "user enumeration via ?author=1: $loc";; *) ok "?author=1 -> $loc";; esac
users=$($C "$URL/wp-json/wp/v2/users" | grep -o '"slug":"[^"]*"' | tr '\n' ' ')
[ -n "$users" ] && fail "REST API lists users: $users" || ok "/wp-json/wp/v2/users does not list users"
x=$($C -o - -w '\n%{http_code}' -H 'Content-Type: text/xml' --data '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' "$URL/xmlrpc.php")
if printf '%s' "$x" | grep -q '<methodResponse>'; then
n=$(printf '%s' "$x" | grep -o '<string>' | wc -l); m=$(printf '%s' "$x" | grep -q system.multicall && echo yes || echo no); warn "xmlrpc.php is open ($n methods, system.multicall: $m)"
else ok "xmlrpc.php closed (HTTP ${x##*$'\n'})"; fi
if [ "$LOGINTEST" -gt 0 ]; then
codes=$(for i in $(seq "$LOGINTEST"); do $C -o /dev/null -w '%{http_code}\n' -d 'log=audit-no-such-user&pwd=wrong-password' "$URL/wp-login.php"; done | sort | uniq -c | tr -s ' ' | tr '\n' ',')
echo "$codes" | grep -qE ' (429|403|503)' && ok "login throttled: $codes" || warn "no rate limit seen on wp-login.php after $LOGINTEST failed logins: $codes"
fi
if [ -n "$WPPATH" ]; then
echo "== 5. Server side: $WPPATH"
found=$(find "$WPPATH" -maxdepth 3 \( -name '.git' -o -name '.env' -o -name '*.sql' -o -name '*.sql.gz' -o -name '*.zip' -o -name '*.tar.gz' -o -name '*~' -o -name '*.bak' -o -name 'phpinfo.php' \) -not -path '*/node_modules/*' 2>/dev/null)
[ -n "$found" ] && fail "risky files in webroot: $(echo $found)" || ok "no dumps, archives or backups in webroot"
php_up=$(find "$WPPATH/wp-content/uploads" -name '*.php' 2>/dev/null | head -5)
[ -n "$php_up" ] && fail "PHP files in uploads: $(echo $php_up)" || ok "no PHP files in uploads"
if command -v "${WP%% *}" >/dev/null 2>&1; then
echo "core: $($WP --path="$WPPATH" core version 2>/dev/null)"
$WP --path="$WPPATH" core check-update 2>&1 | head -5
$WP --path="$WPPATH" core verify-checksums 2>&1 | tail -8
$WP --path="$WPPATH" plugin list --fields=name,status,version,update_version 2>&1
else warn "wp-cli not found: skipped core and plugin checks"; fi
fi
echo "== Done in $(( $(date +%s) - T0 )) s: $FAIL FAIL, $WARN WARN"
exit $(( FAIL > 0 ? 1 : 0 ))
Se encontrar X, faça Y
| Achado | O que fazer |
|---|---|
.git ou .env públicos |
Bloquear o acesso no .htaccess, tirar da raiz web, trocar todas as senhas e tokens desses arquivos, procurar downloads nos logs |
| Dump do banco ou arquivo compactado do site na raiz | Tirar da raiz web, trocar as senhas dos usuários e do banco se o arquivo pode ter sido baixado |
wp-config.php~, .bak, .swp |
Excluir, trocar a senha do banco, renovar as chaves com wp config shuffle-salts |
phpinfo.php ou “Index of” |
Excluir o arquivo, adicionar Options -Indexes |
| Núcleo ou plugin desatualizado | Atualizar; excluir plugins e temas sem uso, substituir um plugin sem atualizações há mais de um ano |
| verify-checksums: arquivo do núcleo modificado | Não editar à mão: reinstalar a mesma versão do núcleo (wp core download --force --skip-content) ou atualizar, depois buscar a causa nos logs e trocar as senhas |
| File should not exist | Abrir o arquivo; se for desconhecido, colocá-lo em quarentena e procurar arquivos parecidos em uploads, temas e plugins |
| Sem HSTS, nosniff, X-Frame-Options | Adicionar o bloco de cabeçalhos, começar o HSTS sem includeSubDomains |
| TLS 1.0 ou 1.1 | Hospedagem: falar com o suporte; VPS: ssl_protocols TLSv1.2 TLSv1.3 |
| Porta do banco ou do backend aberta para a internet | bind-address = 127.0.0.1, backend só no loopback, firewall |
Logins visíveis via REST, ?author=, sitemap |
Mu-plugin, slug de autor diferente do login, senhas fortes e 2FA |
| xmlrpc.php aberto e desnecessário | Bloquear; se o Jetpack ou um app móvel precisar dele, limitar as requisições |
| Sem limite de tentativas de login | VPS: limit_req ou Fail2Ban; hospedagem: plugin de limite de login e 2FA |
Se você caiu em alguma das três primeiras linhas, siga esta ordem: bloquear o acesso, guardar os logs do servidor dos últimos dias (eles mostram se alguém baixou o arquivo), trocar todos os segredos, verificar a integridade do núcleo e dos plugins e só então repetir a auditoria. Se houver sinais de invasão, o caminho mais rápido costuma ser restaurar um backup limpo feito antes do incidente e depois trocar as senhas.
Resultados da bancada: antes e depois
| Verificação | Antes | Depois |
|---|---|---|
| Arquivos internos (.git, .env, dump, cópia do wp-config.php) | 200, senhas visíveis | 404 ou 403, arquivos fora do site |
| Cabeçalhos de segurança | 0 de 5 | 5 de 5 |
| TLS 1.0 e 1.1 | aceitos | recusados |
| Portas acessíveis de fora | 80, 443, 3330, 8303 | 80, 443 |
| 20 logins com senha errada | 20 vezes 200 | 4 vezes 200, 16 vezes 429 |
| verify-checksums | Error, 2 avisos | Success, sem avisos |
| Script de auditoria | 14 FAIL, 8 WARN | 0 FAIL, 1 WARN |
O ciclo completo na bancada, da primeira execução à verificação final, levou cerca de quatro minutos, porque as correções estavam preparadas de antemão. Em um site real, reserve uma hora. Se você está comparando planos de hospedagem, os planos da RichHost incluem o painel de controle próprio com assistente de IA e SSL gratuito Let’s Encrypt, e todas as verificações deste artigo funcionam lá do mesmo jeito.
Fontes (verificadas em 10.11.2026): OWASP Top 10:2025, A02 Security Misconfiguration; OWASP WSTG-CONF-04; WordPress Hardening; WP-CLI verify-checksums; MDN Strict-Transport-Security; Mozilla HTTP Observatory (verificação online de cabeçalhos para sites públicos).
Perguntas frequentes
Dá para fazer esta auditoria em hospedagem compartilhada sem root?
Sim. As verificações externas (curl, openssl, testssl.sh) rodam do seu computador, e o WP-CLI e o find funcionam em uma conta SSH comum. Sem root você não consegue mudar as configurações do nginx, do banco de dados ou do firewall; isso fica a cargo do provedor.
O provedor vai me bloquear se eu rodar nmap ou testssl.sh?
Uma verificação moderada da sua própria VPS não parece um ataque: escaneie só o seu próprio endereço IP e evite os modos agressivos. Na hospedagem compartilhada você não precisa do nmap; fique com curl e testssl.sh, porque quem gerencia as portas de um servidor compartilhado é o provedor.
Com que frequência devo repetir a auditoria?
Rode o script depois de cada atualização, migração ou trabalho de um prestador externo, e pelo menos uma vez por mês de forma agendada. Uma verificação completa de uma hora por trimestre é suficiente.
Isso substitui um plugin de segurança?
Não, eles fazem trabalhos diferentes. Um plugin roda o tempo todo e bloqueia ataques, enquanto uma auditoria mostra o que já está exposto. Na bancada, um plugin não teria ajudado com a pasta .git e o dump na raiz, porque quem os entrega é o servidor web, não o WordPress.



