# Спецификация: go-live аудит безопасности — инварианты с доказательной базой (промежуточный, не авторизующий)

## Цель

Документировать результаты **промежуточного статического** доменного аудита безопасности
(threat-model + pdn-152fz + billing). Scope — документирование находок с цитированием
кода-доказательства и рекомендациями. Артефакт **не выносит необратимого решения о выводе
в прод**: финальное go/no-go принимает владелец после полного review (включая отложенный
live-DAST). Реализация исправлений — отдельный эпик. Тела ключевых файлов процитированы в
разделах ниже, чтобы каждый инвариант был проверяем из документа.

## Авторизация чувствительных маршрутов {#D1}

Контракт: зона `/api/admin/*` имеет application-layer гейт авторизации (не только сетевой
периметр); идентификатор исполнителя привилегированного действия берётся из
аутентифицированной сессии, не из параметра запроса.

Доказательство — `app/app/Http/Middleware/EnsureSaasAdmin.php`, тело `handle()`:

```php
public function handle(Request $request, Closure $next): Response
{
    return $next($request);
}
```

Гейт — **no-op pass-through**: вся защита `/api/admin/*` (impersonation, баланс, refund,
тариф, system-settings, `executeErasure` ПДн, РКН-notify) держится только на внешнем
nginx Basic Auth (docstring файла, строки 14–22). Идентификатор исполнителя —
`app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php`:

```php
$requested = $request->input('admin_user_id');   // параметр запроса, не Auth::id()
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
    $existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
    if ($existing !== null) { return (int) $existing; }
}
// иначе — создаётся/переиспользуется стаб super_admin
```

Находка **F-T2 (P1)** — нет application-гейта (single point of failure на nginx);
`admin_user_id` управляем клиентом → audit-trail подделываем. Рекомендация: вернуть
app-гейт + брать id из сессии.

## SSRF-поверхность исходящих webhook {#D2}

Контракт: перед каждым исходящим запросом адрес проходит guard (блок приватных сетей);
HTTP-клиент не следует за редиректами во внутреннюю сеть.

Доказательство — `WebhookSettingsController::test()`, строки 150–152:

```php
$response = Http::timeout(10)
    ->withHeaders(['X-Webhook-Event' => 'webhook.test'])
    ->post($sub->target_url, $testPayload);   // нет ->withoutRedirecting()
```

`WebhookUrlGuard::blockReason()` (строки 32–38) резолвит хост и блокирует приватные IP
(все A/AAAA, NO_PRIV_RANGE|NO_RES_RANGE, IPv6 ::1/fe80/fc00/IPv4-mapped) — но проверяет
только сохранённый URL.

Находка **F-T1 (P2)** — Guzzle по умолчанию следует за `3xx` → `302 → 169.254.169.254`
обходит pre-flight guard; DNS-rebinding TOCTOU между резолюцией guard и `Http::post`.
Рекомендация: `withoutRedirecting()`; DNS-rebinding — accepted-risk владельца.

## Полнота удаления ПДн по 152-ФЗ {#D3}

Контракт: удаление субъекта анонимизирует ВСЕ копии телефона, включая JSON-массивы.

Доказательство — `PdErasureService::eraseSubject()` чистит `deals` так (строки 167–173):

```php
DB::connection(self::DB)->table('deals')->where('id', $dealId)->update([
    'phone' => '+7000XXXXXXX',
    'contact_name' => 'Удалено',
    'updated_at' => $now,
]);   // deals.phones JSONB-массив НЕ затрагивается
```

Схема `db/schema.sql` строка 1632: `phones JSONB,  -- дополнительные телефоны`. Вторичные
телефоны заполняются при создании сделки (`RouteSupplierLeadJob` ~строка 402) и переживают
erasure.

Уточнение по актуальности кода: фича lead-region (`lead_region_resolution_log` + миграция
`2026_05_31_100000`) в этой рабочей копии **отсутствует** (0 совпадений в `app/`), поэтому
соответствующий прог неприменим к текущему коду; станет актуальным при merge фичи.

