# Спецификация: go-live аудит безопасности — инварианты с доказательной базой

## Цель

Документировать результаты доменного аудита безопасности портала перед публикацией
в интернет (threat-model + pdn-152fz + billing) и дать критерий приёмки отчёта.
Scope аудита — **документирование находок с цитированием кода-доказательства и
рекомендациями**; реализация исправлений — отдельный эпик, в этот аудит не входит.
Тела ключевых файлов процитированы прямо в разделах ниже, чтобы каждый инвариант был
проверяем из самого документа.

## Авторизация чувствительных маршрутов {#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, `'phones' => $phones`) и **переживают** erasure.

Уточнение по актуальности кода: фича lead-region (`lead_region_resolution_log` + 4
региональные колонки, миграция `2026_05_31_100000`) в **этой рабочей копии отсутствует**
(проверено: 0 совпадений `lead_region_resolution|phone_ranges|region_source` в `app/`),
поэтому соответствующий прог F-P1 к текущему коду неприменим — станет актуальным при
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`:

```sql
CREATE INDEX lead_charges_deal_id_deal_received_at_index
    ON lead_charges(deal_id, deal_received_at);   -- не UNIQUE
```

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

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

Отчёт оформляется по шаблону: одна строка на находку, файл+строка, приоритет (P1/P2/P3),
статус (закрыт / остаточный риск / открыт), рекомендация; в конце — вердикт GO/NO-GO с
перечнем блокеров. Открытые вопросы проекта (Б-1/SEC/CTO/Биз/Диз/DO) не помечаются «закрыт».

```
| ID   | Инвариант | Файл:строка                                   | Приоритет | Статус          | Рекомендация            |
|------|-----------|-----------------------------------------------|-----------|-----------------|-------------------------|
| F-T2 | D1        | EnsureSaasAdmin.php:34                         | P1        | открыт          | вернуть app-гейт + сессия |
| 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  |

Вердикт go-live: NO-GO. Блокеры: F-T2, F-P1.
```

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

```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"}
]
```
