# Доменный go-live аудит безопасности Лидерры — авторизованный отчёт (промежуточный, не авторизующий go-live)

**Дата:** 15.06.2026. **Метод:** статический доменный аудит через 4 скила раздела A8
(`threat-model` → `pdn-152fz-audit` → `billing-audit` → `security-go-live`), прогнаны под
церемонией стены «роутер-наставник» с per-action escape-грантами владельца. Доказательная база
— дословные выводы `grep` по рабочей копии `app/` + `db/schema.sql`.

> **Статус: NO-GO (промежуточный).** Найдены блокеры. Это **НЕ авторизация go-live** и **не
> выносит необратимого решения** о публикации. Live-DAST (ZAP/Nuclei/Ward) отложен (нужен
> поднятый сервер). Финальное go/no-go выносит владелец после полного review (live-DAST +
> устранение блокеров). Открытые вопросы проекта (Б-1/SEC/CTO/Биз/Диз/DO) **не закрываются**.

**Решение владельца по блокерам (owner-gate):** **accepted-risk (interim)** — владелец осознанно
принимает риск F-T2 / F-P1 / F-T1 на промежуточном этапе; устранение — до публичного go-live.
Настоящий отчёт go-live не авторизует.

---

## Сводная таблица находок (схема D5)

| ID | Инвариант | Файл:строка | Приоритет | Статус | Рекомендация |
|------|-----------|--------------------------------------|-----------|-----------------|-------------------------|
| F-T2 | D1 | EnsureSaasAdmin.php:34 | P1 (топ-блокер) | подтверждён | вернуть app-гейт (Yandex 360 SSO, Б-1) + admin id из сессии |
| F-T1 | D2 | WebhookSettingsController.php:150 | P2 (блокер по severity SSRF) | подтверждён | `withoutRedirecting()` + ре-резолв адреса на запросе |
| F-T3 | D2 | SupplierWebhookController.php:55,162 | — | закрыт (fail-closed) | — (контроль присутствует) |
| F-P1 | D3 | PdErasureService.php:171 | P1 | подтверждён | чистить `deals.phones[]` JSONB + аудит охвата erasure (supplier_leads/activity_log) |
| F-P2 | D3 | app/config (нет sentry.php) | P3 | observation | PII-scrubbing при развёртывании Sentry (Б-1) |
| F-B1 | D4 | schema.sql:1090 | P3 | подтверждён | UNIQUE на `lead_charges` (defense-in-depth) + проверить CsvReconcile recovery path |

---

## Находки с доказательной базой

### F-T2 (P1, блокер) — `/api/admin/*` без application-гейта · инвариант D1

**Доказательная база (grep):**
- `EnsureSaasAdmin.php:30: class EnsureSaasAdmin`; `bootstrap/app.php:26: 'saas-admin' => EnsureSaasAdmin::class`; `routes/web.php:82: // … EnsureSaasAdmin — dev/testing`
- `EnsureSaasAdmin.php:34: return $next($request);` (тело `handle()` — no-op pass-through)
- `ResolvesAdminUserId.php:24: $requested = $request->input('admin_user_id');`

Гейт привилегированной зоны (impersonation, баланс, refund, тариф, system-settings, erasure ПДн,
РКН-notify) — безусловный проход; защита только nginx Basic Auth (single point of failure).
`admin_user_id` управляем клиентом → audit-trail подделываем. STRIDE: Spoofing / Elevation /
Repudiation / Info-disclosure. **Хуже модели threat-model reference (E20/E21 «bypass-флаг»):** не
флаг, а unconditional pass-through.

### F-T1 (P2, блокер по severity) — SSRF через `/api/webhooks/test` · инвариант D2

**Доказательная база (grep):**
- `WebhookSettingsController.php:150: $response = Http::timeout(10)` — `->post($target_url)` без `->withoutRedirecting()`
- `withoutRedirecting` в файле — **отсутствует** (absence-grep пуст)
- `WebhookUrlGuard.php:24: public static function blockReason(string $url): ?string` (guard присутствует)

Guard блокирует приватные IP, но проверяет только сохранённый URL. Guzzle по умолчанию следует за
`3xx` → `302 → 169.254.169.254` (metadata YC → IAM-токен) в обход pre-flight guard; DNS-rebinding
TOCTOU между резолюцией guard и `Http::post`. Частично смягчён наличием guard'а.

### F-T3 (закрыт) — входящий supplier-webhook IP-allowlist · инвариант D2

**Доказательная база (grep):**
- `SupplierWebhookController.php:36: * production env теперь fail-closed (verifyIpAllowlist возвращает false если…`
- `:55: if (! $this->verifyIpAllowlist($request->ip())) {`; `:162: private function verifyIpAllowlist(?string $clientIp): bool`

Контроль присутствует и fail-closed в production → прежний fail-open закрыт.

