fix(audit): redact object values and isolate change preparation failures

This commit is contained in:
Andras Bacsai
2026-10-04 12:26:06 +02:00
parent 83d19800a6
commit 4759b7bcf7
3 changed files with 113 additions and 9 deletions
+11
View File
@@ -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;
}
+21 -9
View File
@@ -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
+81
View File
@@ -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]);