mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(audit): redact object values and isolate change preparation failures
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user