Перейти до змісту
Пошта Кабінет Обрати тариф

Аудит безпеки сайту за годину: чекліст із командами і перевіркою

Аудит безпеки сайту за годину: чекліст із командами і перевіркою

Аудит безпеки сайту за годину складається з шести перевірок: чи не віддає сервер службові файли (.git, .env, дампи бази, копії wp-config.php), чи актуальні ядро і плагіни, чи збігаються файли ядра з еталоном, чи є заголовки безпеки і сучасний TLS, які порти видно ззовні і що зловмисник дізнається про адміністраторів WordPress. Нижче команди для кожного пункту, реальний вивід із тестового сайту, де ми навмисно залишили всі ці помилки, готовий скрипт і таблиця: що робити з кожною знахідкою.

Як ми перевіряли

10 листопада 2026 року на тестовому стенді: контейнер Ubuntu 24.04.5, nginx 1.24.0 перед Apache 2.4.58, PHP-FPM 8.3.6, MariaDB 10.11.14, WordPress 6.4.3 з офіційного збірного дерева і Akismet 5.3.1. Ми навмисно залишили на сайті всі помилки, про які йдеться нижче, а перевірки запускали з окремого мережевого простору імен, який грав роль зовнішнього комп’ютера: curl, openssl, nmap 7.94, testssl.sh 3.2, WP-CLI. Сервери wordpress.org зі стенду недоступні, тому відповіді їхнього API (контрольні суми, версії) ми зберегли того ж дня і звірили з живим API за SHA-256. Потім виправили кожен пункт і повторили аудит. Документацію OWASP, WordPress і MDN перевірено 10.11.2026. Це поведінка тестового сервера, а не дані з серверів RichHost.

Що знадобиться і скільки це триває

Для сайту на хостингу вистачить терміналу з curl і openssl на вашому комп’ютері та SSH-доступу до акаунта, де можна запустити WP-CLI. Для власного VPS додайте nmap і testssl.sh (обидва є в репозиторіях Ubuntu). Перевіряйте тільки свої сайти і сервери: сканування чужих систем без дозволу власника заборонене правилами більшості провайдерів і законом багатьох країн.

Машинного часу аудит забирає мало. На стенді скрипт нижче пройшов усі зовнішні та серверні перевірки за 2,6 секунди, nmap по 12 портах працював 16,7 секунди, testssl.sh з перевіркою протоколів і заголовків 8,2 секунди. Годину забирає людська частина: прочитати вивід, зрозуміти, що з цього справжня проблема, виправити і перевірити ще раз. Орієнтовний план:

  • 0-15 хвилин: відкриті файли і перелік каталогів;
  • 15-25 хвилин: версії ядра і плагінів, цілісність файлів ядра;
  • 25-35 хвилин: заголовки безпеки і TLS;
  • 35-45 хвилин: вхід в адмінку, xmlrpc.php, перелік користувачів, порти (для VPS);
  • 45-60 хвилин: виправлення і повторний прогін.

1. Відкриті файли: .git, .env, дампи бази, копії wp-config.php

Це найнебезпечніша група, бо кожна знахідка відразу віддає паролі. У OWASP Top 10:2025 такі помилки належать до категорії A02 Security Misconfiguration, а в посібнику з тестування OWASP це перевірка WSTG-CONF-04: старі копії, архіви і файли редакторів, які сервер віддає як звичайний текст. Ось що віддав наш тестовий сайт будь-якому відвідувачу:

$ 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"

Зверніть увагу на wp-config.php~: оригінальний wp-config.php виконується як PHP і нічого не показує, а копію з тильдою редактор зберіг як звичайний текст, і сервер віддав її разом із паролем бази. Із теки .git можна відновити весь код і забрати токен із адреси репозиторію. Дамп бази містить хеші паролів адміністраторів.

Головний висновок стенду: ззовні архів з довільною назвою не вгадати. Скрипт перевірив типові імена (backup.zip, site.zip) і нічого не знайшов, а файл site-backup-2026-11-01.zip показала тільки перевірка на сервері через find:

