mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(audit): redact raw credential fields and keep hidden fields excluded
Raw commands, URLs and configuration blobs can contain credentials even when their field names don't look sensitive. Add a list of these fields to AuditEvent (git_full_url, build/start commands, database confs, proxy configuration, compose files, environment variables, etc.). Expose AuditEvent::isSensitiveField() so redaction and audit change capture use the same check. Auditable now excludes a model's class-level $hidden fields as well as its runtime hidden fields. A field stays out of audit changes even after makeVisible() is called on the instance. Add regression tests for credentials embedded in raw fields, database config on create/delete, nested metadata and proxy configuration, and hidden fields after makeVisible().
This commit is contained in:
@@ -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<int, string>
|
||||
*/
|
||||
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]';
|
||||
}
|
||||
|
||||
|
||||
@@ -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:')));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user