Тесты безопасности как код
Учимся писать автоматические тесты, которые проверяют не «что функция работает», а «что её нельзя обойти»: контроль доступа, валидацию, и регрессии на уже закрытые уязвимости.
Тест безопасности как код — это обычный юнит- или интеграционный тест, но проверяющий негативный сценарий: что неавторизованный запрос отвергнут, что вредоносный ввод не проходит, что закрытая ранее уязвимость не вернулась. Такие тесты ловят регрессии безопасности на каждом коммите автоматически.
Сканеры (SAST/DAST) ищут известные шаблоны, но не знают вашей бизнес-логики: может ли пользователь A открыть заказ пользователя B, имеет ли роль «менеджер» право удалять. Эти правила — часть приложения, и проверять их должны тесты, которые пишет команда. Их огромный плюс: они выполняются в CI на каждом изменении, в отличие от пентеста раз в год. Все примеры ниже — про тестирование своего приложения.
Зачем это знать разработчику
Большинство «обычных» тестов проверяют happy path: правильный пользователь делает правильное действие — получает результат. Но безопасность живёт в негативных путях: неправильный пользователь пытается сделать действие — и должен получить отказ. Если такой путь не покрыт тестом, регрессия проходит незаметно: кто-то рефакторит контроллер, случайно убирает проверку прав — функционал работает, happy-path-тесты зелёные, а дыра открыта. Негативный тест ловит это сразу.
Тесты на контроль доступа
Самый важный класс — проверки авторизации. На каждый защищённый ресурс пишут тест: «чужой/анонимный/недостаточно привилегированный субъект получает отказ» (обычно 403 или 401). Это прямая защита от IDOR (обращение к чужому объекту по id) и broken access control.
def test_user_cannot_read_foreign_order(client):
# Пользователь A залогинен; заказ #2 принадлежит пользователю B
login(client, user="alice")
resp = client.get("/api/orders/2") # чужой заказ
assert resp.status_code == 403 # доступ должен быть запрещён
def test_anonymous_cannot_access_admin(client):
resp = client.get("/api/admin/users") # без аутентификации
assert resp.status_code in (401, 403)
def test_regular_user_cannot_delete(client):
login(client, user="alice") # роль: обычный пользователь
resp = client.delete("/api/orders/1") # удаление — только для менеджера
assert resp.status_code == 403
Принцип — проверять отрицательный результат: не «менеджер может удалить», а «обычный пользователь не может». Именно отказ — суть контроля доступа, и именно его легко случайно сломать рефакторингом.
Тесты на валидацию ввода
Второй класс — проверка, что приложение отвергает или безопасно обрабатывает вредоносный и некорректный ввод: SQL-метасимволы, разметку для XSS, слишком длинные строки, неверные типы, обход пути (../). Тест убеждается, что система валидирует границы и не отражает опасный ввод сырым.
import pytest
# Параметризуем вредоносные входы: тест прогонится для каждого
@pytest.mark.parametrize("payload", [
"' OR '1'='1", # классический SQL-инъекционный маркер
"<script>x</script>", # попытка XSS
"../../etc/passwd", # обход пути
"A" * 100000, # переполнение длины
])
def test_search_rejects_or_sanitizes(client, payload):
resp = client.get("/api/search", params={"q": payload})
# Приложение НЕ должно падать (500) и НЕ должно отражать payload сырым
assert resp.status_code != 500
assert payload not in resp.text
Здесь проверяется два инварианта: приложение устойчиво (нет 500 и краша) и не отражает опасную строку обратно без экранирования. Параметризация (parametrize) удобна тем, что один тест покрывает целый набор пейлоадов, и список легко расширять.
Регрессионные тесты на уязвимости
Третий класс — самый недооценённый. Когда уязвимость уже нашли и закрыли, на неё пишут тест, воспроизводящий именно тот вектор. Этот тест навсегда остаётся в наборе и падает, если кто-то случайно вернёт баг. Так дыра не «реанимируется» при будущем рефакторинге.
def test_regression_jwt_alg_none_rejected(client):
# Раньше сервер принимал JWT с alg=none (подпись не проверялась) — закрыто.
# Этот тест гарантирует, что дыра не вернётся.
forged = make_unsigned_token(user="alice", role="admin") # токен без подписи
resp = client.get("/api/admin/users",
headers={"Authorization": f"Bearer {forged}"})
assert resp.status_code in (401, 403) # неподписанный токен должен отвергаться
Правило команды: каждая исправленная уязвимость сопровождается регрессионным тестом. Это превращает разовую починку в постоянную гарантию и не даёт наступить на старые грабли.
Как это работает под капотом
Технически это те же тесты, что и функциональные: тестовый клиент поднимает приложение (или мокает слой), шлёт запрос и проверяет ответ. Разница — в формулировке утверждения. Функциональный тест говорит «дай мне результат и проверь его правильность». Тест безопасности говорит «попробуй сделать запрещённое и докажи, что система отказала». Поэтому ключевые ассерты здесь — на коды отказа (401/403), на отсутствие 500, на отсутствие опасной строки в ответе и на то, что побочный эффект (запись, удаление) не произошёл.
Как защититься
1. На каждый защищённый эндпоинт — негативный тест авторизации. Анонимный и чужой субъект должны получать отказ; покрывайте это явно.
2. Параметризуйте вредоносные входы. Держите общий список пейлоадов (SQLi-маркеры, XSS, обход пути, гигантские строки) и гоняйте его по всем точкам ввода.
3. На каждую закрытую уязвимость — регрессионный тест. Воспроизводите конкретный вектор, чтобы баг не вернулся.
4. Встройте эти тесты в CI и в гейт. Тест безопасности ценен тем, что выполняется на каждом коммите автоматически, а не раз в год руками.
Итоги
- Тесты безопасности проверяют негативные сценарии: что запрещённое действие отвергнуто, а вредоносный ввод не проходит.
- Они закрывают то, чего не знают сканеры — вашу бизнес-логику контроля доступа.
- Три класса: контроль доступа (отказ чужому), валидация ввода (устойчивость к пейлоадам), регрессии (закрытая дыра не вернулась).
- Ключевые утверждения — коды отказа
401/403, отсутствие500и отсутствие опасной строки в ответе. - Главная ценность — выполнение в CI на каждом коммите; каждую исправленную уязвимость закрепляйте регрессионным тестом.