diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index c7fa3dc2b7..45c8b632fa 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -20,6 +20,27 @@ class AuditEvent extends Model public const UPDATED_AT = null; + /** + * Raw commands and configurations can contain credentials without sensitive field names. + * + * @var array + */ + private const SENSITIVE_VALUE_FIELDS = [ + 'git_full_url', + 'install_command', 'build_command', 'start_command', + 'health_check_command', 'health_check_response_text', + 'custom_docker_run_options', 'pre_deployment_command', 'post_deployment_command', + 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', + 'custom_nginx_configuration', + 'postgres_conf', 'mysql_conf', 'mariadb_conf', 'mongo_conf', 'redis_conf', 'keydb_conf', + 'internal_db_url', 'external_db_url', 'init_scripts', + 'dockerfile', 'docker_compose', 'docker_compose_raw', 'custom_labels', + 'last_saved_proxy_configuration', + 'environment_variables', 'environment_variables_preview', + 'validation_logs', 'server_metadata', 'logs', + 'configuration_snapshot', 'configuration_diff', 'content', 'file_storage_content', + ]; + protected $fillable = [ 'team_id', 'event', @@ -228,9 +249,15 @@ class AuditEvent extends Model return $key ? data_get($context, $key) : null; } + public static function isSensitiveField(string $field): bool + { + return in_array($field, self::SENSITIVE_VALUE_FIELDS, true) + || preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie|license_key/i', $field) === 1; + } + public static function redact(mixed $value, ?string $key = null): mixed { - if ($key !== null && self::isSensitiveKey($key)) { + if ($key !== null && (in_array($key, self::SENSITIVE_VALUE_FIELDS, true) || self::isSensitiveKey($key))) { return '[REDACTED]'; } diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php index f37f796226..52cd033bcb 100644 --- a/app/Traits/Auditable.php +++ b/app/Traits/Auditable.php @@ -135,12 +135,13 @@ trait Auditable 'deleted_at', 'order', 'status', + ...(get_class_vars(static::class)['hidden'] ?? []), ...$this->getHidden(), ...($this->auditExclude ?? []), ], true) || str_ends_with($field, '_id') || str_ends_with($field, '_at') - || preg_match('/password|secret|token|private_key|signature|credential|api_key|access_key|authorization|cookie/i', $field) + || AuditEvent::isSensitiveField($field) || (is_string($cast) && ($cast === 'encrypted' || str_starts_with($cast, 'encrypted:'))); } diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php index 337658c5e0..f10de08adc 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -257,6 +257,86 @@ test('auditable models redact nested secrets in object casts', function () { ]); }); +test('audit changes exclude raw fields that can contain embedded credentials', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + + $application->update([ + 'name' => 'Safe name', + 'git_full_url' => 'https://user:embedded-password@example.com/repo.git', + 'build_command' => 'API_KEY=embedded-key npm run build', + ]); + + expect(AuditEvent::query()->sole()->changes)->toHaveKey('name') + ->not->toHaveKeys(['git_full_url', 'build_command']); +}); + +test('audit changes exclude database configuration credentials on create and delete', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + AuditEvent::query()->delete(); + + $database = StandaloneRedis::query()->create([ + 'name' => 'Redis', + 'environment_id' => $environment->id, + 'destination_type' => Server::class, + 'destination_id' => 0, + 'redis_conf' => 'requirepass embedded-redis-password', + ]); + $database->delete(); + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events)->toHaveCount(2); + foreach ($events as $event) { + expect($event->changes)->toHaveKey('name')->not->toHaveKey('redis_conf'); + } +}); + +test('audit metadata redacts nested raw configuration credentials', function () { + auditLog('ui.server.updated', [ + 'team_id' => $this->team->id, + 'configuration' => ['last_saved_proxy_configuration' => 'PASSWORD=must-not-be-recorded'], + ]); + + expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([ + 'last_saved_proxy_configuration' => '[REDACTED]', + ]); +}); + +test('audit changes keep model default hidden fields excluded after makeVisible', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + $application->makeVisible(['domain_dns_statuses']); + + $application->update([ + 'name' => 'Updated application', + 'domain_dns_statuses' => ['example.com' => 'private DNS data'], + ]); + + expect(AuditEvent::query()->sole()->changes)->not->toHaveKey('domain_dns_statuses'); +}); + +test('audit changes redact credentials embedded in nested proxy configuration', function () { + Server::flushIdentityMap(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=old-secret']; + $server->save(); + AuditEvent::query()->delete(); + + $server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=new-secret']; + $server->save(); + + expect(AuditEvent::query()->sole()->changes['proxy'])->toBe([ + 'old' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'], + 'new' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'], + ]); +}); + test('audit metadata redacts secrets in serializable objects', function (Closure $configuration) { auditLog('ui.server.updated', [ 'team_id' => $this->team->id,