== 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

Перелік каталогів

Якщо за адресою теки без index.php сервер показує сторінку «Index of», будь-хто бачить усі файли в ній. На стенді в /wp-content/uploads/2026/11/ лежали рахунок і вивантаження клієнтів. Перевірка проста: відкрийте /wp-content/uploads/ і /wp-includes/ у браузері. Виправлення займає один рядок Options -Indexes.

2. Застарілі ядро і плагіни

Ззовні версію WordPress видно в метатегу generator і в параметрах ?ver= у підключених стилях. Версії плагінів іноді видно у файлі readme.txt (рядок Stable tag), але не завжди: Akismet закриває свої текстові файли власним .htaccess, і стенд відповів 403. Надійніше дивитися на сервері через WP-CLI:

$ 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

Станом на 10.11.2026 офіційний API WordPress називав актуальною версію 7.1.4, а для гілки 6.4 останнім виправленням був 6.4.14. Неактивний плагін теж небезпечний: його файли лежать на сервері і доступні напряму. Якщо плагін не потрібен, видаліть його (wp plugin delete), а не просто вимикайте.

3. Цілісність файлів ядра: wp core verify-checksums

Команда завантажує з wordpress.org MD5-суми для вашої версії і порівнює з файлами на диску. На стенді ми додали один рядок у wp-login.php і сторонній файл у 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.

Друга половина виводу важливіша за першу. Після оновлення ядра змінений wp-login.php замінився чистим, але сторонній файл залишився, і команда все одно написала Success. Попередження File should not exist не робить перевірку невдалою, тож читайте весь вивід, а не останній рядок. Невідомий файл ми переглянули і перенесли в карантин поза сайтом, після чого перевірка пройшла без попереджень. Плагіни перевіряє аналогічна команда wp plugin verify-checksums --all, але тільки для плагінів із каталогу wordpress.org.

4. Заголовки безпеки і 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)

Мінімальний набір для сайту на WordPress: Strict-Transport-Security (браузер більше не відкриває сайт по HTTP), X-Content-Type-Options: nosniff, захист від вбудовування у чужі фрейми через X-Frame-Options або директиву frame-ancestors у CSP, а також Referrer-Policy. Повна політика Content-Security-Policy для WordPress з плагінами потребує налаштування під конкретний сайт, тому почніть із безпечної частини (frame-ancestors, object-src, base-uri), а решту вмикайте через Content-Security-Policy-Report-Only. За MDN, браузер ігнорує HSTS, отриманий по HTTP, а для списку preload потрібні строк від року і includeSubDomains, тому додавайте includeSubDomains лише тоді, коли всі піддомени працюють по HTTPS.

Після виправлень (блок для .htaccess наведено нижче) стенд відповідав так:

$ 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)

На shared-хостингу протоколи TLS налаштовує провайдер: якщо testssl.sh показує TLS 1.0 або 1.1, напишіть у підтримку. На власному VPS це рядок ssl_protocols TLSv1.2 TLSv1.3; у nginx.

5. Відкриті порти: nmap ззовні (для VPS)

Сканувати треба з іншого комп’ютера, а не з самого сервера: ss -tlnp на сервері показує, що слухає процес, але не те, що пропускає фаєрвол. На стенді база даних і бекенд Apache випадково слухали зовнішню адресу:

$ 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

Відкритий порт бази разом із паролем з .env означає прямий доступ до даних. Правило просте: назовні мають дивитися тільки 80, 443 і SSH, причому вхід по SSH краще дозволити лише за SSH-ключами. Для перевірки всіх портів використовуйте nmap -Pn -p- ваша_IP, це довше, але нічого не пропустить. Як закрити внутрішні сервіси, прибрати старі версії PHP і захистити SSH на рівні конфігурації, ми розібрали в окремій статті про п’ять типових вразливостей сервера.

6. Адмінка WordPress: користувачі, xmlrpc.php, обмеження входу

$ 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

