fix(tradein/proxy): pin ASocks rotate_url host — не слать токен на чужой прокси (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер — security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only, хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый запрос без Authorization (смысл ручной ротации — конкретный провайдер). Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry сообщений (не только reason/note).
This commit is contained in:
parent
b1563b86cb
commit
bed2b7bca9
2 changed files with 187 additions and 7 deletions
|
|
@ -41,6 +41,21 @@ str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~
|
||||||
httpx-исключения несут полный request URL/детали, поэтому наружу — только
|
httpx-исключения несут полный request URL/детали, поэтому наружу — только
|
||||||
нейтральный reason, полные детали — в лог с exc_info=True.
|
нейтральный reason, полные детали — в лог с exc_info=True.
|
||||||
|
|
||||||
|
⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
|
||||||
|
НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
|
||||||
|
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
|
||||||
|
см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks.
|
||||||
|
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
|
||||||
|
на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно,
|
||||||
|
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
|
||||||
|
успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет
|
||||||
|
urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) — несовпадение
|
||||||
|
это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без
|
||||||
|
Authorization: смысл ручной ротации — конкретный провайдер (ASocks), молчаливый
|
||||||
|
вызов чужой ручки без авторизации — это сюрприз оператору (он думает "ASocks
|
||||||
|
ротировал", а фактически задел mobileproxy), которого проще не допустить, чем
|
||||||
|
потом объяснять админу расхождение счётчиков.
|
||||||
|
|
||||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -49,6 +64,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -59,6 +75,7 @@ from app.core.config import settings
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ALLOWED_ROTATE_HOST",
|
||||||
"DAILY_ROTATION_LIMIT",
|
"DAILY_ROTATION_LIMIT",
|
||||||
"RotationResult",
|
"RotationResult",
|
||||||
"rotate_proxy",
|
"rotate_proxy",
|
||||||
|
|
@ -70,6 +87,21 @@ DAILY_ROTATION_LIMIT = 3
|
||||||
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
|
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
|
||||||
_ROTATE_TIMEOUT_S = 30.0
|
_ROTATE_TIMEOUT_S = 30.0
|
||||||
|
|
||||||
|
# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке
|
||||||
|
# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может
|
||||||
|
# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
|
||||||
|
ALLOWED_ROTATE_HOST = "api.asocks.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_rotate_url(url: str) -> bool:
|
||||||
|
"""https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо —
|
||||||
|
urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RotationResult:
|
class RotationResult:
|
||||||
|
|
@ -194,11 +226,14 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
||||||
Порядок:
|
Порядок:
|
||||||
1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный.
|
1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный.
|
||||||
2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка).
|
2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка).
|
||||||
3. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False,
|
3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) → ok=False
|
||||||
|
ДО HTTP-вызова — токен не должен уйти на чужой провайдер (mobileproxy
|
||||||
|
changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле).
|
||||||
|
4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False,
|
||||||
внятный отказ, ничего не ломается.
|
внятный отказ, ничего не ломается.
|
||||||
4. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ
|
5. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ
|
||||||
обращения к API.
|
обращения к API.
|
||||||
5. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
|
6. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
|
||||||
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
||||||
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
||||||
- 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
|
- 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
|
||||||
|
|
@ -229,6 +264,24 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
||||||
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
|
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not _is_allowed_rotate_url(rotate_url):
|
||||||
|
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
|
||||||
|
# mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш
|
||||||
|
# Authorization: Bearer <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
|
||||||
|
# Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он
|
||||||
|
# несёт их собственный секрет в query-string, тот же класс утечки, что
|
||||||
|
# и в rotate_proxy_ip, см. модуль docstring).
|
||||||
|
logger.warning(
|
||||||
|
"proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host "
|
||||||
|
"(%s) — refusing before any HTTP call to avoid leaking the token to it",
|
||||||
|
proxy_id,
|
||||||
|
urlparse(rotate_url).hostname,
|
||||||
|
ALLOWED_ROTATE_HOST,
|
||||||
|
)
|
||||||
|
return RotationResult(
|
||||||
|
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
|
||||||
|
)
|
||||||
|
|
||||||
token = settings.asocks_api_token
|
token = settings.asocks_api_token
|
||||||
if not token:
|
if not token:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -254,15 +307,21 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
|
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
|
||||||
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
|
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
|
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
|
||||||
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
|
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
|
||||||
# служебные детали соединения) — только exc_info=True в лог.
|
# служебные детали соединения) — только exc_info=True в лог. type(exc).__name__
|
||||||
|
# секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note
|
||||||
|
# ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли".
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True
|
"proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True
|
||||||
)
|
)
|
||||||
_record_attempt(
|
_record_attempt(
|
||||||
db, proxy_id, success=False, http_status=None, note="request failed (no response)"
|
db,
|
||||||
|
proxy_id,
|
||||||
|
success=False,
|
||||||
|
http_status=None,
|
||||||
|
note=f"request failed: {type(exc).__name__}",
|
||||||
)
|
)
|
||||||
return RotationResult(
|
return RotationResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,12 @@ FakeSession эмулирует scrape_proxies (одна строка) + scrape_p
|
||||||
- Успешная ротация пишет запись в scrape_proxy_rotations (success=True).
|
- Успешная ротация пишет запись в scrape_proxy_rotations (success=True).
|
||||||
- 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг),
|
- 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг),
|
||||||
аудит-запись пишется, но НЕ считается против суточного лимита.
|
аудит-запись пишется, но НЕ считается против суточного лимита.
|
||||||
- Токен не появляется ни в RotationResult.reason, ни в note аудит-записи —
|
- Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в
|
||||||
|
тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry —
|
||||||
ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success).
|
ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success).
|
||||||
|
- rotate_url на ЧУЖОМ хосте (не ALLOWED_ROTATE_HOST) → отказ ДО HTTP-вызова —
|
||||||
|
scrape_proxies.rotate_url колонка неоднородна (несёт и mobileproxy changeip-
|
||||||
|
ссылки), наш ASOCKS_API_TOKEN не должен уйти на них (security review PR #2611).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -167,6 +171,10 @@ def _no_http_allowed():
|
||||||
|
|
||||||
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
|
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
|
||||||
|
|
||||||
|
# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один
|
||||||
|
# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт).
|
||||||
|
_LogScenario = tuple[str, str | None, tuple[int, dict[str, Any] | None] | None, Exception | None]
|
||||||
|
|
||||||
|
|
||||||
def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]:
|
def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]:
|
||||||
return {"id": 1, "rotate_url": rotate_url}
|
return {"id": 1, "rotate_url": rotate_url}
|
||||||
|
|
@ -202,6 +210,64 @@ async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
assert db.rotations == [] # ничего не писалось — попытки не было
|
assert db.rotations == [] # ничего не писалось — попытки не было
|
||||||
|
|
||||||
|
|
||||||
|
# ── host pinning (security review PR #2611) ─────────────────────────────────
|
||||||
|
#
|
||||||
|
# scrape_proxies.rotate_url колонка неоднородна: прод сейчас несёт mobileproxy
|
||||||
|
# changeip-ссылки (id 3/4/5) БОК О БОК с ASocks-ссылками (id 1/9/10/11, миграция
|
||||||
|
# 199). Без host-пиннинга наш Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы
|
||||||
|
# на чужой провайдер.
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rotate_url_on_foreign_host_refused_before_http_call(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
|
foreign_url = "https://changeip.mobileproxy.space/?proxy_key=mobileproxy-own-secret"
|
||||||
|
db = FakeSession(_proxy_row(rotate_url=foreign_url))
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert result.reason is not None
|
||||||
|
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
|
||||||
|
# if the code had tried an HTTP call (i.e. sent our token) — reaching this
|
||||||
|
# line means it refused first. Belt-and-suspenders: no audit row either
|
||||||
|
# (this is a local rejection, same as no-rotate_url/no-token/limit).
|
||||||
|
assert db.rotations == []
|
||||||
|
assert SECRET_TOKEN not in result.reason
|
||||||
|
|
||||||
|
|
||||||
|
async def test_allowed_host_case_insensitive_still_proceeds(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Хост сверяется без учёта регистра (urlparse().hostname лоуеркейзит) — тот
|
||||||
|
же ALLOWED_ROTATE_HOST в другом регистре ДОЛЖЕН проходить, иначе пиннинг
|
||||||
|
превратился бы в ложный отказ на легитимном rotate_url."""
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
fake_client, calls = _fake_async_client(response=(200, {"ip": "1.2.3.4"}), exception=None)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row(rotate_url="https://API.ASOCKS.COM/unlimited-proxy/1/refresh-ip"))
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_allowed_host_over_plain_http_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""http:// (не https://) на тот же хост — отказ (защита от даунгрейда
|
||||||
|
транспорта, которым Authorization ушёл бы в открытом виде)."""
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row(rotate_url="http://api.asocks.com/unlimited-proxy/1/refresh-ip"))
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert db.rotations == []
|
||||||
|
|
||||||
|
|
||||||
# ── missing token → neutral refusal, no crash ───────────────────────────────
|
# ── missing token → neutral refusal, no crash ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -354,6 +420,9 @@ async def test_token_never_appears_in_reason_on_network_error(
|
||||||
# сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится
|
# сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится
|
||||||
assert db.rotations[0]["http_status"] is None
|
assert db.rotations[0]["http_status"] is None
|
||||||
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
|
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
|
||||||
|
# exception class name (не секрет) в note — оператор отличит "не дозвонились"
|
||||||
|
# (ConnectError) от "дозвонились, зависли" (ReadTimeout).
|
||||||
|
assert "ConnectError" in (db.rotations[0]["note"] or "")
|
||||||
|
|
||||||
|
|
||||||
async def test_token_never_appears_on_provider_error_status(
|
async def test_token_never_appears_on_provider_error_status(
|
||||||
|
|
@ -375,6 +444,58 @@ async def test_token_never_appears_on_provider_error_status(
|
||||||
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
|
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_never_appears_in_log_messages_or_sentry_text(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Расширенное leak-покрытие (security review PR #2611): предыдущие тесты
|
||||||
|
проверяли только reason/note. Здесь — текст, реально уходящий в logging и в
|
||||||
|
Sentry (не exc_info-traceback, который по дизайну МОЖЕТ нести детали
|
||||||
|
исключения — см. модуль docstring; это осознанно разрешённое место).
|
||||||
|
caplog.records[i].getMessage() возвращает форматированный msg %% args, БЕЗ
|
||||||
|
exc_text — то есть эта проверка ловит именно "секрет попал в аргумент
|
||||||
|
logger.*()", а не в traceback.
|
||||||
|
"""
|
||||||
|
sentry_texts: list[str] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sentry_sdk.capture_message",
|
||||||
|
lambda msg, level=None: sentry_texts.append(msg),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
|
||||||
|
scenarios: list[_LogScenario] = [
|
||||||
|
("success", _DEFAULT_ROTATE_URL, (200, {"ip": "1.1.1.1"}), None),
|
||||||
|
("401", _DEFAULT_ROTATE_URL, (401, {"message": "Unauthenticated"}), None),
|
||||||
|
("provider_500", _DEFAULT_ROTATE_URL, (500, {"message": "err"}), None),
|
||||||
|
(
|
||||||
|
"network_error",
|
||||||
|
_DEFAULT_ROTATE_URL,
|
||||||
|
None,
|
||||||
|
httpx.ConnectError(f"boom token={SECRET_TOKEN}"),
|
||||||
|
),
|
||||||
|
("foreign_host", "https://changeip.mobileproxy.space/?proxy_key=x", None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, rotate_url, response, exception in scenarios:
|
||||||
|
if response is not None or exception is not None:
|
||||||
|
fake_client, _ = _fake_async_client(response=response, exception=exception)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
else:
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row(rotate_url=rotate_url))
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
caplog.clear()
|
||||||
|
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
for record in caplog.records:
|
||||||
|
assert (
|
||||||
|
SECRET_TOKEN not in record.getMessage()
|
||||||
|
), f"scenario={name}: token leaked into log message args"
|
||||||
|
|
||||||
|
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
|
||||||
|
assert all(SECRET_TOKEN not in text for text in sentry_texts)
|
||||||
|
|
||||||
|
|
||||||
# ── proxy not found ──────────────────────────────────────────────────────────
|
# ── proxy not found ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue