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:
Andras Bacsai
2026-10-04 12:26:37 +02:00
parent 4759b7bcf7
commit 5805a28872
3 changed files with 110 additions and 2 deletions
+28 -1
View File
@@ -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]';
}
+2 -1
View File
@@ -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:')));
}
+80
View File
@@ -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,