### F-P1 (P1, блокер) — неполное удаление ПДн по 152-ФЗ ст.21 · инвариант D3

**Доказательная база (grep):**
- `PdErasureService.php:20: *   deals:          phone, contact_name         — нет отдельного contact_email`
- `:138: // 3. deals  (phone + contact_name)`; `:171: 'contact_name' => 'Удалено',`
- `schema.sql:1632: phones          JSONB,                                 -- дополнительные телефоны`

`eraseSubject()` обновляет `deals.phone` + `deals.contact_name`, но **не трогает `deals.phones[]`
JSONB** (вторичные телефоны субъекта переживают удаление). По инвентарю ПДн скила
`pdn-152fz-audit` ПДн-носители также включают `supplier_leads` (`phone`, `raw_payload` JSONB) и
`activity_log.context` — **охват erasure против полного инвентаря не подтверждён в этой сессии**
(грепались только поля `deals`). Рекомендация: чистить `deals.phones[]` + аудит охвата erasure.
*Уточнение:* фича lead-region (`lead_region_resolution_log`/`phone_ranges`/`region_source`) в этой
рабочей копии отсутствует (0 совпадений в `app/`) → региональный прог неприменим здесь.

### F-P2 (P3, observation) — Sentry PII-scrubbing не сконфигурирован · инвариант D3

**Доказательная база (grep):** `grep -rn sentry app/config` → **пусто** (нет `app/config/sentry.php`).

Sentry разворачивается в фазе 3 (завязан на Б-1). Конфиг scrubbing'а отсутствует. Контрмера: при
развёртывании Sentry — обязательный PII-scrubbing (regex-маска phone/email/token) **до** go-live.
Carry-forward, Б-1 не закрывается.

### F-B1 (P3, defense-in-depth) — нет UNIQUE на `lead_charges` · инвариант D4

**Доказательная база (grep):** `schema.sql:1090: CREATE INDEX lead_charges_deal_id_deal_received_at_index` (не `UNIQUE`).

Первичная идемпотентность списания обеспечена PK `supplier_lead_deliveries (supplier_lead_id,
tenant_id)` (schema:2048) + `lockForUpdate(tenant)` — на основном пути доставки достаточна. Риск
дубля реален только на путях, минующих PK deliveries (прежде всего `CsvReconcileJob` recovery,
`charge_source` CSV / `recovered_from_csv_at`). Рекомендация: проверить, что CsvReconcileJob пишет
`lead_charges` под защитой idempotency-пути; добавить UNIQUE на `lead_charges` как defense-in-depth.

---

## Чисто (контр-гипотезы подтверждены)

- **D4 деньги через bcmath (без float):** `LedgerService.php:81: $newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);` (+ bcmul/bccomp/bcdiv, строки 72–84). Float не используется. ✅
- **D4 провенанс charge_source:** `LedgerService.php:95: 'charge_source' => 'rub',` (+ docstring стр.26 «INSERT lead_charges (charge_source='rub')»). Инвариант D4 «charge_source фиксируется» доказан. ✅
- **D2 guard приватных сетей:** `WebhookUrlGuard.php:24 blockReason` присутствует. ✅
- **D2 входящий supplier-webhook:** `verifyIpAllowlist` fail-closed (F-T3). ✅

---

## Вердикт security-go-live

**NO-GO (промежуточный).**

**Блокеры (до публичного go-live):** F-T2 (P1, топ — admin-зона без app-гейта), F-P1 (P1 — 152-ФЗ
ст.21 erasure), F-T1 (P2/SSRF — severity high из-за metadata YC).

**Предупреждения (medium/low):** F-P2 (Sentry scrubbing, Б-1), F-B1 (UNIQUE lead_charges /
CsvReconcile path).

**PENDING до публичного деплоя (live-DAST, нужен поднятый сервер):** Semgrep #25, Ward #70,
Nuclei #69, ZAP #68.

**Carry-forward (не закрываются открытые вопросы):**
- Юр./152-ФЗ: РКН-реестр (ст.22), согласия лидов (договор с поставщиком crm.bp-gr.ru ст.6 ч.3),
  договоры поручения Unisender Go / JivoSite, retention/hard-delete сделок.
- Техника, не верифицировано в этой сессии: `/api/dashboard/summary` (E18) без auth, rate-limit на
  login/forgot/2fa (E1/E2/E3), `SAAS_ADMIN_TEST_BYPASS` в prod, непрерывность границ 7 тарифных
  ступеней `PricingTierResolver`, семантика дрейфа `CsvReconcileJob` >5%, охват erasure против
  supplier_leads/activity_log.

**Итог:** портал НЕ готов к публикации на данном этапе — открыты блокеры F-T2/F-P1/F-T1; live-DAST
не прогнан. Решение владельца по блокерам — **accepted-risk (interim)**; устранение — до
публичного go-live отдельным эпиком. Финальное go/no-go выносит владелец после полного review.
