Pular para o conteúdo
Email Painel de controle Escolher plano

Auditoria de segurança de site: checklist de uma hora com comandos

Auditoria de segurança de site: checklist de uma hora com comandos

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.

Mais sobre este tema

Todos os artigos
Tarefas cron na hospedagem: como configurar e corrigir erros
Configuração de servidores12.06.2026

Tarefas cron na hospedagem: como configurar e corrigir erros

Como adicionar uma tarefa cron no painel ou com crontab, por que um comando funciona via SSH mas falha no cron e…

SSL gratuito Let’s Encrypt: painel, Certbot, renovação e erros
Guias e tutoriais de hosting15.04.2026

SSL gratuito Let’s Encrypt: painel, Certbot, renovação e erros

SSL gratuito Let's Encrypt no painel de hospedagem e com Certbot: testado com um servidor ACME de teste, com tabela de erros,…

Configurar Fail2Ban e CSF: jails para SSH, nginx e WordPress testadas
Guias e tutoriais de hosting01.07.2026

Configurar Fail2Ban e CSF: jails para SSH, nginx e WordPress testadas

Como configurar o Fail2Ban para SSH, nginx e WordPress, o que ele realmente bloqueia e seis armadilhas: banir a si mesmo, IPv6,…