mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
- Ignore status, heartbeat, and check fields that background checks write during a user request. - Record repeated unauthenticated failures (failed logins, API auth, webhook signatures) once per source in a short window. - An explicit null team_id in the context is kept, so instance events are not stored on the admin's current team. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96 lines
3.5 KiB
PHP
96 lines
3.5 KiB
PHP
<?php
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\InstanceSettings;
|
|
use Illuminate\Auth\Events\Failed;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Once;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->withoutDefer();
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
Once::flush();
|
|
Log::spy();
|
|
});
|
|
|
|
function pushToSentinelFromIp(string $ip, ?string $token = 'review-fix-e-invalid-token'): void
|
|
{
|
|
$headers = $token === null ? [] : ['Authorization' => 'Bearer '.$token];
|
|
|
|
test()->withServerVariables(['REMOTE_ADDR' => $ip])
|
|
->postJson('/api/v1/sentinel/push', ['containers' => []], $headers)
|
|
->assertUnauthorized();
|
|
}
|
|
|
|
function sentinelFailureReasons(): array
|
|
{
|
|
return AuditEvent::query()
|
|
->where('event', 'webhook.sentinel.signature_failed')
|
|
->orderBy('id')
|
|
->get()
|
|
->map(fn (AuditEvent $event): string => $event->metadata['reason'].'@'.$event->ip_address)
|
|
->all();
|
|
}
|
|
|
|
test('a burst of failed sentinel pushes records one audit event per reason and source ip', function () {
|
|
foreach (range(1, 5) as $attempt) {
|
|
pushToSentinelFromIp('203.0.113.10');
|
|
}
|
|
pushToSentinelFromIp('203.0.113.10', token: null);
|
|
pushToSentinelFromIp('203.0.113.11');
|
|
|
|
expect(sentinelFailureReasons())->toBe([
|
|
'decrypt_failed@203.0.113.10',
|
|
'token_missing@203.0.113.10',
|
|
'decrypt_failed@203.0.113.11',
|
|
]);
|
|
});
|
|
|
|
test('a failure is recorded again after the deduplication window', function () {
|
|
pushToSentinelFromIp('203.0.113.10');
|
|
$this->travel(4)->minutes();
|
|
pushToSentinelFromIp('203.0.113.10');
|
|
$this->travel(2)->minutes();
|
|
pushToSentinelFromIp('203.0.113.10');
|
|
|
|
expect(sentinelFailureReasons())->toBe([
|
|
'decrypt_failed@203.0.113.10',
|
|
'decrypt_failed@203.0.113.10',
|
|
]);
|
|
});
|
|
|
|
test('repeated unauthenticated api requests record one audit event per source ip', function () {
|
|
foreach (range(1, 3) as $attempt) {
|
|
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])->getJson('/api/v1/projects')->assertUnauthorized();
|
|
}
|
|
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.21'])->getJson('/api/v1/projects')->assertUnauthorized();
|
|
|
|
expect(AuditEvent::query()->where('event', 'api.auth.unauthenticated')->pluck('ip_address')->all())
|
|
->toBe(['203.0.113.20', '203.0.113.21']);
|
|
});
|
|
|
|
test('failed logins record one audit event per email and source ip in each window', function () {
|
|
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.30']);
|
|
|
|
foreach (['victim@example.com', 'VICTIM@example.com', 'victim@example.com', 'other@example.com'] as $email) {
|
|
event(new Failed('web', null, ['email' => $email, 'password' => 'wrong']));
|
|
}
|
|
$this->travel(61)->seconds();
|
|
event(new Failed('web', null, ['email' => 'victim@example.com', 'password' => 'wrong']));
|
|
|
|
expect(AuditEvent::query()->where('event', 'auth.user.login_failed')->orderBy('id')->pluck('metadata')->pluck('attempted_email')->all())
|
|
->toBe(['victim@example.com', 'other@example.com', 'victim@example.com']);
|
|
});
|
|
|
|
test('events outside the unauthenticated failure set are never deduplicated', function () {
|
|
auditLog('webhook.github.deployment_queued', ['reason' => 'push']);
|
|
auditLog('webhook.github.deployment_queued', ['reason' => 'push']);
|
|
auditLog('api.auth.ability_denied', ['reason' => 'missing ability']);
|
|
auditLog('api.auth.ability_denied', ['reason' => 'missing ability']);
|
|
|
|
expect(AuditEvent::query()->count())->toBe(4);
|
|
});
|