diff --git a/app/Livewire/Security/CloudInitScript/Show.php b/app/Livewire/Security/CloudInitScript/Show.php index 120a2463d0..28b3c420d7 100644 --- a/app/Livewire/Security/CloudInitScript/Show.php +++ b/app/Livewire/Security/CloudInitScript/Show.php @@ -67,7 +67,7 @@ class Show extends Component ]); auditLog('ui.cloud_init_script.updated', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->cloudInitScript->team_id, 'cloud_init_script_id' => $this->cloudInitScript->id, 'cloud_init_script_name' => $this->cloudInitScript->name, ]); @@ -86,7 +86,7 @@ class Show extends Component $this->cloudInitScript->delete(); auditLog('ui.cloud_init_script.deleted', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->cloudInitScript->team_id, 'cloud_init_script_id' => $scriptId, 'cloud_init_script_name' => $scriptName, ]); diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php index e9f2fbb701..d8006f7740 100644 --- a/app/Livewire/Security/CloudProviderToken/Show.php +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -67,7 +67,7 @@ class Show extends Component ]); auditLog('ui.cloud_token.updated', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->cloudProviderToken->team_id, 'cloud_token_uuid' => $this->cloudProviderToken->uuid, 'cloud_token_name' => $this->cloudProviderToken->name, 'provider' => $this->cloudProviderToken->provider, @@ -98,7 +98,7 @@ class Show extends Component ); auditLog('ui.cloud_token.validated', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->cloudProviderToken->team_id, 'cloud_token_uuid' => $this->cloudProviderToken->uuid, 'cloud_token_name' => $this->cloudProviderToken->name, 'provider' => $this->cloudProviderToken->provider, @@ -118,7 +118,7 @@ class Show extends Component } auditLog('ui.cloud_token.deleted', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->cloudProviderToken->team_id, 'cloud_token_uuid' => $this->cloudProviderToken->uuid, 'cloud_token_name' => $this->cloudProviderToken->name, 'provider' => $this->cloudProviderToken->provider, diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index d8dc685666..66310c104e 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -131,7 +131,7 @@ class IntegrationTokenEditor extends Component $this->loadZones(); auditLog('ui.integration_token.updated', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->integrationToken->team_id, 'integration_token_uuid' => $this->integrationToken->uuid, 'integration_token_name' => $this->integrationToken->name, 'provider' => $this->integrationToken->provider, @@ -141,12 +141,12 @@ class IntegrationTokenEditor extends Component if (in_array('base_url', $changedConnectionFields, true)) { auditLog('ui.integration_token.base_url_changed', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->integrationToken->team_id, 'integration_token_uuid' => $this->integrationToken->uuid, 'integration_token_name' => $this->integrationToken->name, 'provider' => $provider, - 'previous_base_url' => data_get($storedMetadata, 'base_url'), - 'base_url' => data_get($metadata, 'base_url'), + 'previous_base_url' => $this->baseUrlForAudit(data_get($storedMetadata, 'base_url')), + 'base_url' => $this->baseUrlForAudit(data_get($metadata, 'base_url')), ], 'warning'); } @@ -190,6 +190,25 @@ class IntegrationTokenEditor extends Component )); } + /** + * Base URL without user info, query, or fragment, which can carry credentials. + */ + private function baseUrlForAudit(mixed $url): ?string + { + if (! is_string($url) || $url === '') { + return null; + } + + $parts = parse_url($url); + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + return '[invalid-url]'; + } + + return $parts['scheme'].'://'.$parts['host'] + .(isset($parts['port']) ? ':'.$parts['port'] : '') + .($parts['path'] ?? ''); + } + private function reenterSecretMessage(string $provider): string { return match ($provider) { @@ -221,7 +240,7 @@ class IntegrationTokenEditor extends Component $this->integrationToken->delete(); auditLog('ui.integration_token.deleted', [ - 'team_id' => currentTeam()->id, + 'team_id' => $this->integrationToken->team_id, 'integration_token_uuid' => $uuid, 'integration_token_name' => $name, 'provider' => $provider, diff --git a/tests/Feature/Security/SecurityResourceAuditTeamTest.php b/tests/Feature/Security/SecurityResourceAuditTeamTest.php new file mode 100644 index 0000000000..550bb187f4 --- /dev/null +++ b/tests/Feature/Security/SecurityResourceAuditTeamTest.php @@ -0,0 +1,160 @@ +withoutDefer(); + if (! InstanceSettings::query()->whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + Once::flush(); + + $this->resourceTeam = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->resourceTeam->members()->attach($this->user->id, ['role' => 'admin']); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->resourceTeam]); + $this->actingAs($this->user); +}); + +function switchSecurityAuditSessionTeam(Team $team): void +{ + session(['currentTeam' => $team]); + Cache::flush(); + Once::flush(); +} + +function expectSecurityAuditEventInResourceTeam(string $event, Team $resourceTeam, Team $otherTeam, array $secrets): void +{ + $auditEvent = AuditEvent::query()->where('event', $event)->sole(); + + expect($auditEvent->team_id)->toBe($resourceTeam->id) + ->and(AuditEvent::query()->where('team_id', $otherTeam->id)->exists())->toBeFalse(); + + $stored = json_encode($auditEvent->toArray()); + foreach ($secrets as $secret) { + expect($stored)->not->toContain($secret); + } +} + +test('integration token updates are audited in the token team after a session team switch', function () { + $token = IntegrationToken::query()->create([ + 'team_id' => $this->resourceTeam->id, + 'provider' => 'doppler', + 'name' => 'Production secrets', + 'token' => 'dp.st.super-secret', + 'capabilities' => ['secrets'], + ]); + + $component = Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid]); + switchSecurityAuditSessionTeam($this->otherTeam); + + $component->set('name', 'Renamed secrets')->call('save')->assertHasNoErrors(); + + expect($token->fresh()->name)->toBe('Renamed secrets'); + expectSecurityAuditEventInResourceTeam('ui.integration_token.updated', $this->resourceTeam, $this->otherTeam, ['dp.st.super-secret']); +}); + +test('integration token base url changes are audited in the token team without url credentials', function () { + Http::fake(['*' => Http::response(['data' => []])]); + $token = IntegrationToken::query()->create([ + 'team_id' => $this->resourceTeam->id, + 'provider' => 'vault', + 'name' => 'Production Vault', + 'token' => 'hvs.stored-secret', + 'capabilities' => ['secrets'], + 'metadata' => ['base_url' => 'https://old-user:old-pass@example.com:8200'], + ]); + + $component = Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid]); + switchSecurityAuditSessionTeam($this->otherTeam); + + $component->set('metadata.base_url', 'https://new-user:new-pass@example.net:8200') + ->set('newToken', 'hvs.new-secret') + ->call('save') + ->assertHasNoErrors(); + + expect($token->fresh()->token)->toBe('hvs.new-secret'); + expectSecurityAuditEventInResourceTeam('ui.integration_token.base_url_changed', $this->resourceTeam, $this->otherTeam, [ + 'hvs.stored-secret', 'hvs.new-secret', 'old-pass', 'new-pass', + ]); + $auditEvent = AuditEvent::query()->where('event', 'ui.integration_token.base_url_changed')->sole(); + expect(data_get($auditEvent->metadata, 'previous_base_url'))->toBe('https://example.com:8200') + ->and(data_get($auditEvent->metadata, 'base_url'))->toBe('https://example.net:8200'); +}); + +test('integration token deletes are audited in the token team after a session team switch', function () { + $token = IntegrationToken::query()->create([ + 'team_id' => $this->resourceTeam->id, + 'provider' => 'doppler', + 'name' => 'Production secrets', + 'token' => 'dp.st.super-secret', + 'capabilities' => ['secrets'], + ]); + + $component = Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid]); + switchSecurityAuditSessionTeam($this->otherTeam); + + $component->call('delete'); + + expect(IntegrationToken::query()->whereKey($token->id)->exists())->toBeFalse(); + expectSecurityAuditEventInResourceTeam('ui.integration_token.deleted', $this->resourceTeam, $this->otherTeam, ['dp.st.super-secret']); +}); + +test('cloud provider token changes are audited in the token team after a session team switch', function (string $action, string $event) { + Http::fake(['*' => Http::response([])]); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->resourceTeam->id, + 'token' => 'hcloud-super-secret', + 'name' => 'Production Hetzner', + ]); + + $component = Livewire::test(CloudProviderTokenShow::class, ['cloud_token_uuid' => $token->uuid]); + switchSecurityAuditSessionTeam($this->otherTeam); + + $component->set('name', 'Renamed Hetzner')->call($action)->assertHasNoErrors(); + + expectSecurityAuditEventInResourceTeam($event, $this->resourceTeam, $this->otherTeam, ['hcloud-super-secret']); +})->with([ + 'save' => ['save', 'ui.cloud_token.updated'], + 'validate' => ['validateToken', 'ui.cloud_token.validated'], + 'delete' => ['delete', 'ui.cloud_token.deleted'], +]); + +test('cloud-init script changes are audited in the script team after a session team switch', function (string $action, string $event) { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->resourceTeam->id, + 'name' => 'Bootstrap', + 'script' => "#cloud-config\nruncmd:\n - echo cloud-init-super-secret\n", + ]); + + $component = Livewire::test(CloudInitScriptShow::class, ['cloud_init_script_uuid' => $script->uuid]); + switchSecurityAuditSessionTeam($this->otherTeam); + + $component->set('script', "#cloud-config\nruncmd:\n - echo cloud-init-new-secret\n")->call($action)->assertHasNoErrors(); + + expectSecurityAuditEventInResourceTeam($event, $this->resourceTeam, $this->otherTeam, ['cloud-init-super-secret', 'cloud-init-new-secret']); +})->with([ + 'save' => ['save', 'ui.cloud_init_script.updated'], + 'delete' => ['delete', 'ui.cloud_init_script.deleted'], +]);