fix(tradein/security): audit findings — proxy-key leak, admin audit, login status, lead IDOR
Четыре независимых security-audit находки:
1. rotate_proxy_ip (admin.py) отдавал str(httpx exc) клиенту и в Sentry —
mobileproxy changeip-URL несёт API-ключ в query-string. Ответ теперь
нейтральный ("changeip request failed"); sentry_scrub.py получил
composable full-text редактор секрет-подобных query-параметров
(?token=/?proxy_key=/?api_key=/... — не завязан на конкретного провайдера),
встроенный в scrub_pii_event (in-place, сохраняет event identity) — main.py
не тронут, редактор подключается автоматически через существующую ссылку.
2. Меняющие состояние /api/v1/admin/* ручки (куки, авто-логin, прокси,
настройки скрапера, bulk) были полностью исключены из user_events —
установить, кто их вызвал, было нельзя. RequestAuditMiddleware теперь
пишет `admin_action` для POST/PUT/PATCH/DELETE на /admin/* с атрибуцией
(username/ip/path/method/status), БЕЗ тела запроса (там секреты). GET
дашборды по-прежнему не логируются (design как раньше — не шумят).
3. login-событие писалось безусловно, без учёта response.status_code —
отражённая RBAC-попытка (протухший внутренний секрет / неизвестная роль /
scope-блок) была неотличима от настоящего входа. Теперь event_type
расходится на login/login_failed по фактическому статусу ответа; payload
несёт status_code. Дедуп-бакет (once/user+ip+ua+day) оставлен как есть —
разбивка на success/fail потребовала бы правки user_events.py (вне
scope); задокументировано как известный trade-off.
Побочный вопрос (шум basic_auth 401 от сканеров) не требует доп. фильтра
здесь: Caddy гейтит basic_auth ДО проксирования — трафик без валидного
X-Authenticated-User в этот код вообще не попадает.
4. POST /lead принимал estimate_id без проверки владельца — можно было
привязать заявку к чужой оценке. Переиспользован owner-or-admin guard
_assert_estimate_access из trade_in.py (тот же подход, что #690).
Тесты на каждый пункт (secret не в ответе/событии, admin_action с
атрибуцией и без body, login vs login_failed, чужой estimate_id -> 404) +
regression на существующие сьюты. Полный `pytest -q --deselect
tests/test_search_api.py::test_search_cache_hit`: 2635 passed, 8 skipped.
This commit is contained in:
parent
c9ba1b15ba
commit
2cfd112c53
7 changed files with 458 additions and 24 deletions
|
|
@ -2394,9 +2394,14 @@ async def rotate_proxy_ip(
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {}
|
data = {}
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
|
# НЕ отдавать str(exc) клиенту (аудит-фикс, #security-audit): httpx-исключения
|
||||||
|
# несут полный request URL, а rotate_url — mobileproxy changeip-ссылка с API-
|
||||||
|
# ключом провайдера в query-string (?...&proxy_key=...). str(exc) с этим URL в
|
||||||
|
# HTTP-ответе — прямая утечка секрета вызывающему клиенту. Причина сбоя остаётся
|
||||||
|
# в логах (exc_info=True) для диагностики; наружу — только нейтральный reason.
|
||||||
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
|
logger.warning("rotate-ip: changeip failed source=%s", source, exc_info=True)
|
||||||
return RotateIpResponse(ok=False, reason=f"changeip error: {exc}")
|
return RotateIpResponse(ok=False, reason="changeip request failed")
|
||||||
|
|
||||||
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
|
# changeip отдаёт новый IP в одном из полей (формат провайдер-зависимый).
|
||||||
new_ip = None
|
new_ip = None
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,16 @@ POST /api/v1/trade-in/lead — контактная заявка с резуль
|
||||||
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
||||||
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
||||||
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
|
persist + log; `notified_at` в таблице зарезервирован под будущую доставку.
|
||||||
|
|
||||||
|
IDOR-фикс (security-audit): `estimate_id` раньше только проверялся на
|
||||||
|
СУЩЕСТВОВАНИЕ (`SELECT 1 ... WHERE id = ...`), без проверки владельца — любой
|
||||||
|
аутентифицированный пилот мог привязать свою заявку к чужой оценке (утечка через
|
||||||
|
последующий просмотр лида: чужой адрес/телефон/оценка в заявке, которую видит не
|
||||||
|
её владелец). Гвард переиспользует `_assert_estimate_access` из
|
||||||
|
`app.api.v1.trade_in` — тот же owner-or-admin подход, что и `GET /estimate/{id}`
|
||||||
|
(#690, `tests/test_estimate_idor.py`): 401 без `X-Authenticated-User`, 403 —
|
||||||
|
неизвестная роль, 404 — оценка не найдена ИЛИ принадлежит не этому пользователю
|
||||||
|
(существование чужой оценки не подтверждаем).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -14,11 +24,12 @@ import re
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.v1.trade_in import _assert_estimate_access
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -75,15 +86,20 @@ async def create_trade_in_lead(
|
||||||
payload: TradeInLeadInput,
|
payload: TradeInLeadInput,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
"""Сохраняет лид (телефон + согласие) в trade_in_leads."""
|
||||||
if payload.estimate_id is not None:
|
if payload.estimate_id is not None:
|
||||||
exists = db.execute(
|
estimate_row = db.execute(
|
||||||
text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
text("SELECT created_by FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||||||
{"id": str(payload.estimate_id)},
|
{"id": str(payload.estimate_id)},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if exists is None:
|
if estimate_row is None:
|
||||||
raise HTTPException(status_code=404, detail="estimate not found")
|
raise HTTPException(status_code=404, detail="estimate not found")
|
||||||
|
# IDOR guard (security-audit, зеркалит #690): нельзя привязать лид к
|
||||||
|
# чужой оценке. 404 и на "не найдено", и на "чужая" — не подтверждаем
|
||||||
|
# существование чужого estimate_id.
|
||||||
|
_assert_estimate_access(estimate_row.created_by, x_authenticated_user)
|
||||||
|
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
|
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,27 @@
|
||||||
"""RequestAuditMiddleware — пишет `api_request` (+ дедуплицированный `login`)
|
"""RequestAuditMiddleware — пишет `api_request` / `admin_action` (+ дедуплицированный
|
||||||
события в `user_events` для каждого аутентифицированного `/api/*` запроса.
|
`login`/`login_failed`) события в `user_events` для каждого аутентифицированного
|
||||||
|
`/api/*` запроса.
|
||||||
|
|
||||||
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
|
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
|
||||||
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
|
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
|
||||||
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
|
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
|
||||||
не влияет на HTTP-ответ.
|
не влияет на HTTP-ответ.
|
||||||
|
|
||||||
|
Порядок middleware-стека (см. `app/main.py`: `rbac_guard` — `@app.middleware("http")`,
|
||||||
|
объявлен ДО `app.add_middleware(RequestAuditMiddleware)`) делает `RequestAudit`
|
||||||
|
ВНЕШНИМ по отношению к `rbac_guard` (Starlette строит стек в обратном порядке
|
||||||
|
регистрации — последний `add_middleware` оборачивает предыдущие). Поэтому к моменту,
|
||||||
|
когда код ниже читает `response.status_code`, в нём уже отражён исход rbac_guard
|
||||||
|
(401/403 short-circuit) ИЛИ реального хендлера — статус несёт реальный смысл
|
||||||
|
"успех/отказ", а не только "запрос дошёл до хендлера".
|
||||||
|
|
||||||
|
Заведомо неаутентифицированный трафик (сканеры, долбящиеся в /wp-login.php и т.п.
|
||||||
|
без валидного basic_auth) сюда вообще не попадает: Caddy гейтит basic_auth ПЕРЕД
|
||||||
|
проксированием, так что `X-Authenticated-User` в таких запросах нет — условие
|
||||||
|
`if username and ...` ниже их уже отсекает. Поэтому шум сканеров не нужно
|
||||||
|
дополнительно фильтровать в этом файле — тот класс проблемы («сигнал тонет в шуме
|
||||||
|
сканера») здесь структурно невозможен: событие может появиться только для
|
||||||
|
запроса, прошедшего Caddy basic_auth.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -25,6 +42,13 @@ logger = logging.getLogger(__name__)
|
||||||
# циклическую зависимость.
|
# циклическую зависимость.
|
||||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||||
|
|
||||||
|
# Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в
|
||||||
|
# аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил
|
||||||
|
# прокси / изменил настройки скрапера / выполнил bulk-операцию). GET/HEAD/OPTIONS
|
||||||
|
# на /admin/* остаются вне аудита (см. комментарий ниже — это просмотр дашбордов,
|
||||||
|
# не действие).
|
||||||
|
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||||
|
|
||||||
|
|
||||||
class RequestAuditMiddleware(BaseHTTPMiddleware):
|
class RequestAuditMiddleware(BaseHTTPMiddleware):
|
||||||
"""Логирует активность аутентифицированных пользователей в `user_events`."""
|
"""Логирует активность аутентифицированных пользователей в `user_events`."""
|
||||||
|
|
@ -38,32 +62,67 @@ class RequestAuditMiddleware(BaseHTTPMiddleware):
|
||||||
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
|
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
|
||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
ua = request.headers.get("user-agent")
|
ua = request.headers.get("user-agent")
|
||||||
|
method = request.method
|
||||||
|
success = response.status_code < 400
|
||||||
|
is_admin_path = path.startswith("/api/v1/admin/")
|
||||||
|
|
||||||
# Общий behavior/activity-поток — каждый authenticated API-запрос.
|
# Общий behavior/activity-поток — каждый authenticated API-запрос.
|
||||||
# /api/v1/admin/* исключаем: это ops-действия (просмотр самих
|
# /api/v1/admin/* исключаем из `api_request`: это ops-действия
|
||||||
# дашбордов аудита/аналитики), а не поведение пилота — иначе
|
# (просмотр дашбордов аудита/аналитики), а не поведение пилота —
|
||||||
# запросы дашборда зашумляют top_paths и счётчики активности.
|
# иначе запросы дашборда зашумляют top_paths и счётчики активности.
|
||||||
# login ниже логируем всегда (вход админа с IP — валидный аудит).
|
if not is_admin_path:
|
||||||
if not path.startswith("/api/v1/admin/"):
|
|
||||||
schedule_event(
|
schedule_event(
|
||||||
event_type="api_request",
|
event_type="api_request",
|
||||||
username=username,
|
username=username,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=ua,
|
user_agent=ua,
|
||||||
path=path,
|
path=path,
|
||||||
method=request.method,
|
method=method,
|
||||||
|
payload={"status_code": response.status_code},
|
||||||
)
|
)
|
||||||
|
elif method in _MUTATING_METHODS:
|
||||||
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
|
# Admin-аудит (security-audit fix): раньше ЛЮБОЙ запрос под
|
||||||
# на (юзер, IP, устройство).
|
# /api/v1/admin/* (включая меняющие состояние — загрузка кук,
|
||||||
if should_log_login(username, ip, ua):
|
# авто-логин, правка прокси, настройки скраперов, bulk-операции)
|
||||||
|
# полностью исключался из `user_events` тем же условием, что и
|
||||||
|
# шумные GET-дашборды — установить, КТО совершил действие, было
|
||||||
|
# невозможно. Пишем факт действия + атрибуцию (username/ip/path/
|
||||||
|
# method/статус) — БЕЗ тела запроса (там куки/пароли/секреты
|
||||||
|
# правки прокси), это НЕ payload-лог, а событие "что произошло".
|
||||||
schedule_event(
|
schedule_event(
|
||||||
event_type="login",
|
event_type="admin_action",
|
||||||
username=username,
|
username=username,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=ua,
|
user_agent=ua,
|
||||||
path=path,
|
path=path,
|
||||||
method=request.method,
|
method=method,
|
||||||
|
payload={"status_code": response.status_code, "success": success},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
|
||||||
|
# на (юзер, IP, устройство). Security-audit fix: раньше событие
|
||||||
|
# всегда писалось как `login` независимо от исхода запроса —
|
||||||
|
# отражённая RBAC-попытка (валидный Caddy basic_auth, но
|
||||||
|
# 401/403 от rbac_guard: протухший X-Internal-Auth-Secret,
|
||||||
|
# неизвестная роль, scope-блок) была неотличима от настоящего
|
||||||
|
# входа. Теперь тип события расходится по `response.status_code`:
|
||||||
|
# `login` — успех, `login_failed` — otказ. Дедуп-бакет (once per
|
||||||
|
# user+ip+ua+day) НЕ разбит отдельно на success/fail (это
|
||||||
|
# потребовало бы менять `should_log_login` в user_events.py —
|
||||||
|
# вне scope этого фикса): если в рамках одного дня с этого же
|
||||||
|
# устройства сначала случился отказ, а затем реальный успешный
|
||||||
|
# вход, второе событие в тот же день не запишется — тот же
|
||||||
|
# компромисс дедупа, что был и раньше, разница только в том, что
|
||||||
|
# теперь ЕДИНСТВЕННОЕ событие дня корректно отражает, чем оно было.
|
||||||
|
if should_log_login(username, ip, ua):
|
||||||
|
schedule_event(
|
||||||
|
event_type="login" if success else "login_failed",
|
||||||
|
username=username,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=ua,
|
||||||
|
path=path,
|
||||||
|
method=method,
|
||||||
|
payload={"status_code": response.status_code},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
|
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,31 @@ _TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
|
||||||
# `id:value` в логах, напр. `chat_id:12345`).
|
# `id:value` в логах, напр. `chat_id:12345`).
|
||||||
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
|
||||||
|
|
||||||
|
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
|
||||||
|
# mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py
|
||||||
|
# rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`).
|
||||||
|
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
|
||||||
|
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
|
||||||
|
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
|
||||||
|
# сам вырезал бы query-параметры) и кладёт полный URL в span `data["url"]` —
|
||||||
|
# сейчас неактивно (`traces_sample_rate=0.0` в app/main.py/scheduler_main.py →
|
||||||
|
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
|
||||||
|
# когда-нибудь включат.
|
||||||
|
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
|
||||||
|
# от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`,
|
||||||
|
# `exc` в rotate_proxy_ip) в traceback открытым текстом.
|
||||||
|
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
|
||||||
|
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
|
||||||
|
# завязано на конкретного провайдера — покрывает любой query-параметр из
|
||||||
|
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
|
||||||
|
# access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально
|
||||||
|
# другой провайдер, другое имя параметра).
|
||||||
|
_URL_SECRET_QUERY_RE = re.compile(
|
||||||
|
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
|
||||||
|
r"access[_-]?token|auth)=)[^&\s\"'<>]+"
|
||||||
|
)
|
||||||
|
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
|
||||||
|
|
||||||
|
|
||||||
def _scrub(obj: Any) -> None:
|
def _scrub(obj: Any) -> None:
|
||||||
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
|
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
|
||||||
|
|
@ -61,8 +86,50 @@ def _scrub(obj: Any) -> None:
|
||||||
_scrub(item)
|
_scrub(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_url_secrets_inplace(obj: Any) -> None:
|
||||||
|
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
|
||||||
|
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
|
||||||
|
КАЖДОЙ строке event — не ключ-based: секрет утекает через httpx span
|
||||||
|
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
|
||||||
|
request URL), а не только через известные PII-поля формы. Мутирует dict/list
|
||||||
|
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`) —
|
||||||
|
сохраняет identity верхнеуровневого `event`, на что опирается контракт
|
||||||
|
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
|
||||||
|
"""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for key, value in obj.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||||
|
if redacted != value:
|
||||||
|
obj[key] = redacted
|
||||||
|
else:
|
||||||
|
_redact_url_secrets_inplace(value)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for i, value in enumerate(obj):
|
||||||
|
if isinstance(value, str):
|
||||||
|
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
|
||||||
|
if redacted != value:
|
||||||
|
obj[i] = redacted
|
||||||
|
else:
|
||||||
|
_redact_url_secrets_inplace(value)
|
||||||
|
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
|
||||||
|
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
|
||||||
|
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
|
||||||
|
# выбор для dict/list).
|
||||||
|
|
||||||
|
|
||||||
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||||
"""Redact consumer-PII из error event перед отправкой. Возвращает event (не None)."""
|
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
|
||||||
|
|
||||||
|
Композиция (обе — in-place, сохраняют identity `event`): (1) ключ-based
|
||||||
|
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
|
||||||
|
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
|
||||||
|
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
|
||||||
|
changeip — #security-audit). Второй шаг не завязан на конкретные ключи полей —
|
||||||
|
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
|
||||||
|
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
|
||||||
|
event (не None).
|
||||||
|
"""
|
||||||
if not isinstance(event, dict):
|
if not isinstance(event, dict):
|
||||||
return event
|
return event
|
||||||
request = event.get("request")
|
request = event.get("request")
|
||||||
|
|
@ -70,6 +137,7 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||||
_scrub(request.get("data"))
|
_scrub(request.get("data"))
|
||||||
_scrub(event.get("extra"))
|
_scrub(event.get("extra"))
|
||||||
_scrub(event.get("contexts"))
|
_scrub(event.get("contexts"))
|
||||||
|
_redact_url_secrets_inplace(event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Response
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.core.request_audit import RequestAuditMiddleware
|
from app.core.request_audit import RequestAuditMiddleware
|
||||||
|
|
@ -138,3 +138,152 @@ def test_admin_path_excluded_from_api_request_but_login_kept() -> None:
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
||||||
assert event_types == ["login"]
|
assert event_types == ["login"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin audit (security-audit fix): mutating /admin/* -> admin_action ────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_mutating_post_schedules_admin_action_with_attribution() -> None:
|
||||||
|
"""POST на /api/v1/admin/* (напр. правка прокси / настройки скрапера) должен
|
||||||
|
писать `admin_action` с атрибуцией (кто), а НЕ игнорироваться целиком, как
|
||||||
|
раньше (security-audit: не было возможности установить, кто это сделал)."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.post("/api/v1/admin/scraper/avito/rotate-ip")
|
||||||
|
def rotate_ip() -> dict[str, bool]:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||||
|
):
|
||||||
|
resp = TestClient(app).post(
|
||||||
|
"/api/v1/admin/scraper/avito/rotate-ip",
|
||||||
|
headers={"X-Authenticated-User": "admin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert mock_schedule.call_count == 1
|
||||||
|
kwargs = mock_schedule.call_args.kwargs
|
||||||
|
assert kwargs["event_type"] == "admin_action"
|
||||||
|
assert kwargs["username"] == "admin"
|
||||||
|
assert kwargs["path"] == "/api/v1/admin/scraper/avito/rotate-ip"
|
||||||
|
assert kwargs["method"] == "POST"
|
||||||
|
assert kwargs["payload"] == {"status_code": 200, "success": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_get_does_not_schedule_admin_action() -> None:
|
||||||
|
"""GET на /admin/* (просмотр дашборда) НЕ должен писать admin_action — только
|
||||||
|
мутирующие методы считаются "действием"."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.get("/api/v1/admin/scraper/health")
|
||||||
|
def health() -> dict[str, bool]:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||||
|
):
|
||||||
|
TestClient(app).get(
|
||||||
|
"/api/v1/admin/scraper/health", headers={"X-Authenticated-User": "admin"}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_schedule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_action_payload_excludes_request_body() -> None:
|
||||||
|
"""security-audit: тело запроса (куки/пароли/секреты правки прокси) НЕ должно
|
||||||
|
попадать в audit-payload — только факт действия + атрибуция."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.post("/api/v1/admin/scraper/cookies")
|
||||||
|
def upload_cookies() -> dict[str, bool]:
|
||||||
|
# Хендлер намеренно НЕ объявляет body-параметр — middleware проверяет
|
||||||
|
# только headers/path/method/status, JSON-тело запроса ниже (куки) в
|
||||||
|
# audit-payload попасть не может структурно, не только "по договорённости".
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||||
|
):
|
||||||
|
TestClient(app).post(
|
||||||
|
"/api/v1/admin/scraper/cookies",
|
||||||
|
headers={"X-Authenticated-User": "admin"},
|
||||||
|
json={"cookies": "super-secret-session-cookie"},
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = mock_schedule.call_args.kwargs
|
||||||
|
assert kwargs["event_type"] == "admin_action"
|
||||||
|
assert "super-secret-session-cookie" not in repr(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_mutating_failure_status_recorded_in_payload() -> None:
|
||||||
|
"""admin_action на неуспешный ответ (напр. 500 от нижестоящего сервиса) должен
|
||||||
|
нести success=False + реальный status_code — не маскироваться под успех."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.post("/api/v1/admin/scraper/pacing")
|
||||||
|
def pacing() -> Response:
|
||||||
|
return Response(status_code=502)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||||
|
):
|
||||||
|
TestClient(app).post(
|
||||||
|
"/api/v1/admin/scraper/pacing", headers={"X-Authenticated-User": "admin"}
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = mock_schedule.call_args.kwargs
|
||||||
|
assert kwargs["event_type"] == "admin_action"
|
||||||
|
assert kwargs["payload"] == {"status_code": 502, "success": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ── login vs login_failed (security-audit fix) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_event_type_when_request_succeeds(client: TestClient) -> None:
|
||||||
|
"""Ответ < 400 -> event_type='login' (успешный вход/активность), payload несёт
|
||||||
|
status_code."""
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=True),
|
||||||
|
):
|
||||||
|
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
login_calls = [c for c in mock_schedule.call_args_list if c.kwargs["event_type"] == "login"]
|
||||||
|
assert len(login_calls) == 1
|
||||||
|
assert login_calls[0].kwargs["payload"] == {"status_code": 200}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_failed_event_type_when_rbac_rejects_request() -> None:
|
||||||
|
"""Ответ >= 400 (напр. RBAC-отказ downstream: неизвестная роль / протухший
|
||||||
|
внутренний секрет) -> event_type='login_failed', а НЕ 'login' — раньше эти
|
||||||
|
два случая были неразличимы в журнале (security-audit)."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(RequestAuditMiddleware)
|
||||||
|
|
||||||
|
@app.get("/api/v1/ping")
|
||||||
|
def ping() -> Response:
|
||||||
|
return Response(status_code=403, content="forbidden")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||||
|
patch("app.core.request_audit.should_log_login", return_value=True),
|
||||||
|
):
|
||||||
|
TestClient(app).get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||||
|
|
||||||
|
login_calls = [
|
||||||
|
c
|
||||||
|
for c in mock_schedule.call_args_list
|
||||||
|
if c.kwargs["event_type"] in ("login", "login_failed")
|
||||||
|
]
|
||||||
|
assert len(login_calls) == 1
|
||||||
|
assert login_calls[0].kwargs["event_type"] == "login_failed"
|
||||||
|
assert login_calls[0].kwargs["payload"] == {"status_code": 403}
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,44 @@ def test_rotate_ip_changeip_error(client: TestClient) -> None:
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["ok"] is False
|
assert body["ok"] is False
|
||||||
assert "changeip error" in body["reason"]
|
# security-audit: нейтральный reason, БЕЗ текста исходного исключения
|
||||||
|
# (str(exc) httpx мог нести rotate_url с proxy-ключом в query — см. тест ниже).
|
||||||
|
assert body["reason"] == "changeip request failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_ip_changeip_error_does_not_leak_proxy_key(client: TestClient) -> None:
|
||||||
|
"""security-audit: секретный API-ключ провайдера в rotate_url НЕ должен попасть
|
||||||
|
в HTTP-ответ клиенту через текст httpx-исключения (раньше
|
||||||
|
`reason=f"changeip error: {exc}"` отдавал str(exc) с полным URL, включая
|
||||||
|
query-параметр ключа, наружу)."""
|
||||||
|
from app.api.v1 import admin as admin_module
|
||||||
|
|
||||||
|
secret_url = "http://ch/changeip?proxy_key=TOP-SECRET-KEY-1234"
|
||||||
|
|
||||||
|
class _BoomClient:
|
||||||
|
def __init__(self, *a: Any, **k: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _BoomClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get(self, *a: Any, **k: Any) -> Any:
|
||||||
|
raise RuntimeError(f"All connection attempts failed for {secret_url}&format=json")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(admin_module.httpx, "AsyncClient", _BoomClient),
|
||||||
|
patch.object(admin_module.settings, "avito_proxy_rotate_url", secret_url),
|
||||||
|
):
|
||||||
|
r = client.post("/api/v1/admin/scraper/avito/rotate-ip")
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "TOP-SECRET-KEY-1234" not in r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is False
|
||||||
|
assert "TOP-SECRET-KEY-1234" not in (body["reason"] or "")
|
||||||
|
|
||||||
|
|
||||||
# ── API 4: GET /scraper/pacing ───────────────────────────────────────────────
|
# ── API 4: GET /scraper/pacing ───────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
- телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening)
|
- телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening)
|
||||||
- source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы)
|
- source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы)
|
||||||
- estimate_id, которого нет в trade_in_estimates -> 404
|
- estimate_id, которого нет в trade_in_estimates -> 404
|
||||||
|
- IDOR guard (security-audit, зеркалит #690/test_estimate_idor.py): estimate_id
|
||||||
|
чужого пользователя -> 404; admin может привязать любой; нет
|
||||||
|
X-Authenticated-User -> 401; неизвестная роль -> 403
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -16,6 +19,7 @@ import os
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
@ -45,6 +49,16 @@ def client(db: MagicMock) -> TestClient:
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_get_role():
|
||||||
|
"""Restore app.core.auth.get_role after each test (mirror test_estimate_idor.py)."""
|
||||||
|
from app.core import auth as auth_mod
|
||||||
|
|
||||||
|
original = auth_mod.get_role
|
||||||
|
yield
|
||||||
|
auth_mod.get_role = original
|
||||||
|
|
||||||
|
|
||||||
def _insert_result(lead_id: str) -> MagicMock:
|
def _insert_result(lead_id: str) -> MagicMock:
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.mappings.return_value.one.return_value = {
|
result.mappings.return_value.one.return_value = {
|
||||||
|
|
@ -135,9 +149,14 @@ def test_lead_with_unknown_estimate_id_404(client: TestClient, db: MagicMock) ->
|
||||||
|
|
||||||
|
|
||||||
def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> None:
|
def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> None:
|
||||||
|
"""Owner привязывает лид к своей же оценке -> 200."""
|
||||||
|
from app.core import auth as auth_mod
|
||||||
|
|
||||||
|
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
|
||||||
|
|
||||||
lead_id = str(uuid4())
|
lead_id = str(uuid4())
|
||||||
exists_result = MagicMock()
|
exists_result = MagicMock()
|
||||||
exists_result.fetchone.return_value = (1,)
|
exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
|
||||||
db.execute.side_effect = [exists_result, _insert_result(lead_id)]
|
db.execute.side_effect = [exists_result, _insert_result(lead_id)]
|
||||||
|
|
||||||
estimate_id = str(uuid4())
|
estimate_id = str(uuid4())
|
||||||
|
|
@ -148,6 +167,7 @@ def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> N
|
||||||
"consent": True,
|
"consent": True,
|
||||||
"estimate_id": estimate_id,
|
"estimate_id": estimate_id,
|
||||||
},
|
},
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
params = db.execute.call_args.args[1]
|
params = db.execute.call_args.args[1]
|
||||||
|
|
@ -155,6 +175,86 @@ def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> N
|
||||||
assert params["source"] == "result"
|
assert params["source"] == "result"
|
||||||
|
|
||||||
|
|
||||||
|
# ── IDOR guard (security-audit): estimate_id ownership ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_estimate_id_owned_by_other_user_gets_404(client: TestClient, db: MagicMock) -> None:
|
||||||
|
"""Чужой estimate_id -> 404 (существование не подтверждаем), лид НЕ создаётся."""
|
||||||
|
from app.core import auth as auth_mod
|
||||||
|
|
||||||
|
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
|
||||||
|
|
||||||
|
exists_result = MagicMock()
|
||||||
|
exists_result.fetchone.return_value = SimpleNamespace(created_by="victim")
|
||||||
|
db.execute.return_value = exists_result
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
|
||||||
|
headers={"X-Authenticated-User": "attacker"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
assert not db.commit.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_estimate_id_admin_can_attach_any_200(client: TestClient, db: MagicMock) -> None:
|
||||||
|
"""Admin может привязать лид к чужой оценке (owner-or-admin, зеркалит #690)."""
|
||||||
|
from app.core import auth as auth_mod
|
||||||
|
|
||||||
|
auth_mod.get_role = lambda _u: "admin" # type: ignore[assignment]
|
||||||
|
|
||||||
|
lead_id = str(uuid4())
|
||||||
|
exists_result = MagicMock()
|
||||||
|
exists_result.fetchone.return_value = SimpleNamespace(created_by="someone_else")
|
||||||
|
db.execute.side_effect = [exists_result, _insert_result(lead_id)]
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
|
||||||
|
headers={"X-Authenticated-User": "admin"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_estimate_id_requires_authenticated_user_401(
|
||||||
|
client: TestClient, db: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""estimate_id задан, но нет X-Authenticated-User -> 401 (defense-in-depth: в
|
||||||
|
проде rbac_guard уже требует заголовок раньше, см. app/main.py)."""
|
||||||
|
exists_result = MagicMock()
|
||||||
|
exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
|
||||||
|
db.execute.return_value = exists_result
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401, r.text
|
||||||
|
assert not db.commit.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_estimate_id_unknown_role_403(client: TestClient, db: MagicMock) -> None:
|
||||||
|
"""Аутентифицирован через Caddy, но роль отсутствует в roles.yaml -> 403."""
|
||||||
|
from app.core import auth as auth_mod
|
||||||
|
|
||||||
|
def _raise_keyerror(_u: str):
|
||||||
|
raise KeyError(_u)
|
||||||
|
|
||||||
|
auth_mod.get_role = _raise_keyerror # type: ignore[assignment]
|
||||||
|
|
||||||
|
exists_result = MagicMock()
|
||||||
|
exists_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
|
||||||
|
db.execute.return_value = exists_result
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/lead",
|
||||||
|
json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())},
|
||||||
|
headers={"X-Authenticated-User": "ghost"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
assert not db.commit.called
|
||||||
|
|
||||||
|
|
||||||
def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None:
|
def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None:
|
||||||
# "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы),
|
# "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы),
|
||||||
# но содержит 0 цифр -> должно отклоняться digit-валидатором.
|
# но содержит 0 цифр -> должно отклоняться digit-валидатором.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue