diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 62c2a16d82..c7fa3dc2b7 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +10,8 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use JsonSerializable; +use stdClass; use Throwable; class AuditEvent extends Model @@ -231,6 +234,14 @@ class AuditEvent extends Model return '[REDACTED]'; } + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } elseif ($value instanceof JsonSerializable) { + $value = $value->jsonSerialize(); + } elseif ($value instanceof stdClass) { + $value = (array) $value; + } + if (! is_array($value)) { return $value; } diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php index 2038103bba..f37f796226 100644 --- a/app/Traits/Auditable.php +++ b/app/Traits/Auditable.php @@ -7,7 +7,9 @@ use App\Models\PersonalAccessToken; use App\Models\Team; use Closure; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Throwable; trait Auditable { @@ -100,15 +102,25 @@ trait Auditable */ private function auditChanges(string $action, array $fields): array { - return collect($fields) - ->reject(fn (string $field): bool => $this->isExcludedAuditField($field)) - ->mapWithKeys(fn (string $field): array => [ - $field => [ - 'old' => $action === 'created' ? null : AuditEvent::redact($this->getOriginal($field)), - 'new' => $action === 'deleted' ? null : AuditEvent::redact($this->getAttribute($field)), - ], - ]) - ->all(); + try { + return collect($fields) + ->reject(fn (string $field): bool => $this->isExcludedAuditField($field)) + ->mapWithKeys(fn (string $field): array => [ + $field => [ + 'old' => $action === 'created' ? null : AuditEvent::redact($this->getOriginal($field)), + 'new' => $action === 'deleted' ? null : AuditEvent::redact($this->getAttribute($field)), + ], + ]) + ->all(); + } catch (Throwable $exception) { + Log::warning('Audit change preparation failed', [ + 'resource_type' => Str::snake(class_basename($this)), + 'action' => $action, + 'exception' => $exception::class, + ]); + + return []; + } } private function isExcludedAuditField(string $field): bool diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php index 8bae3fa71e..337658c5e0 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -31,6 +31,8 @@ use App\Models\User; use App\Traits\Auditable; use Illuminate\Auth\Events\Failed; use Illuminate\Auth\Events\Logout; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; @@ -39,6 +41,19 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Once; use Livewire\Livewire; +class FailingAuditAttributeCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + throw new RuntimeException('sensitive attribute value'); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + return $value; + } +} + uses(RefreshDatabase::class); beforeEach(function () { @@ -218,6 +233,72 @@ test('auditable models exclude hidden and encrypted attributes from changes', fu ->not->toContain('super-secret'); }); +test('auditable models redact nested secrets in object casts', function () { + Server::flushIdentityMap(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->proxy = [ + 'type' => 'TRAEFIK', + 'credentials' => ['password' => 'old-proxy-password'], + ]; + $server->save(); + AuditEvent::query()->delete(); + + $server->proxy = [ + 'type' => 'TRAEFIK', + 'credentials' => ['password' => 'new-proxy-password'], + ]; + $server->save(); + + $changes = AuditEvent::query()->sole()->changes; + + expect($changes['proxy'])->toBe([ + 'old' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'], + 'new' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'], + ]); +}); + +test('audit metadata redacts secrets in serializable objects', function (Closure $configuration) { + auditLog('ui.server.updated', [ + 'team_id' => $this->team->id, + 'configuration' => $configuration(), + ]); + + expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([ + 'enabled' => true, + 'nested' => ['password' => '[REDACTED]'], + ]); +})->with([ + 'plain object' => fn () => (object) [ + 'enabled' => true, + 'nested' => (object) ['password' => 'must-not-be-recorded'], + ], + 'JSON serializable object' => fn () => new class implements JsonSerializable + { + public function jsonSerialize(): mixed + { + return ['enabled' => true, 'nested' => ['password' => 'must-not-be-recorded']]; + } + }, +]); + +test('audit change preparation failures do not fail model writes', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + AuditEvent::query()->delete(); + $project->mergeCasts(['description' => FailingAuditAttributeCast::class]); + + $project->update(['description' => 'Persist this description']); + + expect(Project::query()->findOrFail($project->id)->description)->toBe('Persist this description') + ->and(AuditEvent::query()->sole()->changes)->toBe([]); + + Log::shouldHaveReceived('warning')->once()->with( + 'Audit change preparation failed', + Mockery::on(fn (array $context): bool => $context['action'] === 'updated' + && $context['exception'] === RuntimeException::class + && array_keys($context) === ['resource_type', 'action', 'exception']), + ); +}); + test('auditable models exclude timestamp attributes from changes', function () { $project = Project::factory()->create(['team_id' => $this->team->id]); $environment = Environment::factory()->create(['project_id' => $project->id]);