diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 4f469fdbeb..be8a584e26 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -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'; diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php index 878d46c1e4..d0b08cd033 100644 --- a/app/Traits/Auditable.php +++ b/app/Traits/Auditable.php @@ -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() diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index bb93547c4d..2c8b7f4a8f 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,7 +1,9 @@ 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 $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. diff --git a/tests/Feature/AuditFailureDeduplicationTest.php b/tests/Feature/AuditFailureDeduplicationTest.php new file mode 100644 index 0000000000..2fb9a53343 --- /dev/null +++ b/tests/Feature/AuditFailureDeduplicationTest.php @@ -0,0 +1,95 @@ +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); +}); diff --git a/tests/Feature/AuditableHeartbeatFieldsTest.php b/tests/Feature/AuditableHeartbeatFieldsTest.php new file mode 100644 index 0000000000..c1facdcd6f --- /dev/null +++ b/tests/Feature/AuditableHeartbeatFieldsTest.php @@ -0,0 +1,132 @@ +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']); +}); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php index 23456e688c..5cbe0d4fc4 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -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); +});