Логін адміністратора видно за дві секунди, xmlrpc.php приймає 80 методів (зокрема system.multicall, який дозволяє перебирати багато паролів одним запитом), а 20 неправильних паролів поспіль сервер прийняв без жодної затримки. OWASP відносить це до тестів перебору облікових записів (WSTG 4.3.4) і слабкого блокування (WSTG 4.4.3).

Несподіванка стенду: після того як ми закрили ?author=1 і REST-ендпоінт, логіни все одно лишилися видимими через посилання на авторів на головній, карту сайту користувачів і 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.

Тому справжнє виправлення: щоб публічний slug автора не збігався з логіном (--user_nicename), плюс невеликий mu-плагін, який ховає список користувачів від анонімних запитів і прибирає карту сайту авторів. Обмеження спроб входу на VPS дає nginx (limit_req, після виправлення 16 із 20 спроб отримали код 429) або Fail2Ban за журналом. На shared-хостингу ставте плагін обмеження спроб входу і двофакторну автентифікацію для адміністраторів.

Виправлення, які ми застосували

Для сайту на хостингу все робиться у файлі .htaccess у корені сайту. Блок ставте над секцією WordPress і після збереження перевірте головну, запис, сторінку входу і завантаження файлів: на стенді всі вони відповіли 200, а фільтр записів за автором в адмінці продовжив працювати.

# 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

Блок закриває доступ, але не прибирає самі файли. Після нього перенесіть дампи, архіви і .git за межі каталогу сайту, а wp-config.php залиште з обмеженими правами доступу до файлів (600 або 640). Mu-плагін для користувачів (файл 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 );

Для власного VPS додаються налаштування nginx і бази:

# 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

Скрипт аудиту

Скрипт виконує всі зовнішні перевірки зі статті, а з параметром --wp-path ще й серверні (пошук ризикових файлів, PHP у uploads, версії і контрольні суми через WP-CLI). Він нічого не змінює на сайті; опція --login-test 10 надсилає 10 неправильних спроб входу з неіснуючим логіном. На стенді до виправлень він знайшов 14 FAIL і 8 WARN, після виправлень 0 FAIL і 1 WARN (версія в метатегу generator).

Запуск: bash site-audit.sh https://ваш-сайт --login-test 10, на сервері додайте --wp-path ~/public_html (шлях залежить від панелі).

#!/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 ))

Якщо знайшли X, робіть Y

Що знайшли Що робити
Відкриті .git або .env Закрити доступ у .htaccess, винести з каталогу сайту, змінити всі паролі й токени з цих файлів, перевірити в журналах, хто їх завантажував
Дамп бази або архів сайту в корені Перенести за межі каталогу сайту, змінити паролі користувачів і бази, якщо файл встигли завантажити
wp-config.php~, .bak, .swp Видалити, змінити пароль бази, оновити ключі командою wp config shuffle-salts
phpinfo.php або «Index of» Видалити файл, додати Options -Indexes
Застаріле ядро або плагін Оновити; непотрібні плагіни і теми видалити, плагін без оновлень понад рік замінити
verify-checksums: змінений файл ядра Не правити вручну: перевстановити ядро тієї ж версії (wp core download --force --skip-content) або оновити, потім шукати причину в журналах і змінити паролі
File should not exist Відкрити файл; якщо він невідомий, перенести в карантин і шукати схожі файли в uploads, темах і плагінах
Немає HSTS, nosniff, X-Frame-Options Додати блок заголовків, HSTS почати без includeSubDomains
TLS 1.0 або 1.1 Хостинг: звернутися в підтримку; VPS: ssl_protocols TLSv1.2 TLSv1.3
Порт бази або бекенда відкритий назовні bind-address = 127.0.0.1, бекенд тільки на loopback, фаєрвол
Логіни видно через REST, ?author=, карту сайту Mu-плагін, slug автора відмінний від логіна, сильні паролі і 2FA
xmlrpc.php відкритий і не потрібен Закрити; якщо потрібен для Jetpack чи мобільного застосунку, обмежити частоту запитів
Немає обмеження спроб входу VPS: limit_req або Fail2Ban; хостинг: плагін обмеження спроб і 2FA

