Тесты безопасности как код

Учимся писать автоматические тесты, которые проверяют не «что функция работает», а «что её нельзя обойти»: контроль доступа, валидацию, и регрессии на уже закрытые уязвимости.

Тест безопасности как код — это обычный юнит- или интеграционный тест, но проверяющий негативный сценарий: что неавторизованный запрос отвергнут, что вредоносный ввод не проходит, что закрытая ранее уязвимость не вернулась. Такие тесты ловят регрессии безопасности на каждом коммите автоматически.

Сканеры (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 на каждом коммите; каждую исправленную уязвимость закрепляйте регрессионным тестом.
Проверьте себя
1. Чем тест безопасности на контроль доступа отличается от обычного функционального теста?
AОн проверяет негативный сценарий: что неавторизованный или чужой субъект получает отказ (403/401), а не что правильный пользователь получает результат
BОн запускается только вручную раз в год во время пентеста
CОн проверяет скорость работы эндпоинта под нагрузкой
DОн анализирует исходный код, не отправляя никаких запросов
2. Зачем на каждую исправленную уязвимость писать регрессионный тест?
AЧтобы воспроизвести конкретный вектор атаки и гарантировать, что баг не вернётся при будущих изменениях кода
BЧтобы навсегда отключить сканеры SAST и DAST
CЧтобы тест замедлял сборку и команда реже коммитила
DРегрессионные тесты безопасности не нужны, достаточно один раз починить код