d170c886bc
Closes hole #1: log_hash written by trigger but never verified → tampering invisible. audit:verify-chains (cron daily 04:00) recomputes SHA-256 chain for all 6 audit tables via SQL on pgsql_supplier (prod-safe). Serialization reproduces trigger exactly (ROW with log_hash=NULL::bytea). Break → incidents_log (high, dedup 24h) + AuditChainBreachMail to kdv1@bk.ru + non-zero exit. Tests 5/5, Console 19/19.
168 lines
5.6 KiB
PHP
168 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Mail\AuditChainBreachMail;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
uses(SharesSupplierPdo::class);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: ensure at least one active saas_admin_user row exists (FK for incidents_log)
|
|
// ---------------------------------------------------------------------------
|
|
function ensureAuditAdmin(): int
|
|
{
|
|
$id = DB::table('saas_admin_users')
|
|
->where('is_active', true)
|
|
->whereNull('deleted_at')
|
|
->value('id');
|
|
|
|
if ($id !== null) {
|
|
return (int) $id;
|
|
}
|
|
|
|
return (int) DB::table('saas_admin_users')->insertGetId([
|
|
'email' => 'audit-cron@liderra.ru',
|
|
'full_name' => 'Audit Cron',
|
|
'password_hash' => '$2y$12$placeholder',
|
|
'role' => 'dev_oncall',
|
|
'is_active' => true,
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: insert N rows into auth_log via the normal path (trigger fills log_hash).
|
|
// Uses the 3rd constraint variant: actor_type='tenant_user', user_id=NULL,
|
|
// saas_admin_user_id=NULL, email IS NOT NULL (login attempt with unknown email).
|
|
// ---------------------------------------------------------------------------
|
|
function insertAuthLogRows(int $n, ?int $tenantId = null): void
|
|
{
|
|
for ($i = 0; $i < $n; $i++) {
|
|
DB::table('auth_log')->insert([
|
|
'actor_type' => 'tenant_user',
|
|
'tenant_id' => $tenantId,
|
|
'email' => "test{$i}@example.com",
|
|
'event' => 'login_failed',
|
|
'ip_address' => '127.0.0.1',
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup
|
|
// ---------------------------------------------------------------------------
|
|
beforeEach(function () {
|
|
ensureAuditAdmin();
|
|
Mail::fake();
|
|
});
|
|
|
|
// ===========================================================================
|
|
// TDD ANCHOR: clean chain must verify intact (serialization correctness gate)
|
|
// ===========================================================================
|
|
|
|
test('clean auth_log chain verifies intact', function () {
|
|
insertAuthLogRows(3);
|
|
|
|
$this->artisan('audit:verify-chains')->assertSuccessful();
|
|
|
|
// No incident should be created for an intact chain
|
|
$count = DB::connection('pgsql_supplier')
|
|
->table('incidents_log')
|
|
->where('summary', 'like', '%chain%auth_log%')
|
|
->count();
|
|
|
|
expect($count)->toBe(0);
|
|
|
|
// No email should be sent
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
test('empty tables are skipped gracefully (no false positive)', function () {
|
|
// auth_log might have rows from other tests but other tables are empty on dev.
|
|
// The command must not raise an error on empty tables.
|
|
$this->artisan('audit:verify-chains')->assertSuccessful();
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
test('multiple rows in auth_log all pass intact', function () {
|
|
insertAuthLogRows(10);
|
|
|
|
$this->artisan('audit:verify-chains')->assertSuccessful();
|
|
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Tampering detection
|
|
// ===========================================================================
|
|
|
|
test('tampered auth_log row raises incident and sends email', function () {
|
|
insertAuthLogRows(3);
|
|
|
|
// Sanity: intact before tampering
|
|
$this->artisan('audit:verify-chains')->assertSuccessful();
|
|
Mail::assertNothingSent();
|
|
|
|
// Tamper: disable triggers, mutate the first row's ip_address, re-enable.
|
|
// DISABLE TRIGGER USER disables all user-defined triggers (audit_block_mutation
|
|
// is BEFORE UPDATE, so without disabling it the UPDATE would be blocked).
|
|
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');
|
|
|
|
// Now the command must detect the breach
|
|
$this->artisan('audit:verify-chains')->assertFailed();
|
|
|
|
$incident = DB::connection('pgsql_supplier')
|
|
->table('incidents_log')
|
|
->where('type', 'other')
|
|
->where('severity', 'high')
|
|
->where('summary', 'like', '%chain%auth_log%')
|
|
->first();
|
|
|
|
expect($incident)->not->toBeNull();
|
|
expect($incident->summary)->toContain('auth_log');
|
|
|
|
Mail::assertSent(AuditChainBreachMail::class, function ($mail) {
|
|
return $mail->tableName === 'auth_log';
|
|
});
|
|
});
|
|
|
|
test('incident dedup: same table breach does not create duplicate within 24h', function () {
|
|
insertAuthLogRows(3);
|
|
|
|
// First tamper
|
|
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
|
|
DB::table('auth_log')
|
|
->orderBy('id')
|
|
->limit(1)
|
|
->update(['ip_address' => '5.5.5.5']);
|
|
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
|
|
|
|
$this->artisan('audit:verify-chains')->assertFailed();
|
|
|
|
$countAfterFirst = DB::connection('pgsql_supplier')
|
|
->table('incidents_log')
|
|
->where('summary', 'like', '%chain%auth_log%')
|
|
->count();
|
|
expect($countAfterFirst)->toBe(1);
|
|
|
|
// Second run (same ongoing breach) must NOT create a second incident (dedup)
|
|
$this->artisan('audit:verify-chains')->assertFailed();
|
|
|
|
$countAfterSecond = DB::connection('pgsql_supplier')
|
|
->table('incidents_log')
|
|
->where('summary', 'like', '%chain%auth_log%')
|
|
->count();
|
|
expect($countAfterSecond)->toBe(1);
|
|
});
|