mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(audit): reduce audit log noise and keep instance events out of team logs
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f75ffd329f
commit
9862f75dc2
@@ -116,9 +116,9 @@ class AuditEvent extends Model
|
||||
private static function attributesFor(string $event, array $context, string $level): array
|
||||
{
|
||||
$teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id')
|
||||
?? data_get($context, 'team_id')
|
||||
?? currentTeam()?->id
|
||||
?? self::teamIdFromContext($context);
|
||||
?? (array_key_exists('team_id', $context)
|
||||
? $context['team_id']
|
||||
: currentTeam()?->id ?? self::teamIdFromContext($context));
|
||||
|
||||
$parts = explode('.', $event);
|
||||
$source = $parts[0] ?? 'system';
|
||||
|
||||
@@ -10,6 +10,41 @@ use Illuminate\Support\Str;
|
||||
|
||||
trait Auditable
|
||||
{
|
||||
/**
|
||||
* Fields that status checks, Sentinel pushes and server checks write as a side effect.
|
||||
* They can change inside a user's request (for example GetContainersStatus::run()),
|
||||
* so they must not appear as changes made by that user.
|
||||
*/
|
||||
private const AUDIT_IGNORED_FIELDS = [
|
||||
'updated_at',
|
||||
'order',
|
||||
// Container status and restart tracking (GetContainersStatus, PushServerUpdateJob).
|
||||
'status',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'restart_limit_reached',
|
||||
'container_present',
|
||||
'started_at',
|
||||
'config_hash',
|
||||
'custom_healthcheck_found',
|
||||
'domain_dns_statuses',
|
||||
// Server heartbeat and check state (Sentinel push, ServerConnectionCheckJob, validation, proxy checks).
|
||||
'sentinel_updated_at',
|
||||
'sentinel_waiting_since',
|
||||
'unreachable_count',
|
||||
'high_disk_usage_notification_sent',
|
||||
'log_drain_notification_sent',
|
||||
'validation_logs',
|
||||
'is_validating',
|
||||
'detected_traefik_version',
|
||||
'traefik_outdated_info',
|
||||
'hetzner_server_status',
|
||||
'vultr_instance_status',
|
||||
'digitalocean_droplet_status',
|
||||
];
|
||||
|
||||
private bool $auditLoggingEnabled = true;
|
||||
|
||||
public static function bootAuditable(): void
|
||||
@@ -33,9 +68,7 @@ trait Auditable
|
||||
$changedFields = $action === 'updated'
|
||||
? collect(array_keys($this->getChanges()))
|
||||
->reject(fn (string $field): bool => in_array($field, [
|
||||
'updated_at',
|
||||
'order',
|
||||
'status',
|
||||
...self::AUDIT_IGNORED_FIELDS,
|
||||
...($this->auditExclude ?? []),
|
||||
], true))
|
||||
->values()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
if (! function_exists('auditLog')) {
|
||||
/**
|
||||
@@ -13,6 +15,10 @@ if (! function_exists('auditLog')) {
|
||||
*/
|
||||
function auditLog(string $event, array $context = [], string $level = 'info'): void
|
||||
{
|
||||
if (! shouldRecordAuditEvent($event, $context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$level = AuditEvent::normalizeLevel($level);
|
||||
|
||||
try {
|
||||
@@ -41,6 +47,55 @@ if (! function_exists('auditLog')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('auditFailureDeduplicationWindow')) {
|
||||
/**
|
||||
* Seconds during which repeated unauthenticated failures from one source are recorded once.
|
||||
*
|
||||
* Failed logins use a short window so an ongoing brute force attempt stays visible
|
||||
* (one row per email and source IP each minute, on top of the login rate limit).
|
||||
*/
|
||||
function auditFailureDeduplicationWindow(string $event): ?int
|
||||
{
|
||||
return match (true) {
|
||||
$event === 'auth.user.login_failed' => 60,
|
||||
$event === 'api.auth.unauthenticated',
|
||||
preg_match('/^webhook\.[a-z0-9_-]+\.signature_failed$/i', $event) === 1 => 300,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('shouldRecordAuditEvent')) {
|
||||
/**
|
||||
* Keep only the first unauthenticated failure per event, source IP, reason, server and
|
||||
* attempted email in each deduplication window. All other events are always recorded.
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
function shouldRecordAuditEvent(string $event, array $context): bool
|
||||
{
|
||||
$window = auditFailureDeduplicationWindow($event);
|
||||
if ($window === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$request = app()->bound('request') ? request() : null;
|
||||
$fingerprint = hash('sha256', json_encode([
|
||||
$event,
|
||||
$request?->ip(),
|
||||
data_get($context, 'reason'),
|
||||
data_get($context, 'server_uuid'),
|
||||
Str::lower(trim((string) data_get($context, 'attempted_email'))),
|
||||
]));
|
||||
|
||||
return Cache::add("audit-failure-dedup:{$fingerprint}", true, $window);
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('auditLogWebhookFailure')) {
|
||||
/**
|
||||
* Record a webhook signature/auth verification failure.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\StandaloneSqlite;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
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();
|
||||
Server::flushIdentityMap();
|
||||
Log::spy();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
function createAuditedResourceForHeartbeatTest(string $model, Environment $environment): Model
|
||||
{
|
||||
if ($model === Application::class) {
|
||||
return Application::factory()->create(['environment_id' => $environment->id]);
|
||||
}
|
||||
|
||||
$passwords = [
|
||||
StandalonePostgresql::class => ['postgres_password' => 'review-fix-e-password'],
|
||||
StandaloneMysql::class => ['mysql_root_password' => 'review-fix-e-password', 'mysql_password' => 'review-fix-e-password'],
|
||||
StandaloneMariadb::class => ['mariadb_root_password' => 'review-fix-e-password', 'mariadb_password' => 'review-fix-e-password'],
|
||||
StandaloneMongodb::class => ['mongo_initdb_root_password' => 'review-fix-e-password'],
|
||||
StandaloneRedis::class => [],
|
||||
StandaloneKeydb::class => ['keydb_password' => 'review-fix-e-password'],
|
||||
StandaloneDragonfly::class => ['dragonfly_password' => 'review-fix-e-password'],
|
||||
StandaloneClickhouse::class => ['clickhouse_admin_password' => 'review-fix-e-password'],
|
||||
StandaloneSqlite::class => [],
|
||||
];
|
||||
|
||||
return $model::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'review-fix-e-database',
|
||||
'status' => 'exited',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_type' => Server::class,
|
||||
'destination_id' => 0,
|
||||
...$passwords[$model],
|
||||
]);
|
||||
}
|
||||
|
||||
test('container heartbeat and restart tracking writes are not audited as user changes', function (string $model) {
|
||||
$resource = createAuditedResourceForHeartbeatTest($model, $this->environment);
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
$resource->update(['status' => 'running:healthy', 'last_online_at' => now()]);
|
||||
$resource->update(['restart_count' => 3, 'last_restart_at' => now(), 'last_restart_type' => 'crash']);
|
||||
$resource->update(['config_hash' => 'review-fix-e-hash']);
|
||||
$resource->update($model === Application::class
|
||||
? ['container_present' => false, 'restart_limit_reached' => true]
|
||||
: ['started_at' => now()]);
|
||||
|
||||
expect(AuditEvent::query()->count())->toBe(0);
|
||||
|
||||
$resource->update(['name' => 'review-fix-e-renamed', 'restart_count' => 0]);
|
||||
|
||||
expect(AuditEvent::query()->sole()->metadata['changed_fields'])->toBe(['name']);
|
||||
})->with([
|
||||
Application::class,
|
||||
StandalonePostgresql::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
StandaloneSqlite::class,
|
||||
]);
|
||||
|
||||
test('application dns check results are not audited as user changes', function () {
|
||||
$application = Application::factory()->create(['environment_id' => $this->environment->id]);
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
$application->update(['domain_dns_statuses' => ['https://example.com' => ['status' => 'ok']], 'custom_healthcheck_found' => true]);
|
||||
|
||||
expect(AuditEvent::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('server heartbeat and check state writes are not audited as user changes', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
AuditEvent::query()->delete();
|
||||
|
||||
$server->sentinelHeartbeat();
|
||||
$server->update([
|
||||
'sentinel_waiting_since' => now(),
|
||||
'unreachable_count' => 2,
|
||||
'high_disk_usage_notification_sent' => true,
|
||||
'log_drain_notification_sent' => true,
|
||||
'validation_logs' => 'review-fix-e-validation',
|
||||
'is_validating' => true,
|
||||
'detected_traefik_version' => 'v3.1.0',
|
||||
'traefik_outdated_info' => ['current' => 'v3.1.0'],
|
||||
]);
|
||||
|
||||
expect(AuditEvent::query()->count())->toBe(0);
|
||||
|
||||
$server->update(['description' => 'review-fix-e-description']);
|
||||
|
||||
expect(AuditEvent::query()->sole()->metadata['changed_fields'])->toBe(['description']);
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Livewire\SettingsOauth;
|
||||
use App\Livewire\Team\AuditLog;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\Team;
|
||||
@@ -235,3 +237,29 @@ it('toggles provider enabled state from the action button', function () {
|
||||
|
||||
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('stores instance authentication audit events without the admin current team', function () {
|
||||
$this->withoutDefer();
|
||||
$otherTeam = Team::factory()->create();
|
||||
$instanceAdmin = actingAsInstanceAdmin();
|
||||
$instanceAdmin->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
$instanceAdmin->unsetRelation('teams');
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
Livewire::test(SettingsOauth::class)
|
||||
->set('disable_registration_when_oauth_enabled', true)
|
||||
->call('saveRegistrationPolicy')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$event = AuditEvent::query()->where('event', 'ui.instance.authentication.updated')->sole();
|
||||
expect($event->team_id)->toBeNull();
|
||||
|
||||
$otherTeamAdmin = User::factory()->create();
|
||||
$otherTeam->members()->attach($otherTeamAdmin, ['role' => 'admin']);
|
||||
$this->actingAs($otherTeamAdmin);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
|
||||
Livewire::test(AuditLog::class)
|
||||
->assertDontSee($instanceAdmin->email)
|
||||
->assertDontSee($event->description);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user