Якщо знайшли щось із перших трьох рядків, дійте в такому порядку: закрийте доступ, збережіть журнали сервера за останні дні (у них видно, чи завантажував хтось файл), змініть усі секрети, перевірте цілісність ядра і плагінів, і тільки потім запускайте аудит повторно. Якщо є ознаки зламу, найшвидший шлях часто полягає у відновленні з чистої резервної копії, зробленої до інциденту, з подальшою зміною паролів.

Результат на стенді: до і після

Перевірка До Після
Службові файли (.git, .env, дамп, копія wp-config.php) 200, паролі видно 404 або 403, файли поза сайтом
Заголовки безпеки 0 із 5 5 із 5
TLS 1.0 і 1.1 приймаються відхилені
Порти назовні 80, 443, 3330, 8303 80, 443
20 неправильних входів 20 разів 200 4 рази 200, 16 разів 429
verify-checksums Error, 2 попередження Success без попереджень
Скрипт аудиту 14 FAIL, 8 WARN 0 FAIL, 1 WARN

Повний цикл на стенді, від першого прогону до повторної перевірки, зайняв близько чотирьох хвилин, бо виправлення ми підготували заздалегідь. На реальному сайті закладайте годину. Якщо ви шукаєте хостинг для WordPress, на тарифах RichHost є панель керування власної розробки з ШІ-помічником і безкоштовний SSL Let’s Encrypt, а перевірки зі статті працюють так само.

Джерела (перевірено 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 (онлайн-перевірка заголовків публічного сайту).

Часті питання

Чи можна запускати такий аудит на хостингу без root?

Так. Зовнішні перевірки (curl, openssl, testssl.sh) запускаються з вашого комп’ютера, а WP-CLI і find працюють у звичайному SSH-акаунті. Без root недоступні тільки налаштування nginx, бази і фаєрвола, за них відповідає провайдер.

Чи не заблокує мене хостинг за nmap чи testssl.sh?

Помірна перевірка власного VPS не схожа на атаку: скануйте тільки свою IP-адресу і без агресивних режимів. На shared-хостингу nmap не потрібен, обмежтеся curl і testssl.sh, бо порти спільного сервера налаштовує провайдер.

Як часто повторювати аудит?

Скрипт варто запускати після кожного оновлення, переносу чи роботи підрядника на сайті і хоча б раз на місяць за розкладом. Повну годинну перевірку достатньо робити раз на квартал.

Чи замінює це плагін безпеки?

Ні, це різні речі. Плагін працює постійно і блокує атаки, а аудит показує, що вже лежить відкритим. На стенді плагін не допоміг би з .git і дампом у корені, бо їх віддає вебсервер, не WordPress.

Ще за темою

Усі статті
Swap і OOM Killer на сервері: скільки swap потрібно і чому гине база
Продуктивність хостингу14.06.2026

Swap і OOM Killer на сервері: скільки swap потрібно і чому гине база

Скільки swap потрібно серверу, що означає swappiness і чому OOM Killer вбиває MariaDB замість PHP: тест у cgroup з реальними рядками dmesg,…

IOPS, затримка і пропускна здатність диска: що важливо для сайту і як виміряти
Продуктивність хостингу03.06.2026

IOPS, затримка і пропускна здатність диска: що важливо для сайту і як виміряти

Що означають IOPS, затримка і пропускна здатність диска, чому для WordPress і MySQL важливі 4K і fsync, а не МБ/с, і як…

Бенчмарки хостингу: яким тестам довіряти і як перевірити хостинг самому
Продуктивність хостингу13.04.2026

Бенчмарки хостингу: яким тестам довіряти і як перевірити хостинг самому

Які бенчмарки хостингу показують швидкість вашого сайту, а які лише потенціал заліза: заміри fio, dd, sysbench і wrk на стенді, пороги TTFB…