Предыдущие статьи раздела разбирали дыры по одной: инъекции, SSRF, чужой заказ по номеру, лишние поля в запросе и ответе, обход пути и загрузки, XXE и десериализацию, перебор и лимиты, open redirect, CSRF и CORS, гонки, ошибки с JWT. Эта статья про обратную задачу: как найти такую дыру в своём сервисе раньше, чем её найдёт кто-то другой. Она одинаково нужна разработчику, который пишет тесты к своему эндпоинту, и QA-инженеру, который отвечает за регресс.
Порядок такой: сначала понять, что вообще может пойти не так (модель угроз), потом превратить ответы в автотесты, потом добавить инструменты, которые ищут то, о чём вы не подумали (SAST, сканеры зависимостей, DAST, фаззинг), и в конце пустить внешних людей (пентест, bug bounty).
Как устроена проблема и чем грозит
Типичная дыра в доступе появляется не в первом релизе, а в десятом. В шпаргалке OWASP по автоматизации тестов авторизации это сказано прямо: большая часть проблем с авторизацией возникает, когда функции добавляют или меняют в очередных релизах, не проверив, как это влияет на права. Новый эндпоинт «отменить заказ» скопировали со «смотреть заказ», проверку владельца забыли, ручной тест под своим пользователем прошёл, потому что свой заказ отменять можно.
Тесты, которые пишут без модели угроз, проверяют то, о чём автор помнил: «покупатель видит свой заказ». Атакующий проверяет остальное: чужой заказ, чужую роль, мусор вместо номера, тысячу запросов в секунду. Отсюда цена: чужие заказы с адресами и телефонами (BOLA, API1:2023), покупатель, меняющий цену товара (BFLA, API5:2023), а Broken Access Control стоит первым в OWASP Top 10:2025.
Модель угроз за час: потоки данных маркетплейсаспросят на собеседовании
Шпаргалка OWASP по моделированию угроз сводит процесс к четырём вопросам из Threat Modeling Manifesto: над чем мы работаем, что может пойти не так, что мы с этим сделаем, хорошо ли мы справились. Для первого вопроса там же советуют диаграмму потоков данных (DFD), на которой видны границы доверия, потоки, хранилища, процессы и внешние участники: именно они обычно и есть точки атаки.
Для маркетплейса хватит доски и часа. Участники и кто что может вызвать:
| Кто | Откуда | Что вызывает |
|---|---|---|
| Гость | интернет | каталог, вход, регистрация |
| Покупатель | интернет, с токеном | свои заказы: создать, посмотреть, отменить |
| Продавец | интернет, с токеном | свои товары и цены, заказы своего магазина |
| Оператор поддержки, админ | админка | любые заказы, возвраты, блокировки |
| Платёжный провайдер | интернет | вебхук «платёж прошёл» |
| Сервис заказов | внутренняя сеть | сервис платежей, склад, загрузка картинки товара по URL |
Границы доверия проводят там, где меняется «кому мы верим»: между интернетом и шлюзом, между шлюзом и внутренними сервисами, между сервисом и базой, между нами и платёжным провайдером, между админкой и остальным миром. Каждый поток, который пересекает границу, получает вопрос «как мы проверяем того, кто на другой стороне, и то, что он прислал».
STRIDE по элементам схемыспросят на собеседовании
Для второго вопроса шпаргалка берёт STRIDE, мнемонику, которую придумали сотрудники Microsoft: шесть категорий угроз, каждая нарушает одно свойство безопасности. Совет оттуда же: прикладывать все шесть категорий к компонентам, потокам и границам доверия и записывать, что именно затронуто.
| Категория | Что нарушает | Маркетплейс: пример | Чем проверить |
|---|---|---|---|
| Spoofing | аутентификацию | поддельный вебхук «оплачено» без подписи | тест: вебхук без подписи и с чужой подписью отклонён |
| Tampering | целостность | покупатель меняет price в теле заказа | тест: лишнее поле не меняет цену (mass assignment) |
| Repudiation | учёт действий | админ сделал возврат, в журнале пусто | тест: действие админа оставляет запись аудита |
| Information disclosure | конфиденциальность | GET /orders/{id} отдаёт чужой заказ | матрица доступа, ниже |
| Denial of service | доступность | перебор пароля блокирует чужой аккаунт | тест лимита: 429 после N попыток |
| Elevation of privilege | авторизацию | подмена роли в JWT | тест: токен с alg: none и чужой подписью отклонён (JWT) |
Последняя колонка и есть смысл упражнения. Шпаргалка на шаге проверки спрашивает: можно ли протестировать согласованные меры, можно ли измерить их успех. Угроза без теста остаётся пожеланием, угроза с тестом становится требованием, которое сборка проверяет на каждом коммите. На третий вопрос шпаргалка даёт четыре ответа: снизить риск, убрать функцию, передать риск другой стороне или принять его, и решение по каждой угрозе записывают.
Матрица доступа: тест на каждую роль и действиеспросят на собеседовании
Шпаргалка OWASP по автоматизации тестов авторизации описывает доступ двумя измерениями: функция и логическая роль, иногда третьим, данными. Таблицу из них называют матрицей авторизации, и совет там такой: держать её в файле, который читает программа, и гонять по ней интеграционный тест на каждом релизе. Тогда новая строка в матрице сразу становится тестом, а упавший тест сразу говорит, какая пара «роль, действие» нарушена.
Ниже такой прогон на маленьком обработчике в памяти: четыре роли, четыре действия, ожидаемые коды записаны в матрицу. В обработчике две типичные ошибки: просмотр заказа проверяет владельца только у продавца, а смена цены не проверяет роль вообще. Программа печатает таблицу «ожидали/получили» и падает на расхождении.
// ошибка: java.lang.AssertionError: матрица доступа нарушена: 2 расхождения
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
public class AuthzMatrix {
record User(String id, String role) {}
record Order(String customerId, String sellerId) {}
record Row(String action, BiFunction<User, String, Integer> call, String target, List<Integer> expected) {}
static final Map<String, Order> ORDERS = Map.of(
"o-1", new Order("alice", "shop-a"),
"o-2", new Order("bob", "shop-b"));
static final Map<String, String> PRODUCT_SELLER = Map.of("p-1", "shop-a");
static final List<String> ROLES = List.of("guest", "customer", "seller", "admin");
static User login(String role) {
return switch (role) {
case "customer" -> new User("alice", role);
case "seller" -> new User("shop-a", role);
case "admin" -> new User("root", role);
default -> null;
};
}
static int viewOrder(User u, String orderId) {
if (u == null) return 401;
Order o = ORDERS.get(orderId);
if (u.role().equals("seller") && !o.sellerId().equals(u.id())) return 404;
return 200;
}
static int cancelOrder(User u, String orderId) {
if (u == null) return 401;
if (u.role().equals("admin")) return 200;
if (!u.role().equals("customer")) return 403;
return ORDERS.get(orderId).customerId().equals(u.id()) ? 200 : 404;
}
static int changePrice(User u, String productId) {
if (u == null) return 401;
if (u.role().equals("seller") && !PRODUCT_SELLER.get(productId).equals(u.id())) return 404;
return 200;
}
public static void main(String[] args) {
List<Row> matrix = List.of(
new Row("свой заказ", AuthzMatrix::viewOrder, "o-1", List.of(401, 200, 200, 200)),
new Row("чужой заказ", AuthzMatrix::viewOrder, "o-2", List.of(401, 404, 404, 200)),
new Row("отмена заказа", AuthzMatrix::cancelOrder, "o-1", List.of(401, 200, 403, 200)),
new Row("смена цены", AuthzMatrix::changePrice, "p-1", List.of(401, 403, 200, 200)));
int mismatches = 0;
System.out.printf("%-15s", "ожидали/факт");
ROLES.forEach(r -> System.out.printf("%-14s", r));
System.out.println();
for (Row row : matrix) {
System.out.printf("%-15s", row.action());
for (int i = 0; i < ROLES.size(); i++) {
int expected = row.expected().get(i);
int actual = row.call().apply(login(ROLES.get(i)), row.target());
String mark = expected == actual ? "" : " FAIL";
if (expected != actual) mismatches++;
System.out.printf("%-14s", expected + "/" + actual + mark);
}
System.out.println();
}
if (mismatches > 0) throw new AssertionError("матрица доступа нарушена: " + mismatches + " расхождения");
}
}
// ошибка: матрица доступа нарушена: 2 расхождения
package main
import (
"fmt"
"os"
)
type User struct{ ID, Role string }
type Order struct{ CustomerID, SellerID string }
var orders = map[string]Order{"o-1": {"alice", "shop-a"}, "o-2": {"bob", "shop-b"}}
var productSeller = map[string]string{"p-1": "shop-a"}
var roles = []string{"guest", "customer", "seller", "admin"}
func login(role string) *User {
switch role {
case "customer":
return &User{"alice", role}
case "seller":
return &User{"shop-a", role}
case "admin":
return &User{"root", role}
}
return nil
}
func viewOrder(u *User, orderID string) int {
if u == nil {
return 401
}
if u.Role == "seller" && orders[orderID].SellerID != u.ID {
return 404
}
return 200
}
func cancelOrder(u *User, orderID string) int {
switch {
case u == nil:
return 401
case u.Role == "admin":
return 200
case u.Role != "customer":
return 403
case orders[orderID].CustomerID != u.ID:
return 404
}
return 200
}
func changePrice(u *User, productID string) int {
if u == nil {
return 401
}
if u.Role == "seller" && productSeller[productID] != u.ID {
return 404
}
return 200
}
type row struct {
action string
call func(*User, string) int
target string
expected []int
}
func main() {
matrix := []row{
{"свой заказ", viewOrder, "o-1", []int{401, 200, 200, 200}},
{"чужой заказ", viewOrder, "o-2", []int{401, 404, 404, 200}},
{"отмена заказа", cancelOrder, "o-1", []int{401, 200, 403, 200}},
{"смена цены", changePrice, "p-1", []int{401, 403, 200, 200}},
}
mismatches := 0
fmt.Printf("%-15s", "ожидали/факт")
for _, r := range roles {
fmt.Printf("%-14s", r)
}
fmt.Println()
for _, r := range matrix {
fmt.Printf("%-15s", r.action)
for i, role := range roles {
actual := r.call(login(role), r.target)
cell := fmt.Sprintf("%d/%d", r.expected[i], actual)
if actual != r.expected[i] {
cell += " FAIL"
mismatches++
}
fmt.Printf("%-14s", cell)
}
fmt.Println()
}
if mismatches > 0 {
fmt.Fprintf(os.Stderr, "матрица доступа нарушена: %d расхождения\n", mismatches)
os.Exit(1)
}
}
живой пример
// ошибка: AssertionError [ERR_ASSERTION]: матрица доступа нарушена: 2 расхождения
const assert = require("assert");
const orders = { "o-1": { customerId: "alice", sellerId: "shop-a" }, "o-2": { customerId: "bob", sellerId: "shop-b" } };
const productSeller = { "p-1": "shop-a" };
const roles = ["guest", "customer", "seller", "admin"];
const users = { guest: null, customer: { id: "alice", role: "customer" }, seller: { id: "shop-a", role: "seller" }, admin: { id: "root", role: "admin" } };
function viewOrder(user, orderId) {
if (!user) return 401;
if (user.role === "seller" && orders[orderId].sellerId !== user.id) return 404;
return 200;
}
function cancelOrder(user, orderId) {
if (!user) return 401;
if (user.role === "admin") return 200;
if (user.role !== "customer") return 403;
return orders[orderId].customerId === user.id ? 200 : 404;
}
function changePrice(user, productId) {
if (!user) return 401;
if (user.role === "seller" && productSeller[productId] !== user.id) return 404;
return 200;
}
const matrix = [
["свой заказ", viewOrder, "o-1", [401, 200, 200, 200]],
["чужой заказ", viewOrder, "o-2", [401, 404, 404, 200]],
["отмена заказа", cancelOrder, "o-1", [401, 200, 403, 200]],
["смена цены", changePrice, "p-1", [401, 403, 200, 200]],
];
let mismatches = 0;
console.log("ожидали/факт".padEnd(15) + roles.map((r) => r.padEnd(14)).join(""));
for (const [action, call, target, expected] of matrix) {
const cells = roles.map((role, i) => {
const actual = call(users[role], target);
if (actual !== expected[i]) mismatches++;
return `${expected[i]}/${actual}${actual === expected[i] ? "" : " FAIL"}`.padEnd(14);
});
console.log(action.padEnd(15) + cells.join(""));
}
assert.strictEqual(mismatches, 0, `матрица доступа нарушена: ${mismatches} расхождения`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
# ошибка: AssertionError: матрица доступа нарушена: 2 расхождения
ORDERS = {"o-1": {"customer": "alice", "seller": "shop-a"},
"o-2": {"customer": "bob", "seller": "shop-b"}}
PRODUCT_SELLER = {"p-1": "shop-a"}
ROLES = ["guest", "customer", "seller", "admin"]
USERS = {"guest": None, "customer": ("alice", "customer"),
"seller": ("shop-a", "seller"), "admin": ("root", "admin")}
def view_order(user, order_id):
if user is None:
return 401
user_id, role = user
if role == "seller" and ORDERS[order_id]["seller"] != user_id:
return 404
return 200
def cancel_order(user, order_id):
if user is None:
return 401
user_id, role = user
if role == "admin":
return 200
if role != "customer":
return 403
return 200 if ORDERS[order_id]["customer"] == user_id else 404
def change_price(user, product_id):
if user is None:
return 401
user_id, role = user
if role == "seller" and PRODUCT_SELLER[product_id] != user_id:
return 404
return 200
MATRIX = [
("свой заказ", view_order, "o-1", [401, 200, 200, 200]),
("чужой заказ", view_order, "o-2", [401, 404, 404, 200]),
("отмена заказа", cancel_order, "o-1", [401, 200, 403, 200]),
("смена цены", change_price, "p-1", [401, 403, 200, 200]),
]
mismatches = 0
print(f"{'ожидали/факт':<15}" + "".join(f"{r:<14}" for r in ROLES))
for action, call, target, expected in MATRIX:
cells = []
for role, want in zip(ROLES, expected):
got = call(USERS[role], target)
mismatches += got != want
cells.append(f"{f'{want}/{got}' + ('' if got == want else ' FAIL'):<14}")
print(f"{action:<15}" + "".join(cells))
assert mismatches == 0, f"матрица доступа нарушена: {mismatches} расхождения"
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Вывод одинаковый на всех четырёх языках:
ожидали/факт guest customer seller admin
свой заказ 401/401 200/200 200/200 200/200
чужой заказ 401/401 404/200 FAIL 404/404 200/200
отмена заказа 401/401 200/200 403/403 200/200
смена цены 401/401 403/200 FAIL 200/200 200/200
Обе ошибки видны строкой и столбцом сразу: покупатель видит чужой заказ (BOLA) и меняет цену чужого товара (BFLA). Ручной тест «покупатель открыл свой заказ» прошёл бы зелёным. Починка в две строки: в просмотре заказа такая же проверка владельца для покупателя, как для продавца, а в смене цены отказ 403 всем, кроме продавца и админа. После неё все шестнадцать ячеек совпадают, и программа завершается без ошибки. Попробуйте сами в редакторе выше.
Почему для чужого заказа в матрице 404, а не 403, разобрано в статье про владение ресурсом: 403 подтверждает, что заказ с таким номером существует, и помогает перебору. Решение «404» и есть требование, и матрица его фиксирует.
Тесты во фреймворке: чужой заказ, враждебный ввод, лимиты
В настоящем проекте та же матрица живёт в параметризованном тесте над HTTP-слоем, рядом с негативными тестами на ввод. Что проверять, берём из шпаргалки OWASP по REST: тело больше лимита отклоняется кодом 413, тело с неподдерживаемым Content-Type кодом 415, слишком частые запросы получают 429. К этому добавляем строки, на которых ломались статьи раздела: ../ в имени файла, кавычку и OR '1'='1 в идентификаторе, и проверку, что в ответе на мусор нет трассировки стека.
Примеры ниже не запускаются в песочнице: им нужны фреймворк и приложение. Помощники вроде выдачи тестового токена для роли и сборки приложения из тестового хранилища здесь условные, в каждом проекте они свои; лимит входа в тестовой конфигурации равен пяти попыткам.
@WebMvcTest(OrderController.class)
class OrderSecurityTest {
@Autowired MockMvc mvc;
@ParameterizedTest(name = "{0} {1} {2} -> {3}")
@CsvSource({
"guest, GET, /orders/o-1, 401",
"customer, GET, /orders/o-2, 404",
"seller, POST, /orders/o-1/cancel, 403",
"customer, PUT, /products/p-1/price, 403",
})
void matrix(String role, String method, String path, int status) throws Exception {
var request = request(HttpMethod.valueOf(method), path)
.contentType(MediaType.APPLICATION_JSON).content("{\"price\":100}");
if (!role.equals("guest")) request.with(TestUsers.jwtFor(role));
mvc.perform(request).andExpect(status().is(status));
}
@ParameterizedTest
@ValueSource(strings = {"../../etc/passwd", "o-1' OR '1'='1", "o-1\u0000.pdf"})
void hostileInvoiceName(String file) throws Exception {
mvc.perform(get("/orders/o-1/invoice").param("file", file).with(TestUsers.jwtFor("customer")))
.andExpect(status().is4xxClientError())
.andExpect(content().string(not(containsString("Exception"))));
}
@Test
void wrongContentType() throws Exception {
mvc.perform(post("/orders").with(TestUsers.jwtFor("customer"))
.contentType(MediaType.TEXT_PLAIN).content("o-1"))
.andExpect(status().isUnsupportedMediaType());
}
}
func TestAuthzMatrix(t *testing.T) {
srv := newServer(testStore())
cases := []struct {
role, method, path string
want int
}{
{"guest", http.MethodGet, "/orders/o-1", http.StatusUnauthorized},
{"customer", http.MethodGet, "/orders/o-2", http.StatusNotFound},
{"seller", http.MethodPost, "/orders/o-1/cancel", http.StatusForbidden},
{"customer", http.MethodPut, "/products/p-1/price", http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.role+" "+c.method+" "+c.path, func(t *testing.T) {
req := httptest.NewRequest(c.method, c.path, strings.NewReader(`{"price":100}`))
req.Header.Set("Content-Type", "application/json")
if c.role != "guest" {
req.Header.Set("Authorization", "Bearer "+testToken(c.role))
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("status %d, want %d", rec.Code, c.want)
}
})
}
}
func TestOversizedBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/orders", strings.NewReader(strings.Repeat("a", 2<<20)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+testToken("customer"))
rec := httptest.NewRecorder()
newServer(testStore()).ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status %d, want 413", rec.Code)
}
}
const { test } = require("node:test");
const request = require("supertest");
const { buildApp } = require("../src/app");
const { testStore, testToken } = require("./helpers");
const app = buildApp(testStore());
const matrix = [
["guest", "get", "/orders/o-1", 401],
["customer", "get", "/orders/o-2", 404],
["seller", "post", "/orders/o-1/cancel", 403],
["customer", "put", "/products/p-1/price", 403],
];
for (const [role, method, path, status] of matrix) {
test(`${role} ${method} ${path} -> ${status}`, async () => {
const req = request(app)[method](path);
if (role !== "guest") req.set("Authorization", `Bearer ${testToken(role)}`);
await req.send({ price: 100 }).expect(status);
});
}
test("login rate limit returns 429", async () => {
for (let i = 0; i < 5; i++) {
await request(app).post("/login").send({ email: "a@example.com", password: "wrong" }).expect(401);
}
await request(app).post("/login").send({ email: "a@example.com", password: "wrong" }).expect(429);
});
import pytest
from fastapi.testclient import TestClient
from shop.app import create_app
from tests.helpers import make_store, token_for
client = TestClient(create_app(make_store()))
@pytest.mark.parametrize("role,method,path,status", [
("guest", "GET", "/orders/o-1", 401),
("customer", "GET", "/orders/o-2", 404),
("seller", "POST", "/orders/o-1/cancel", 403),
("customer", "PUT", "/products/p-1/price", 403),
])
def test_authz_matrix(role, method, path, status):
headers = {} if role == "guest" else {"Authorization": f"Bearer {token_for(role)}"}
resp = client.request(method, path, headers=headers, json={"price": 100})
assert resp.status_code == status
@pytest.mark.parametrize("file", ["../../etc/passwd", "o-1' OR '1'='1", "o-1\x00.pdf"])
def test_hostile_invoice_name(file):
resp = client.get("/orders/o-1/invoice", params={"file": file},
headers={"Authorization": f"Bearer {token_for('customer')}"})
assert 400 <= resp.status_code < 500
assert "Traceback" not in resp.text
Враждебные строки передаём в параметре запроса, а не в пути: клиент или маршрутизатор может нормализовать ../ в пути до того, как запрос дойдёт до обработчика, и тест проверит не то. И помните, где стоит лимит: если размер тела ограничивает прокси или контейнер, тест на уровне обработчика его не увидит, такой лимит проверяют запросом к развёрнутому стенду.
SAST, DAST и фаззинг: кто что ловитспросят на собеседовании
Автотесты проверяют то, что вы записали в требования. Инструменты ищут остальное, и у каждого своё слепое пятно.
| Инструмент | Что смотрит | Хорошо ловит | Не ловит |
|---|---|---|---|
| SAST | исходный код | склейку SQL, слабую криптографию, опасные API | чужой заказ: правило не знает, кому принадлежит o-2 |
| Сканер зависимостей | дерево библиотек | известные CVE | ваш собственный код |
| Поиск секретов | диф и историю git | ключи и пароли в коде | секреты, которые попали в логи |
| DAST | работающий сервис снаружи | заголовки, ошибки 500, инъекции в ответах | правила доступа между ролями |
| Фаззинг | одну функцию с потоком входов | падения и зависания на кривом вводе | логические ошибки, если нет проверки результата |
Настройка SAST в сборке, порог, который её роняет, и подавления со сроком описаны в стандарте: SAST по коду. Там же CVE в зависимостях и секреты в коде и истории. Повторять их здесь не будем; главное правило оттуда одно: каждая проверка роняет сборку, а не пишет отчёт, который никто не читает.
DAST: OWASP ZAP в CIспросят на собеседовании
DAST проверяет запущенный сервис по HTTP, как внешний клиент. Открытый инструмент для этого ZAP (OWASP ZAP), и в его Docker-образе есть готовые скрипты для CI. Всё ниже по документации ZAP.
- Baseline scan по умолчанию минуту обходит сайт пауком, затем ждёт пассивного анализа и пишет отчёт. Он не выполняет настоящих атак, работает несколько минут, и документация называет его подходящим для CI даже против продакшена.
- API scan берёт описание OpenAPI, SOAP или GraphQL и запускает активное сканирование по найденным адресам. Он настроен под API: не ищет XSS, зато поднимает тревогу на ответах 5xx и на типах содержимого, которые для API необычны.
- Full scan атакует по-настоящему и может работать долго.
Активные сканы атакуют цель, и README GitHub Action для API scan предупреждает: сканируйте только то, что вам разрешено, и заранее предупредите хостинг и CDN. Поэтому в CI их направляют на тестовый стенд, а не на прод.
docker run --rm -v $(pwd):/zap/wrk/:rw \
-e ZAP_AUTH_HEADER_VALUE="Bearer $CUSTOMER_TOKEN" \
-t ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t https://staging.shop.example/openapi.json -f openapi \
-c zap-rules.conf -r zap-report.html
ZAP_AUTH_HEADER_VALUE по документации об аутентификации добавляется заголовком ко всем запросам (по умолчанию Authorization), и задать его нужно до старта ZAP. По умолчанию все находки идут как WARN; файл -c переводит правила в FAIL или IGNORE, а заготовку файла со всеми правилами создаёт ключ -g. Коды выхода: 0 чисто, 1 хотя бы один FAIL, 2 только WARN, 3 сбой. Значит, шаг CI красный уже от одних WARN, если не передать ключ -I: с ним предупреждения код выхода не портят. Для GitHub есть готовые действия; у них параметр fail_action по умолчанию false, и шаг не падает из-за находок, пока его не включить:
- name: ZAP API scan
uses: zaproxy/action-api-scan@v0.10.0
with:
target: 'https://staging.shop.example/openapi.json'
format: openapi
rules_file_name: '.zap/rules.tsv'
Сканер ходит под одним токеном и не знает, что заказ o-2 чужой: BOLA он не найдёт. Это работа матрицы доступа, а ZAP закрывает заголовки, утечки в ошибках и инъекции, которые видно по ответу.
Фаззинг: что это и где он окупается
OWASP определяет фаззинг как автоматическую подачу программе неожиданных, испорченных и почти правильных входов, чтобы найти ошибки и уязвимости. Современные фаззеры управляются покрытием: документация Go объясняет, что движок по покрытию кода ищет входы, доходящие до краёв, которые человек пропустит.
Окупается фаззинг на коде, который разбирает чужие байты: парсер номера заказа, нормализация имени файла, чтение CSV от продавца, разбор подписи вебхука. Вместо «не упало» полезнее проверять свойство: «нормализованный путь всегда внутри каталога загрузок», «разобранный и снова собранный номер равен исходному».
- Go поддерживает фаззинг в стандартном инструментарии с Go 1.18: функция
FuzzXxx(f *testing.F)в файле_test.go, начальные входы черезf.Add, цель черезf.Fuzz. Обычныйgo testпрогоняет только начальные входы как юнит-тест, аgo test -fuzz=FuzzNameфаззит без ограничения по времени, если не задать-fuzztime. Найденный падающий вход сохраняется вtestdata/fuzz/, и с этого момента он регрессионный тест. - Java: Jazzer, фаззер с покрытием, работает внутри процесса, встраивается в JUnit 5.9+, тест помечают
@FuzzTest. - Node.js: Jazzer.js, тот же подход, с интеграцией в Jest.
- Python: Atheris, фаззер с покрытием на основе libFuzzer.
В CI фаззер гоняют по расписанию с ограничением времени, а в каждой сборке прогоняют только сохранённый корпус: так найденное однажды не возвращается.
Пентест и bug bounty: чего ждатьспросят на собеседовании
Пентест покупают, когда своих тестов уже достаточно: внешняя команда ищет то, что вы не моделировали. Чтобы деньги не ушли на отчёт из заголовков безопасности, дайте пентестерам стенд с данными, по аккаунту на каждую роль (два покупателя, два продавца, чтобы было что воровать друг у друга), описание API и вашу модель угроз. Методологию обычно сверяют с OWASP Web Security Testing Guide: стабильная версия 4.2, версия 5.0 в разработке. Ждите отчёт с находками, оценкой серьёзности и шагами воспроизведения, и договоритесь о повторной проверке после исправлений.
Bug bounty это постоянная программа, где внешним исследователям платят за найденное. Шпаргалка OWASP по раскрытию уязвимостей перечисляет, что нужно решить заранее: какие системы в области (прод или стенд, без чужих сервисов), какие типы уязвимостей оплачиваются (TLS, отсутствующие заголовки, раскрытие версии), правовые гарантии (safe harbor), размер выплат и сроки ответа. Там же честно про цену: много мусора и ложных срабатываний, споры из-за дублей и выхода за область, трафик исследователей трудно отличить от атаки. Вывод шпаргалки: программу стоит запускать только тем, у кого уже есть зрелый процесс приёма и исправления уязвимостей. Первый шаг дешевле: файл /.well-known/security.txt по RFC 9116 с обязательными полями Contact и Expires, чтобы исследователь знал, куда писать.
Что делать с пришедшей находкой, по шагам: воспроизвести, оценить серьёзность, назначить срок по ней и не подавлять без даты. Сроки по severity и правила подавлений описаны в стандарте реакции на findings.
Чеклист ревью PR
Ревьюер не обязан быть специалистом по безопасности, ему достаточно задать эти вопросы к каждому PR, который трогает API или данные.
- Новый эндпоинт есть в матрице доступа, и тест на него падает, если убрать проверку. Чужой объект возвращает 404 (IDOR и BOLA).
- Запрос принимает DTO, а не сущность; ответ отдаёт только нужные поля (mass assignment).
- Запросы к базе параметризованы, ни одной склейки строк с вводом (инъекции).
- Путь к файлу из ввода проверен после нормализации, у загрузок есть лимит и свой генерированный файл (обход пути).
- Сервер ходит по URL из ввода только по списку разрешённых адресов, без следования редиректам (SSRF).
- Разбор XML и десериализация не принимают DTD и произвольные классы (XXE и десериализация).
- Вход, восстановление пароля, отправка SMS ограничены по частоте (перебор и лимиты).
- Списания и промокоды атомарны или идемпотентны (гонки).
- Редирект по параметру и CORS идут по списку разрешённых значений (open redirect, CSRF, CORS).
- Токен проверяется с фиксированным алгоритмом,
audи сроком (JWT). - В логах и ошибках нет персональных данных и секретов (PII и секреты).
Типичные ошибки
- Тест только под своим пользователем. «Покупатель видит заказ» зелёный, а «покупатель видит чужой заказ» никто не написал. Каждая строка матрицы проверяется под каждой ролью, особенно в запрещающих ячейках.
- Матрица в голове или в вики, а не в тесте. Через полгода она расходится с кодом, и никто этого не замечает.
- Проверка «не 200» вместо точного кода. Ответ 500 на чужой заказ тоже «не 200», но это другая дыра: сервис упал и, возможно, вывел подробности.
- DAST с настройками по умолчанию как единственная проверка. Он найдёт отсутствующие заголовки и пропустит чужие заказы.
- Отчёт сканера, который никого не останавливает. Скан запущен с
-Iили в action оставленfail_action: false, сборка зелёная при любых находках, отчёт никто не открывает. Нужен порог FAIL и список исключений с причиной и датой. - Исправили находку пентеста без теста. Через два релиза её вернёт рефакторинг. Сначала тест, который воспроизводит находку и падает, потом исправление.
Глубже: от находки до регрессионного тестарасширенное
Находка из любого источника, будь то пентест, bug bounty, ZAP или жалоба в поддержку, проходит один и тот же путь, и сам путь важнее, чем инструмент.
- Воспроизвести на стенде по шагам из отчёта. Не воспроизвелось: попросить у автора запрос целиком, а не закрывать.
- Найти класс, а не случай. Отчёт говорит «покупатель видит заказ 1042». Класс: «просмотр заказа не проверяет владельца у покупателя». Значит, проверить и соседние действия: отмену, счёт, чат по заказу.
- Написать падающий тест: новую строку в матрице, негативный тест или вход в корпус фаззера.
- Исправить и убедиться, что тест позеленел, а остальные строки матрицы не поменялись.
- Обновить модель угроз, если находка показала поток или границу, которых в ней не было. Шпаргалка по моделированию угроз прямо требует держать модель в актуальном состоянии вместе с системой.
Так каждая внешняя находка делает внутренние тесты сильнее, и та же дыра не возвращается.
Глубже: что тестировать QA-инженеру без доступа к кодурасширенное
Матрица доступа не требует знать код: её строки это пары «роль, действие» из требований. QA-инженер может завести по аккаунту на каждую роль и по два аккаунта одной роли, пройти каждый сценарий под чужим аккаунтом, подставляя номера чужих объектов, и записать коды ответов в ту же таблицу. Отдельно стоит пройти:
- каждое поле формы с
../, кавычкой, очень длинной строкой и пустым значением; - запрос без тела, с телом в несколько мегабайт и с чужим
Content-Type; - одно и то же действие (оплата, промокод) двумя вкладками одновременно;
- вход с неверным паролем десять раз подряд, ожидая 429 или блокировку;
- текст ошибки на любой из этих запросов: в нём не должно быть стека, SQL и путей на сервере.
Найденное превращается в строку матрицы или в негативный тест у разработчика, и дальше его проверяет сборка, а не человек.
Коротко
- Модель угроз отвечает на четыре вопроса: что строим, что может сломаться, что делаем, хорошо ли сделали. Потоки данных и границы доверия маркетплейса рисуются за час.
- STRIDE прикладывают к каждому компоненту, потоку и границе; у каждой угрозы должен быть тест, иначе это пожелание.
- Матрица доступа (роль × действие → ожидаемый код) живёт в файле и проверяется автотестом на каждом релизе. Чужой заказ возвращает 404, и это тоже строка матрицы.
- Негативные тесты: обход пути, инъекционные строки, тело больше лимита (413), чужой
Content-Type(415), превышение частоты (429), никакого стека в ответе. - SAST, сканер зависимостей и поиск секретов роняют сборку; правила в стандарте безопасности.
- ZAP baseline пассивен и подходит для CI, API scan и full scan атакуют и идут на стенд. Права между ролями DAST не проверяет.
- Фаззинг окупается на парсерах чужого ввода; найденные входы становятся регрессионными тестами.
- Bug bounty только поверх зрелого процесса приёма уязвимостей; начать можно с
security.txt. Любая находка: воспроизвести, найти класс, написать падающий тест, исправить.
Что почитать дальше
- Где ставить проверку доступа - слои проверки, которые матрица доступа проверяет снаружи.
- Владение ресурсом и ABAC - почему 404 на чужой заказ и как не дать перебрать номера.
- IDOR и BOLA - разбор самой частой дыры, которую ловит матрица.
- Перебор и лимиты - что именно ограничивать и как отвечать 429.
- Стандарт безопасности - SAST, зависимости, секреты, образы и реакция на находки.
- Безопасность в браузере - XSS, CSRF, CORS и cookie со стороны клиента.