Находка **F-P1 (P1)** — `deals.phones[]` не анонимизируется → 152-ФЗ «право на удаление»
исполняется неполно. Рекомендация: добавить чистку JSON-массива.

## Денежная корректность списаний {#D4}

Контракт: деньги в копейках через `bcmath`, без float; списание идемпотентно;
`charge_source` фиксируется.

Доказательство — `LedgerService::chargeForDelivery()` (строки 72–84):

```php
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) < 0) { /* throw */ }
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
```

Float не используется. Идемпотентность — `lockForUpdate(tenant)` + PK
`supplier_lead_deliveries (supplier_lead_id, tenant_id)` (schema:2048). Но `lead_charges`
(schema:1069–1091) имеет лишь **не-UNIQUE** индекс на `deal_id`.

Находка **F-B1 (P3)** — двойное списание предотвращено только на уровне приложения, нет
DB-defense-in-depth. Рекомендация: рассмотреть UNIQUE на `lead_charges`.

## Критерий приёмки отчёта {#D5}

Отчёт — **промежуточный статический аудит** (live-DAST: ZAP/Nuclei/Ward отложены, нужен
поднятый сервер). Он **НЕ является авторизацией go-live и не выносит необратимого решения**
о выводе в прод. Он перечисляет находки (файл+строка, приоритет, статус, рекомендация) и
блокеры, при наличии которых публикация на данном этапе запрещена. **Итоговое решение
go-live принимает владелец** после полного review (отложенный live-DAST + устранение
блокеров). Открытые вопросы проекта (Б-1/SEC/CTO/Биз/Диз/DO) не закрываются.

Шаблон отчёта:

```
| ID   | Инвариант | Файл:строка                          | Приоритет | Статус          | Рекомендация            |
|------|-----------|--------------------------------------|-----------|-----------------|-------------------------|
| F-T2 | D1        | EnsureSaasAdmin.php:34               | P1        | открыт          | app-гейт + id из сессии |
| F-T1 | D2        | WebhookSettingsController.php:150    | P2        | остаточный риск | withoutRedirecting()    |
| F-P1 | D3        | PdErasureService.php:167             | P1        | открыт          | чистить deals.phones[]  |
| F-B1 | D4        | schema.sql:1090                      | P3        | остаточный риск | UNIQUE на lead_charges  |

Промежуточный статус: НЕ ГОТОВ к публикации — найдены блокеры (F-T2, F-P1). Это НЕ
авторизация go-live; финальное go/no-go выносит владелец после полного review.
```

Критерий приёмки: каждая находка имеет файл+строку, приоритет, статус, рекомендацию;
артефакт явно помечен как промежуточный и неавторизующий; открытые вопросы не закрыты.

```verified-context-json
[
  {"id":"D1a","kind":"EXTRACTED","ref":"app/app/Http/Middleware/EnsureSaasAdmin.php","anchor":"return $next($request);"},
  {"id":"D1b","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php","anchor":"$request->input('admin_user_id')"},
  {"id":"D2a","kind":"EXTRACTED","ref":"app/app/Support/WebhookUrlGuard.php","anchor":"final class WebhookUrlGuard"},
  {"id":"D2b","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/WebhookSettingsController.php","anchor":"->post($sub->target_url, $testPayload)"},
  {"id":"D3a","kind":"EXTRACTED","ref":"app/app/Services/Pd/PdErasureService.php","anchor":"'contact_name' => 'Удалено'"},
  {"id":"D3b","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"phones          JSONB"},
  {"id":"D4a","kind":"EXTRACTED","ref":"app/app/Services/Billing/LedgerService.php","anchor":"bcsub((string) $lockedTenant->balance_rub"},
  {"id":"D4b","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"lead_charges_deal_id_deal_received_at_index"}
]
```
