# Дыра #1: Валидатор хеш-цепочки audit-журналов — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development` / `executing-plans`. TDD, checkbox steps.

**Goal:** периодически проверять целостность SHA-256 hash-chain во всех audit-таблицах. Если цепочка порвана (кто-то изменил/удалил строку в обход триггеров — напр. прямым SQL под суперюзером) — поднять инцидент + email-алёрт. Сейчас hash пишется, но НИКТО его не проверяет → tampering незаметен.

**Architecture:** artisan-команда `audit:verify-chains` (cron daily) идёт по каждой из 6 hash-chain таблиц, пересчитывает цепочку и сравнивает с хранимым `log_hash`. Разрыв → `incidents_log` (severity high, через `pgsql_supplier` BYPASSRLS — паттерн дыры #7) + email на `kdv1@bk.ru`. Read-only к audit-таблицам (их UPDATE/DELETE и так запрещён `audit_block_mutation`).

**Tech Stack:** Laravel 13, PostgreSQL 16, Pest 4.

## Hash-chain механизм (из db/schema.sql §14, функция `audit_chain_hash()`)

- BEFORE INSERT триггер: `NEW.log_hash := digest(COALESCE(prev_hash,''::bytea) || NEW::text::bytea, 'sha256')`.
- `prev_hash` = `log_hash` последней строки (`ORDER BY id DESC LIMIT 1`).
- В момент вычисления `NEW.log_hash` ещё **NULL** → сериализуется `NEW::text` со столбцом `log_hash` пустым (NULL).
- **6 таблиц:** `auth_log`, `activity_log`, `pd_processing_log`, `saas_admin_audit_log`, `balance_transactions`, `tenant_operations_log`.

## КРИТИЧНО — точность сериализации

Валидатор ОБЯЗАН воспроизвести ровно `sha256(prev_log_hash || <row с log_hash=NULL>::text)`. Это лучше делать в PostgreSQL (та же сериализация `ROW::text`, что у триггера), НЕ в PHP (форматы дат/типов разойдутся). Подход: SQL-запрос, который по каждой таблице берёт строки `ORDER BY id`, через `lag(log_hash) OVER (ORDER BY id)` берёт prev, и пересчитывает `digest(coalesce(prev,''::bytea) || <row-with-null-log_hash>::text::bytea, 'sha256')`, сравнивает со stored `log_hash`.

Чтобы получить `<row с log_hash=NULL>::text`: построить явный `ROW(col1, col2, ..., NULL /*log_hash position*/, ...)::text` со списком колонок таблицы в порядке схемы (log_hash на своём месте = NULL). Список колонок каждой таблицы взять из `information_schema.columns ORDER BY ordinal_position` или из db/schema.sql.

**TDD-якорь:** первый тест — валидатор на НЕТРОНУТОЙ цепочке (после нескольких INSERT через нормальный путь) ДОЛЖЕН вернуть «целостно». Если он флагует разрыв на честной цепочке — сериализация не совпала, чините SQL до зелёного. Только потом тест на tampering.

---

## Task 1: VerifyAuditChains command skeleton + per-table column maps

**Files:**

- Create: `app/app/Console/Commands/VerifyAuditChains.php`
- Create test: `app/tests/Feature/Console/VerifyAuditChainsTest.php`

- [ ] **Step 1.1: Write failing test — clean chain passes**

```php
<?php
declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;

uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);

test('clean auth_log chain verifies intact', function () {
    $tenant = \App\Models\Tenant::factory()->create();
    DB::statement('SET app.current_tenant_id = '.$tenant->id);
    // insert a few auth_log rows via normal path (trigger fills log_hash)
    for ($i = 0; $i < 3; $i++) {
        DB::table('auth_log')->insert([
            'tenant_id' => $tenant->id,
            'event' => 'login',
            'ip_address' => '127.0.0.1',
            'created_at' => now(),
        ]);
    }
    $this->artisan('audit:verify-chains')->assertSuccessful();
    // no incident created for intact chain
    expect(DB::connection('pgsql_supplier')->table('incidents_log')
        ->where('summary', 'like', '%chain%')->count())->toBe(0);
});
```

(adjust auth_log columns to actual schema — read db/schema.sql.)

- [ ] **Step 1.2: Run — FAIL (command missing).**
- [ ] **Step 1.3: Implement command.** `protected $signature = 'audit:verify-chains'`. `private const DB_CONNECTION = 'pgsql_supplier';`. Const array of 6 tables. For each table: run the recompute SQL (above), collect rows where `stored IS DISTINCT FROM expected`. Use `DB::connection(self::DB_CONNECTION)`.
- [ ] **Step 1.4: Run — PASS (clean chain, 0 mismatches).**
- [ ] **Step 1.5: Commit.**

## Task 2: Tampering detection + incident + alert

**Files:**

- Modify: `app/app/Console/Commands/VerifyAuditChains.php`
- Modify test.

- [ ] **Step 2.1: Failing test — tampered chain detected.**
To simulate tampering despite `audit_block_mutation` (forbids UPDATE/DELETE): in the test, temporarily disable the triggers, mutate a row, re-enable:

```php
test('tampered chain raises incident', function () {
    $tenant = \App\Models\Tenant::factory()->create();
    DB::statement('SET app.current_tenant_id = '.$tenant->id);
    for ($i = 0; $i < 3; $i++) {
        DB::table('auth_log')->insert([...]);
    }
    // tamper: disable triggers, change a middle row's data, re-enable
    DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
    DB::table('auth_log')->orderBy('id')->limit(1)->update(['ip_address' => '6.6.6.6']);
    DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');

    $this->artisan('audit:verify-chains')->assertFailed(); // non-zero exit on breach
    expect(DB::connection('pgsql_supplier')->table('incidents_log')
        ->where('type','other')->where('severity','high')
        ->where('summary','like','%auth_log%')->count())->toBeGreaterThanOrEqual(1);
});
```

- [ ] **Step 2.2: Run — FAIL.**
- [ ] **Step 2.3: Implement.** On any mismatch: insert `incidents_log` row (via pgsql_supplier; `type='other'`, `severity='high'`, `summary` naming table + first broken id), dedup (don't duplicate same-table incident within 24h), send email to `kdv1@bk.ru` (use existing Mail pattern — search codebase for an existing Mailable or `Mail::raw`), and command exits non-zero (`return self::FAILURE`).
- [ ] **Step 2.4: Run — PASS.**
- [ ] **Step 2.5: Commit.**

## Task 3: Schedule + full regression

- [ ] **Step 3.1:** Add to `app/routes/console.php`: `Schedule::command('audit:verify-chains')->dailyAt('04:00');` (mirror existing schedule style).
- [ ] **Step 3.2:** `cd app && ./vendor/bin/pest tests/Feature/Console/VerifyAuditChainsTest.php` — PASS.
- [ ] **Step 3.3:** `cd app && ./vendor/bin/pest --parallel` — green baseline.
- [ ] **Step 3.4: Commit.**

## Deploy (controller, after merge)

- scp `VerifyAuditChains.php` + `routes/console.php` → prod, sed CRLF, install www-data.
- restart liderra-queue + reload php-fpm.
- Smoke: `sudo -u www-data php artisan audit:verify-chains` → expect "all chains intact" exit 0 on real prod data (verifies serialization matches on prod too).

## Self-review (controller)

1. Clean chain on DEV passes (serialization correct)?
2. Tampered detection works?
3. Uses pgsql_supplier (works on prod non-bypass role)?
4. Dedup prevents incident spam?
5. Prod smoke: real chains verify intact (no false positive)?
