feat(logging): strengthen secret redaction and add admin controls (#12151)

This commit is contained in:
Andras Bacsai
2026-10-13 20:43:19 +02:00
committed by GitHub
parent 9302395063
commit 448dc8ea0a
92 changed files with 4582 additions and 417 deletions
+5
View File
@@ -106,6 +106,11 @@
- The parsers add sudo only to line starts (and after `&&`, `||`, `|`, `$(`). Put `if`/`else` branches on their own lines. When a redirect or `cd` must stay, make the whole line one `sh -c '...'` script; it runs as `sudo sh -c` without inner sudo or bash.
- Tests that replace `DatabaseStartCommandExecutor` or use root servers miss non-root bugs. Test the parsed commands of a non-root server.
## Redact sensitive output before every sink
- Use one fail-closed redaction policy for database logs, framework logs, notifications, API output, exports, and MCP responses.
- Replace known resource secrets before storage; format detection is only a second layer.
- Keep read-time redaction for legacy records, but do not use it as the primary control.
## Cached and stored state
- `StandaloneDocker`/`SwarmDocker` `server` uses `Server::findCached()`, a per-process identity map that is flushed only when the `servers` row updates. A `ServerSetting` save does not flush it, so pass the current `Server` instance (`setRelation('server', $server)`) when generated output depends on just-saved settings.
- Application deployments use the stored `custom_labels` when they exist. When a server-level setting changes the generated labels, regenerate the stored labels of read-only (`is_container_label_readonly_enabled`) applications, or a redeploy keeps the old labels.
+147 -17
View File
@@ -6,19 +6,33 @@ use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Helpers\SshMultiplexingHelper;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\DatabaseImport\DatabaseImportCleanup;
use App\Support\RemoteProcessCommand;
use App\Support\ResourceStartActivity;
use App\Traits\BroadcastsToTeam;
use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Process\Exceptions\ProcessTimedOutException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException;
use Throwable;
class RunRemoteProcess
{
/**
* A private key block that has no END line yet.
*/
private const UNFINISHED_PRIVATE_KEY_PATTERN = '/-----BEGIN [A-Z ]*PRIVATE KEY-----(?![\s\S]*-----END [A-Z ]*PRIVATE KEY-----)[\s\S]*/';
public Activity $activity;
public bool $hide_from_output;
@@ -39,6 +53,21 @@ class RunRemoteProcess
protected int $counter = 1;
/**
* Output of each stream that is not redacted yet, because its line or private key block is not complete.
*
* @var array<string, string>
*/
private array $pendingOutput = [];
/**
* Environment values of the activity's resource to hide in its output, resolved once.
* False when resolving them failed, so the output is fully redacted.
*
* @var array{known: list<string>, locked: list<string>}|false|null
*/
private array|false|null $logSecrets = null;
/**
* Create a new job instance.
*/
@@ -83,16 +112,23 @@ class RunRemoteProcess
$status = ProcessStatus::IN_PROGRESS;
$timeout = config('constants.ssh.command_timeout');
$process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...));
// Start from the stored properties and save now, so later output writes only change the log and
// never write back an old copy over a status that Coolify set meanwhile (for example a stopped import).
$stored = Activity::query()->whereKey($this->activity->getKey())->first()?->properties ?? $this->activity->properties;
$this->activity->properties = $stored->merge([
'process_id' => $process->id(),
]);
$this->activity->save();
try {
$process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...));
// Start from the stored properties and save now, so later output writes only change the log and
// never write back an old copy over a status that Coolify set meanwhile (for example a stopped import).
$stored = Activity::query()->whereKey($this->activity->getKey())->first()?->properties ?? $this->activity->properties;
$this->activity->properties = $stored->merge([
'process_id' => $process->id(),
]);
$this->activity->save();
$processResult = $process->wait();
$processResult = $process->wait();
} catch (ProcessTimedOutException|SymfonyProcessTimedOutException) {
// The timeout message contains the full SSH command, which can contain secrets.
throw new \RuntimeException("The command on the server timed out after {$timeout} seconds.", 124);
} finally {
$this->flushPendingOutput();
}
// A database import whose SSH command ended without the restore result keeps blocking the
// database until its restore is stopped. The stop event also cleans up, so the normal
@@ -103,13 +139,13 @@ class RunRemoteProcess
&& DatabaseImportCleanup::stopAfterTransportFailure($this->activity, $processResult->exitCode())) {
$this->activity->properties = $this->activity->properties->merge([
'exitCode' => $processResult->exitCode(),
'stdout' => $processResult->output(),
'stderr' => $processResult->errorOutput(),
'stdout' => $this->redact($processResult->output()),
'stderr' => $this->redact($processResult->errorOutput()),
]);
$this->activity->save();
if (! $this->ignore_errors) {
throw new \RuntimeException($processResult->errorOutput(), $processResult->exitCode());
throw new \RuntimeException($this->redact($processResult->errorOutput()), $processResult->exitCode());
}
return $processResult;
@@ -127,8 +163,8 @@ class RunRemoteProcess
$properties = [
'exitCode' => $processResult->exitCode(),
'stdout' => $processResult->output(),
'stderr' => $processResult->errorOutput(),
'stdout' => $this->redact($processResult->output()),
'stderr' => $this->redact($processResult->errorOutput()),
'status' => $status->value,
];
@@ -148,12 +184,12 @@ class RunRemoteProcess
if ($this->call_event_on_finish) {
try {
self::dispatchFinishEvent($this->activity, $this->call_event_on_finish, $this->call_event_data);
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::error('Error calling event: '.$e->getMessage());
}
}
if ($processResult->exitCode() != 0 && ! $this->ignore_errors) {
throw new \RuntimeException($processResult->errorOutput(), $processResult->exitCode());
throw new \RuntimeException($this->redact($processResult->errorOutput()), $processResult->exitCode());
}
return $processResult;
@@ -192,6 +228,11 @@ class RunRemoteProcess
if ($this->hide_from_output) {
return;
}
$this->pendingOutput[$type] = ($this->pendingOutput[$type] ?? '').$output;
$output = self::takeCompleteOutput($this->pendingOutput[$type]);
if ($output === '') {
return;
}
$this->current_time = $this->elapsedTime();
$this->activity->description = $this->encodeOutput($type, $output);
if ($this->isAfterLastThrottle()) {
@@ -203,6 +244,51 @@ class RunRemoteProcess
}
}
/**
* Write the output that is still buffered when the process ends, also after a timeout or failure.
*/
private function flushPendingOutput(): void
{
$written = false;
foreach ($this->pendingOutput as $type => $pending) {
$output = self::takeCompleteOutput($this->pendingOutput[$type], final: true);
if ($output !== '') {
$this->activity->description = $this->encodeOutput($type, $output);
$written = true;
}
}
if ($written) {
$this->activity->save();
}
}
/**
* Take the complete lines from the buffered output of a stream, so a secret split across output
* chunks is redacted as a whole. A private key block stays buffered until its END line. With
* $final, all buffered output is taken, and an unfinished private key block is hidden.
*/
public static function takeCompleteOutput(string &$pending, bool $final = false): string
{
if ($final) {
$output = preg_replace(self::UNFINISHED_PRIVATE_KEY_PATTERN, REDACTED, $pending) ?? REDACTED;
$pending = '';
return $output;
}
$end = max(strrpos($pending, "\n"), strrpos($pending, "\r"));
if ($end === false) {
return '';
}
$output = substr($pending, 0, $end + 1);
if (preg_match(self::UNFINISHED_PRIVATE_KEY_PATTERN, $output, $match, PREG_OFFSET_CAPTURE) === 1) {
$output = substr($output, 0, $match[0][1]);
}
$pending = substr($pending, strlen($output));
return $output;
}
protected function elapsedTime(): int
{
$timeMs = (hrtime(true) - $this->time_start) / 1_000_000;
@@ -215,7 +301,7 @@ class RunRemoteProcess
$outputStack = json_decode($this->activity->description, associative: true, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$outputStack[] = [
'type' => $type,
'output' => $output,
'output' => $this->redact($output),
'timestamp' => hrtime(true),
'batch' => ApplicationDeploymentJob::$batch_counter,
'order' => $this->getLatestCounter(),
@@ -234,6 +320,50 @@ class RunRemoteProcess
return end($description)['order'] + 1;
}
private function redact(string $value): string
{
$this->logSecrets ??= $this->resolveLogSecrets();
if ($this->logSecrets === false) {
return REDACTED;
}
return resolve(SensitiveDataRedactor::class)->redactText($value, $this->logSecrets['known'], $this->logSecrets['locked']);
}
/**
* The environment values of the application or service this activity runs for, following
* the resource's log redaction setting.
*
* @return array{known: list<string>, locked: list<string>}|false
*/
private function resolveLogSecrets(): array|false
{
try {
return EnvironmentVariable::logRedactionSecretsFor($this->logResource());
} catch (Throwable) {
return false;
}
}
private function logResource(): Application|Service|null
{
$subject = $this->activity->subject_type !== null ? $this->activity->subject : null;
if ($subject instanceof ServiceApplication || $subject instanceof ServiceDatabase) {
return $subject->service;
}
if ($subject instanceof Application || $subject instanceof Service) {
return $subject;
}
$typeUuid = $this->activity->getExtraProperty('type_uuid');
if (! is_string($typeUuid) || $typeUuid === '') {
return null;
}
return Service::query()->where('uuid', $typeUuid)->first()
?? Application::query()->where('uuid', $typeUuid)->first();
}
/**
* Determines if it's time to write again to database.
*
+2 -2
View File
@@ -180,7 +180,7 @@ class CleanupDocker implements ShouldBeUnique
if ($commandOutput !== null) {
$cleanupLog[] = [
'command' => $command,
'output' => $commandOutput,
'output' => remove_iip($commandOutput),
];
}
}
@@ -363,7 +363,7 @@ class CleanupDocker implements ShouldBeUnique
$deleteOutput = $this->runRemoteCommand($deleteCommand, $server);
$cleanupLog[] = [
'command' => $deleteCommand,
'output' => $deleteOutput ?? 'Images removed or were in use',
'output' => remove_iip($deleteOutput ?? 'Images removed or were in use'),
];
}
+1 -1
View File
@@ -108,7 +108,7 @@ class DeleteServer
// Notify the team about the failure
$team = Team::find($teamId);
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, remove_iip($e->getMessage())));
}
}
@@ -50,6 +50,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'is_env_sorting_enabled',
'redact_all_env_values_in_logs',
'is_pr_deployments_public_enabled',
'stop_grace_period',
'docker_images_to_keep',
@@ -76,6 +77,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'is_env_sorting_enabled',
'redact_all_env_values_in_logs',
'is_pr_deployments_public_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
@@ -429,6 +431,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
@@ -629,6 +632,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
@@ -829,6 +833,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
@@ -1000,6 +1005,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
@@ -1167,6 +1173,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
@@ -2700,7 +2707,10 @@ class ApplicationsController extends Controller
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps);
$logs = sanitizeLogsForExport(
getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps),
EnvironmentVariable::logRedactionSecretsFor($application, includePreview: $pullRequestId !== null),
);
return response()->json([
'logs' => $logs,
@@ -3172,6 +3182,7 @@ class ApplicationsController extends Controller
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'],
'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'],
'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'],
@@ -3278,6 +3289,10 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
if ($request->has('redact_all_env_values_in_logs')) {
$this->authorize('manageEnvironment', $application);
}
if ($request->instant_deploy) {
abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy');
$this->authorize('deploy', $application);
@@ -18,6 +18,7 @@ use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
@@ -355,11 +356,19 @@ class DatabasesController extends Controller
}
$databaseIds = $databases->pluck('id')->toArray();
$databasesByMorphKey = $databases->keyBy(fn ($database) => $database->getMorphClass().':'.$database->id);
$messageSecrets = [];
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['latest_log', 's3', 's3Storages'])
->whereIn('database_id', $databaseIds)
->get()
->map(fn (ScheduledDatabaseBackup $backup) => $this->withBackupS3StorageUuids($backup))
->map(function (ScheduledDatabaseBackup $backup) use ($databasesByMorphKey, &$messageSecrets): ScheduledDatabaseBackup {
if ($backup->latest_log) {
$this->redactBackupExecutionMessage($backup->latest_log, $databasesByMorphKey->get($backup->database_type.':'.$backup->database_id), $messageSecrets);
}
return $this->withBackupS3StorageUuids($backup);
})
->groupBy(fn (ScheduledDatabaseBackup $backup) => $backup->database_type.':'.$backup->database_id);
$databases = $databases->map(function ($database) use ($backupConfigs) {
@@ -431,8 +440,15 @@ class DatabasesController extends Controller
$this->authorize('view', $database);
$messageSecrets = [];
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['executions', 's3', 's3Storages'])->where('database_id', $database->id)->get()
->map(fn (ScheduledDatabaseBackup $backup) => $this->withBackupS3StorageUuids($backup));
->map(function (ScheduledDatabaseBackup $backup) use ($database, &$messageSecrets): ScheduledDatabaseBackup {
$backup->executions->each(function (ScheduledDatabaseBackupExecution $execution) use ($database, &$messageSecrets): void {
$this->redactBackupExecutionMessage($execution, $database, $messageSecrets);
});
return $this->withBackupS3StorageUuids($backup);
});
return response()->json($backupConfig);
}
@@ -573,6 +589,7 @@ class DatabasesController extends Controller
'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions.'],
],
),
)
@@ -627,6 +644,7 @@ class DatabasesController extends Controller
'limits_cpus' => 'string',
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'redact_all_env_values_in_logs' => 'boolean',
]);
if ($validator->fails()) {
@@ -636,6 +654,7 @@ class DatabasesController extends Controller
], 422);
}
$uuid = $request->uuid;
$redactAllEnvValuesInLogs = $request->has('redact_all_env_values_in_logs') ? $request->boolean('redact_all_env_values_in_logs') : null;
removeUnnecessaryFieldsFromRequest($request);
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
@@ -644,6 +663,10 @@ class DatabasesController extends Controller
$this->authorize('update', $database);
if ($redactAllEnvValuesInLogs !== null) {
$this->authorize('manageEnvironment', $database);
}
if ($request->is_public && $request->public_port) {
if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) {
return response()->json(['message' => 'Public port already used by another database.'], 400);
@@ -876,6 +899,9 @@ class DatabasesController extends Controller
}
// Only update database fields, not backup configuration
if ($redactAllEnvValuesInLogs !== null) {
$database->redact_all_env_values_in_logs = $redactAllEnvValuesInLogs;
}
$database->update($request->only($allowedFields));
if ($whatToDoWithDatabaseProxy === 'start') {
@@ -889,7 +915,10 @@ class DatabasesController extends Controller
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
'changed_fields' => [
...array_values(array_intersect($allowedFields, array_keys($request->all()))),
...($redactAllEnvValuesInLogs === null ? [] : ['redact_all_env_values_in_logs']),
],
]);
return response()->json([
@@ -2783,7 +2812,10 @@ class DatabasesController extends Controller
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps);
$logs = sanitizeLogsForExport(
getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps),
EnvironmentVariable::logRedactionSecretsFor($database),
);
return response()->json([
'logs' => $logs,
@@ -3145,7 +3177,7 @@ class DatabasesController extends Controller
#[OA\Get(
summary: 'List backup executions',
description: 'Get all executions for a specific backup configuration.',
description: 'Get all executions for a specific backup configuration. The execution `message` is only returned (redacted) to tokens with the `read:sensitive` or `root` ability; otherwise it is `null`.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions',
operationId: 'list-backup-executions',
security: [
@@ -3185,7 +3217,7 @@ class DatabasesController extends Controller
new OA\Property(property: 'filename', type: 'string'),
new OA\Property(property: 'size', type: 'integer'),
new OA\Property(property: 'created_at', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'message', type: 'string', nullable: true, description: 'Redacted execution output. Null unless the token has the `read:sensitive` or `root` ability.'),
new OA\Property(property: 'status', type: 'string'),
]
)
@@ -3226,10 +3258,13 @@ class DatabasesController extends Controller
}
// Get all executions for this backup configuration
$messageSecrets = [];
$executions = $backup->executions()
->orderBy('created_at', 'desc')
->get()
->map(function ($execution) {
->map(function ($execution) use ($database, &$messageSecrets) {
$this->redactBackupExecutionMessage($execution, $database, $messageSecrets);
return [
'uuid' => $execution->uuid,
'filename' => $execution->filename,
@@ -5282,4 +5317,34 @@ class DatabasesController extends Controller
'message' => 'Database cloned.',
], 201);
}
/**
* Execution output can embed credentials: hide it unless the token can read sensitive data, and redact it when shown.
* The database secrets are loaded once per database and kept in $secretsByDatabase for the rest of the request.
*
* @param array<string, array{known: array<int, string>, locked: array<int, string>}|null> $secretsByDatabase
*/
private function redactBackupExecutionMessage(ScheduledDatabaseBackupExecution $execution, ?Model $database, array &$secretsByDatabase): void
{
$message = $execution->message;
if (request()->attributes->get('can_read_sensitive', false) !== true || ! is_string($message) || ! $database) {
$execution->setAttribute('message', null);
return;
}
$key = $database->getMorphClass().':'.$database->getKey();
if (! array_key_exists($key, $secretsByDatabase)) {
try {
// Use a copy so the relations loaded for redaction do not end up in the response.
$secretsByDatabase[$key] = EnvironmentVariable::logRedactionSecretsFor(clone $database);
} catch (\Throwable $e) {
report($e);
$secretsByDatabase[$key] = null;
}
}
// Without the database secrets the message cannot be redacted reliably, so hide it.
$execution->setAttribute('message', $secretsByDatabase[$key] === null ? null : sanitizeLogsForExport($message, $secretsByDatabase[$key]));
}
}
@@ -7,6 +7,7 @@ use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Service\UpdateServiceApplicationFromApi;
use App\Http\Controllers\Controller;
use App\Models\EnvironmentVariable;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -532,7 +533,10 @@ class ServiceApplicationsController extends Controller
}
$lines = normalizeLogLines($request->query('lines'));
$logs = getContainerLogs($server, $containerName, $lines);
$logs = sanitizeLogsForExport(
getContainerLogs($server, $containerName, $lines),
EnvironmentVariable::logRedactionSecretsFor($serviceApplication->service),
);
return response()->json([
'logs' => $logs,
@@ -8,6 +8,7 @@ use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Http\Controllers\Controller;
use App\Models\EnvironmentVariable;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Http\JsonResponse;
@@ -401,7 +402,10 @@ class ServiceDatabasesController extends Controller
$lines = normalizeLogLines($request->query('lines'));
return response()->json([
'logs' => getContainerLogs($server, $containerName, $lines),
'logs' => sanitizeLogsForExport(
getContainerLogs($server, $containerName, $lines),
EnvironmentVariable::logRedactionSecretsFor($serviceDatabase->service),
),
]);
}
@@ -321,6 +321,7 @@ class ServicesController extends Controller
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'default' => true, 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
],
),
@@ -388,7 +389,7 @@ class ServicesController extends Controller
)]
public function create_service(Request $request)
{
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'tags'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -422,6 +423,7 @@ class ServicesController extends Controller
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
];
@@ -554,6 +556,9 @@ class ServicesController extends Controller
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
if ($request->has('redact_all_env_values_in_logs')) {
$service->redact_all_env_values_in_logs = $request->boolean('redact_all_env_values_in_logs');
}
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@@ -622,7 +627,7 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'tags'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -641,6 +646,7 @@ class ServicesController extends Controller
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
];
@@ -757,6 +763,9 @@ class ServicesController extends Controller
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
if ($request->has('redact_all_env_values_in_logs')) {
$service->redact_all_env_values_in_logs = $request->boolean('redact_all_env_values_in_logs');
}
$service->save();
$service->parse(isNew: true);
@@ -985,7 +994,10 @@ class ServicesController extends Controller
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
$logs = sanitizeLogsForExport(
getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps),
EnvironmentVariable::logRedactionSecretsFor($service),
);
return response()->json([
'logs' => $logs,
@@ -1132,6 +1144,7 @@ class ServicesController extends Controller
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'default' => true, 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'],
],
)
),
@@ -1220,12 +1233,16 @@ class ServicesController extends Controller
$this->authorize('update', $service);
if ($request->has('redact_all_env_values_in_logs')) {
$this->authorize('manageEnvironment', $service);
}
if ($request->boolean('instant_deploy')) {
abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy');
$this->authorize('deploy', $service);
}
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs'];
$validationRules = [
'name' => 'string|max:255',
@@ -1239,6 +1256,7 @@ class ServicesController extends Controller
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -1306,6 +1324,9 @@ class ServicesController extends Controller
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
if ($request->has('redact_all_env_values_in_logs')) {
$service->redact_all_env_values_in_logs = $request->boolean('redact_all_env_values_in_logs');
}
$previousDnsHostnames = $this->serviceApplicationDnsHostnames($service);
$service->save();
+3 -9
View File
@@ -6025,7 +6025,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
{
$this->failDeployment();
// Log comprehensive error information
// failed() runs on a fresh job instance, so remote secrets fetched during the deployment are not loaded here.
// Every entry still passes through ApplicationDeploymentQueue::redactLogText(); raw stack traces are not written
// because their truncated arguments can hold partial secrets that redaction cannot match.
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
$errorCode = $exception->getCode();
$errorClass = get_class($exception);
@@ -6053,14 +6055,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true);
$this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true);
}
// Log first few lines of stack trace for debugging
$trace = $exception->getTraceAsString();
$traceLines = explode("\n", $trace);
$this->application_deployment_queue->addLogEntry('Stack trace (first 5 lines):', 'stderr', hidden: true);
foreach (array_slice($traceLines, 0, 5) as $traceLine) {
$this->application_deployment_queue->addLogEntry(" {$traceLine}", 'stderr', hidden: true);
}
$this->application_deployment_queue->addLogEntry('========================================', 'stderr');
if ($this->application->build_pack !== 'dockercompose') {
+6 -3
View File
@@ -99,14 +99,17 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
'job' => 'CoolifyTask',
'activity_id' => $this->activity->id,
'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
'error' => $exception?->getMessage(),
'error' => $exception ? remove_iip($exception->getMessage()) : null,
'total_attempts' => $this->attempts(),
'trace' => $exception?->getTraceAsString(),
]);
// A database import whose restore can still run in the database container stays in progress
// until its stop has run, so it keeps blocking other operations on the database.
if (DatabaseImportCleanup::stopAfterTaskFailure($this->activity, $exception?->getMessage() ?: 'Job permanently failed')) {
// The message can contain command output or the SSH command, so store only a redacted copy.
$message = filled($exception?->getMessage()) ? remove_iip($exception->getMessage()) : null;
if (DatabaseImportCleanup::stopAfterTaskFailure($this->activity, $message ?: 'Job permanently failed')) {
RemoteProcessCommand::forget($this->activity);
return;
@@ -118,7 +121,7 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
$stopRequested = DatabaseImportCleanup::stopRequested($this->activity);
$this->activity->properties = $this->activity->properties->merge(array_filter([
'status' => ProcessStatus::ERROR->value,
'error' => $stopRequested ? null : ($exception?->getMessage() ?? 'Job permanently failed'),
'error' => $stopRequested ? null : ($message ?? 'Job permanently failed'),
'failed_at' => now()->toIso8601String(),
], fn ($value) => $value !== null));
$this->activity->save();
+35 -8
View File
@@ -20,6 +20,7 @@ use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\BackupCompression;
use App\Support\ClickhouseBackupCommand;
use App\Support\DatabaseOperationReservation;
@@ -454,10 +455,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} catch (Throwable $e) {
// Local backup failed: the partial file is not tracked, so retention would never remove it.
deleteBackupsLocally($this->backup_location, $this->server);
$failureMessage = $this->redact($this->error_output ?? $this->backup_output ?? $e->getMessage());
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'message' => $failureMessage,
'size' => $size,
'filename' => null,
's3_uploaded' => null,
@@ -465,7 +467,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
]);
}
try {
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
$this->team?->notify(new BackupFailed($this->backup, $this->database, $failureMessage, $database));
} catch (Throwable $notifyException) {
Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
'backup_id' => $this->backup->uuid,
@@ -490,13 +492,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
} catch (Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
$s3UploadError = $this->redact($e->getMessage());
}
}
// Step 3: Update status and send notifications based on results
if ($localBackupSucceeded) {
$message = $this->backup_output;
$message = $this->backup_output === null ? null : $this->redact($this->backup_output);
if ($s3UploadError) {
$message = $message
@@ -867,7 +869,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->upload_to_s3($storage);
$uploaded = true;
} catch (Throwable $e) {
$uploadError = $e->getMessage();
$uploadError = $this->redact($e->getMessage());
$failures[] = "{$storage->name}: {$uploadError}";
}
@@ -878,7 +880,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'message' => $uploadError,
]);
} catch (Throwable $e) {
$failures[] = "{$storage->name}: {$e->getMessage()}";
$failures[] = "{$storage->name}: {$this->redact($e->getMessage())}";
}
}
@@ -1100,7 +1102,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
foreach ($unfinished as $execution) {
$execution->update([
'status' => 'failed',
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
'message' => $this->redact('Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error')),
'size' => 0,
'filename' => null,
'finished_at' => Carbon::now(),
@@ -1111,7 +1113,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
$output = $this->redact($this->backup_output ?? $exception?->getMessage() ?? 'Unknown error');
$databaseNames = $unfinished->isEmpty() ? ['unknown'] : $unfinished->map(fn (ScheduledDatabaseBackupExecution $execution) => $execution->database_name ?? 'unknown');
foreach ($databaseNames as $databaseName) {
try {
@@ -1125,6 +1127,31 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
/**
* Redact the backed-up database credentials and the key and secret of every S3 destination from text that is stored or sent in notifications.
*/
private function redact(string $text): string
{
try {
$database = $this->database ?? $this->backup->database;
$s3 = $this->s3 ?? $this->backup->s3;
$lockedSecrets = [$this->postgres_password, $this->mongo_root_password, $s3?->key, $s3?->secret];
foreach ($this->backup->selectedS3Storages() as $storage) {
$lockedSecrets[] = $storage->key;
$lockedSecrets[] = $storage->secret;
}
foreach (array_keys($database?->getAttributes() ?? []) as $attribute) {
if (str_contains($attribute, 'password')) {
$lockedSecrets[] = $database->getAttribute($attribute);
}
}
} catch (Throwable) {
return SensitiveDataRedactor::FAILURE_MESSAGE;
}
return app(SensitiveDataRedactor::class)->redactText($text, lockedSecrets: array_filter($lockedSecrets, fn ($secret): bool => is_string($secret) && $secret !== ''));
}
/**
* @return Collection<int, ScheduledDatabaseBackupExecution>
*/
+1 -1
View File
@@ -115,7 +115,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$server = data_get($this->resource, 'destination.server');
if ($server) {
$this->resource->team()?->notify(new GeneralNotification(
"'{$this->resource->name}' was removed from Coolify, but its Docker resources could not be removed from server '{$server->name}' and may still exist there. Error: {$e->getMessage()}",
"'{$this->resource->name}' was removed from Coolify, but its Docker resources could not be removed from server '{$server->name}' and may still exist there. Error: ".remove_iip($e->getMessage()),
success: false,
));
}
+4 -3
View File
@@ -187,14 +187,15 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
}
} catch (\Throwable $e) {
$failed = true;
$errorMessage = remove_iip($e->getMessage());
if ($this->execution_log) {
$this->execution_log->update([
'status' => 'failed',
'message' => $e->getMessage(),
'message' => $errorMessage,
]);
event(new DockerCleanupDone($this->execution_log));
}
$this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$e->getMessage()));
$this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$errorMessage));
throw $e;
} finally {
if (! $failed && $this->occurrenceUuid) {
@@ -246,7 +247,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$message = $exception?->getMessage() ?? 'Docker cleanup job failed without an exception.';
$message = remove_iip($exception?->getMessage() ?? 'Docker cleanup job failed without an exception.');
$updated = DockerCleanupExecution::query()
->whereKey($execution->id)
+2 -2
View File
@@ -120,7 +120,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
->where('status', GithubRunnerStatus::Provisioning)
->update([
'status' => GithubRunnerStatus::Failed,
'error_message' => "Could not start a runner after {$attempt} attempts: {$e->getMessage()}",
'error_message' => remove_iip("Could not start a runner after {$attempt} attempts: {$e->getMessage()}"),
'completed_at' => now(),
]);
CleanupGithubRunnerJob::dispatch($execution->id);
@@ -140,7 +140,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
'runner_name' => null,
'runner_id' => null,
'ready_at' => null,
'error_message' => "Attempt {$attempt} of ".self::MAX_ATTEMPTS." failed: {$e->getMessage()}",
'error_message' => remove_iip("Attempt {$attempt} of ".self::MAX_ATTEMPTS." failed: {$e->getMessage()}"),
]);
if (! $retried) {
return;
+44 -9
View File
@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Events\ScheduledTaskDone;
use App\Exceptions\NonReportableException;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
@@ -13,6 +14,7 @@ use App\Models\Team;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -67,6 +69,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
public ?string $task_output = null;
/** @var array{known: array<int, string>, locked: array<int, string>}|null */
private ?array $logRedactionSecrets = null;
public array $containers = [];
public string $server_timezone = 'UTC';
@@ -178,7 +183,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
$exec = $this->boundedTaskCommand($execCommand);
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
// See: https://github.com/coollabsio/coolify/issues/6736
$this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->task->timeout ?? 300, disableMultiplexing: true);
$this->task_output = $this->redact(instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->task->timeout ?? 300, disableMultiplexing: true) ?? '');
$this->task_log->update([
'status' => 'success',
'message' => $this->task_output,
@@ -194,10 +199,11 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?');
} catch (\Throwable $e) {
$failed = true;
$safeErrorMessage = $this->redact($e->getMessage());
if ($this->task_log) {
$this->task_log->update([
'status' => 'failed',
'message' => $this->task_output ?? $e->getMessage(),
'message' => $this->task_output ?? $safeErrorMessage,
]);
}
@@ -208,13 +214,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
'error' => $safeErrorMessage,
]);
// Only notify and throw on final failure
// Re-throw to trigger Laravel's retry mechanism with backoff
throw $e;
throw $this->redactedException($e, $safeErrorMessage);
} finally {
if (! $failed && $this->occurrenceUuid) {
app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid);
@@ -244,6 +250,32 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code";
}
private function redact(string $value): string
{
try {
$this->logRedactionSecrets ??= EnvironmentVariable::logRedactionSecretsFor($this->resource);
} catch (\Throwable) {
return REDACTED;
}
return resolve(SensitiveDataRedactor::class)->redactText($value, $this->logRedactionSecrets['known'], $this->logRedactionSecrets['locked']);
}
/**
* Keep the original exception when redaction did not change its message.
* Otherwise, drop the original (and its trace) so the secret is not stored in failed_jobs.
*/
private function redactedException(\Throwable $exception, string $safeMessage): \Throwable
{
if ($safeMessage === $exception->getMessage()) {
return $exception;
}
return $exception instanceof NonReportableException
? new NonReportableException($safeMessage, (int) $exception->getCode())
: new \RuntimeException($safeMessage, (int) $exception->getCode());
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
@@ -262,6 +294,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
}
$this->team ??= Team::find($this->task->team_id);
$this->resource ??= $this->task->application ?? $this->task->service;
$safeErrorMessage = $this->redact($exception?->getMessage() ?? 'Unknown error');
$safeTrace = $this->redact($exception?->getTraceAsString() ?? '');
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob',
@@ -269,8 +304,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'total_attempts' => $this->attempts(),
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
'error' => $safeErrorMessage,
'trace' => $safeTrace,
]);
// After a worker timeout only the queued uuid identifies this run's execution.
@@ -282,13 +317,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
if ($execution) {
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
if ($exception) {
$errorMessage .= ': '.$exception->getMessage();
$errorMessage .= ': '.$safeErrorMessage;
}
$execution->update([
'status' => 'failed',
'message' => $errorMessage,
'error_details' => $exception?->getTraceAsString(),
'error_details' => $safeTrace,
'finished_at' => Carbon::now()->toImmutable(),
]);
} else {
@@ -299,6 +334,6 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
}
// Notify team about permanent failure
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
$this->team?->notify(new TaskFailed($this->task, $safeErrorMessage));
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$response = Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload());
$response = $this->sendNotificationRequest('Discord', $this->webhookUrl, fn () => Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload()));
// 401: invalid webhook token, 403: missing access, 404: unknown (deleted) webhook.
$this->handleNotificationResponse($response, 'Discord', in_array($response->status(), [401, 403, 404], true));
+1 -1
View File
@@ -45,7 +45,7 @@ class SendMessageToPushoverJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
$response = Http::post('https://api.pushover.net/1/messages.json', $this->message->toPayload($this->token, $this->user));
$response = $this->sendNotificationRequest('Pushover', 'https://api.pushover.net/1/messages.json', fn () => Http::post('https://api.pushover.net/1/messages.json', $this->message->toPayload($this->token, $this->user)));
// Pushover marks the invalid field in the error body, for example {"token": "invalid"}.
$isConfigurationError = $response->clientError()
+2 -2
View File
@@ -71,9 +71,9 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
*
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
*/
$response = $this->isSlackWebhook()
$response = $this->sendNotificationRequest('Slack', $this->webhookUrl, fn () => $this->isSlackWebhook()
? $this->sendToSlack($httpOptions)
: $this->sendToMattermost($httpOptions);
: $this->sendToMattermost($httpOptions));
// 403: posting is not allowed, 404: the webhook or channel was removed, 410: the channel is archived.
$this->handleNotificationResponse($response, 'Slack', in_array($response->status(), [403, 404, 410], true));
+1 -1
View File
@@ -78,7 +78,7 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue
if ($this->threadId) {
$payload['message_thread_id'] = $this->threadId;
}
$response = Http::post($url, $payload);
$response = $this->sendNotificationRequest('Telegram', $url, fn () => Http::post($url, $payload));
// 401: invalid bot token, 403: the bot was blocked or removed from the chat, 400 "chat not found": wrong chat ID.
$isConfigurationError = in_array($response->status(), [401, 403], true)
+1 -1
View File
@@ -71,7 +71,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$response = Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
$response = $this->sendNotificationRequest('Webhook', $this->webhookUrl, fn () => Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload));
// 401/403: the endpoint rejects us, 404/410: the endpoint does not exist anymore.
$this->handleNotificationResponse($response, 'Webhook', in_array($response->status(), [401, 403, 404, 410], true));
+32 -7
View File
@@ -13,6 +13,7 @@ use App\Notifications\VolumeBackup\BackupFailed;
use App\Notifications\VolumeBackup\BackupSuccess;
use App\Rules\SafeWebhookUrl;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\BackupCompression;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
@@ -188,6 +189,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
$warning = $warning === null ? null : $this->redact($warning);
$this->execution->update([
'status' => 'success',
'message' => $warning,
@@ -203,7 +205,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
Log::channel('scheduled-errors')->warning('Volume backup retention cleanup failed', [
'backup_id' => $this->backup->id,
'execution_id' => $this->execution->id,
'error' => $exception->getMessage(),
'error' => $this->redact($exception->getMessage()),
]);
}
} catch (Throwable $exception) {
@@ -234,14 +236,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->execution->update([
'status' => 'failed',
'message' => $exception->getMessage().$recoveryError,
'message' => $this->redact($exception->getMessage().$recoveryError),
'filename' => $streamToS3
? ($s3CleanupPending ? $backupLocation : null)
: ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation),
'local_storage_deleted' => $archiveDeleted,
]);
throw $exception;
$safeMessage = $this->redact($exception->getMessage());
throw $safeMessage === $exception->getMessage()
? $exception
: new \RuntimeException($safeMessage, (int) $exception->getCode());
} finally {
if (! $failed && $this->occurrenceUuid) {
app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid);
@@ -285,7 +290,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$execution->update([
'status' => 'failed',
'message' => ($exception?->getMessage() ?? 'Volume backup timed out or was terminated.').$recoveryError,
'message' => $this->redact(($exception?->getMessage() ?? 'Volume backup timed out or was terminated.').$recoveryError),
'finished_at' => now(),
'filename' => $filename,
'local_storage_deleted' => $localStorageDeleted,
@@ -294,10 +299,29 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup->team?->notify(new BackupFailed(
$this->backup,
$exception?->getMessage() ?? 'Volume backup timed out or was terminated.',
$this->redact($exception?->getMessage() ?? 'Volume backup timed out or was terminated.'),
));
}
/**
* Redact the key and secret of every S3 destination of the backup from text that is stored or sent in notifications.
*/
private function redact(string $text): string
{
try {
$s3 = $this->backup->s3;
$lockedSecrets = [$s3?->key, $s3?->secret];
foreach ($this->backup->selectedS3Storages() as $storage) {
$lockedSecrets[] = $storage->key;
$lockedSecrets[] = $storage->secret;
}
} catch (Throwable) {
return SensitiveDataRedactor::FAILURE_MESSAGE;
}
return app(SensitiveDataRedactor::class)->redactText($text, lockedSecrets: array_filter($lockedSecrets, fn ($secret): bool => is_string($secret) && $secret !== ''));
}
/**
* @return array<int, string>
*/
@@ -376,8 +400,9 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->uploadToS3($replica->s3, $backupLocation, $backupDirectory, $server);
$replica->update(['s3_uploaded' => true, 'message' => null]);
} catch (Throwable $exception) {
$replica->update(['s3_uploaded' => false, 'message' => $exception->getMessage()]);
$errors[] = ($replica->s3?->name ?? 'Deleted storage').': '.$exception->getMessage();
$message = $this->redact($exception->getMessage());
$replica->update(['s3_uploaded' => false, 'message' => $message]);
$errors[] = ($replica->s3?->name ?? 'Deleted storage').': '.$message;
}
}
@@ -57,6 +57,8 @@ class All extends Component
public bool $use_build_secrets = false;
public bool $redact_all_env_values_in_logs = true;
/**
* Environment variable rows are loaded after first paint via wire:init
* so the surrounding configuration page can render immediately.
@@ -109,6 +111,9 @@ class All extends Component
{
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
if ($this->hasLogRedactionSetting()) {
$this->redact_all_env_values_in_logs = $this->resource->redactsAllEnvValuesInLogs();
}
$this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
@@ -137,6 +142,41 @@ class All extends Component
}
}
/**
* Applications, services, and standalone databases have the "Environment values in logs" setting.
*/
public function hasLogRedactionSetting(): bool
{
return method_exists($this->resource, 'redactsAllEnvValuesInLogs');
}
public function saveLogRedactionSetting()
{
try {
$this->authorize('manageEnvironment', $this->resource);
if ($this->resource instanceof Application) {
$this->resource->settings->redact_all_env_values_in_logs = $this->redact_all_env_values_in_logs;
$this->saveApplicationSettingsWithAudit($this->resource);
} elseif ($this->hasLogRedactionSetting()) {
// Services and databases use the Auditable trait, so saving records the changed field.
$this->resource->redact_all_env_values_in_logs = $this->redact_all_env_values_in_logs;
$this->resource->save();
} else {
return;
}
$this->dispatch('success', 'Log redaction setting updated.');
} catch (\Throwable $e) {
if ($this->hasLogRedactionSetting()) {
$this->resource->refresh();
$this->redact_all_env_values_in_logs = $this->resource->redactsAllEnvValuesInLogs();
}
return handleError($e, $this);
}
}
public function instantSave()
{
try {
+15 -2
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Shared;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -192,7 +193,7 @@ class GetLogs extends Component
$options = "-n {$this->resolveLogTail()}".($this->showTimeStamps ? ' -t' : '');
return sanitizeLogsForExport($this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB', $this->showTimeStamps));
return sanitizeLogsForExport($this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB', $this->showTimeStamps), $this->logRedactionSecrets());
}
public function downloadAllLogs(): string
@@ -212,7 +213,19 @@ class GetLogs extends Component
$command = $this->logCommand($this->showTimeStamps ? '-t' : '');
return sanitizeLogsForExport($this->runLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', $this->showTimeStamps));
return sanitizeLogsForExport($this->runLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', $this->showTimeStamps), $this->logRedactionSecrets());
}
/**
* Environment values and database credentials of the resource whose logs are exported.
*
* @return array{known: array<int, string>, locked: array<int, string>}
*/
private function logRedactionSecrets(): array
{
$resource = $this->resource ?? $this->servicesubtype?->service;
return EnvironmentVariable::logRedactionSecretsFor($resource, includePreview: $resource instanceof Application && filled($this->pull_request));
}
private function resolveLogTail(): int|string
@@ -4,12 +4,13 @@ namespace App\Livewire\Server;
use App\Models\Server;
use App\Traits\ListensToTeamChannel;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class DockerCleanupExecutions extends Component
{
use ListensToTeamChannel;
use AuthorizesRequests, ListensToTeamChannel;
public Server $server;
@@ -34,12 +35,15 @@ class DockerCleanupExecutions extends Component
public function mount(Server $server)
{
$this->authorize('view', $server);
$this->server = $server;
$this->refreshExecutions();
}
public function refreshExecutions(): void
{
$this->authorize('view', $this->server);
$this->executions = $this->server->dockerCleanupExecutions()
->orderBy('created_at', 'desc')
->take(20)
@@ -55,6 +59,8 @@ class DockerCleanupExecutions extends Component
public function selectExecution($key): void
{
$this->authorize('view', $this->server);
if ($key == $this->selectedKey) {
$this->selectedKey = null;
$this->selectedExecution = null;
@@ -74,6 +80,8 @@ class DockerCleanupExecutions extends Component
public function polling()
{
$this->authorize('view', $this->server);
if ($this->selectedExecution && $this->isPollingActive) {
$this->selectedExecution->refresh();
if ($this->selectedExecution->status !== 'running') {
@@ -85,6 +93,8 @@ class DockerCleanupExecutions extends Component
public function loadMoreLogs()
{
$this->authorize('view', $this->server);
$this->currentPage++;
}
@@ -105,6 +115,8 @@ class DockerCleanupExecutions extends Component
public function downloadLogs(int $executionId)
{
$this->authorize('view', $this->server);
$execution = $this->executions->firstWhere('id', $executionId);
if (! $execution) {
return;
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Logging;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Log\Logger;
/**
* Redacts the audit log like every other channel, but keeps email addresses so the log still records who acted.
*/
final class RedactSensitiveAuditData
{
public function __construct(private readonly SensitiveDataRedactor $redactor) {}
public function __invoke(Logger $logger): void
{
(new RedactSensitiveData($this->redactor->withoutEmailRedaction()))($logger);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Logging;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Log\Logger;
use Monolog\LogRecord;
use Throwable;
final class RedactSensitiveData
{
public function __construct(private readonly SensitiveDataRedactor $redactor) {}
public function __invoke(Logger $logger): void
{
if (! self::enabled()) {
return;
}
$logger->getLogger()->pushProcessor(fn (LogRecord $record): LogRecord => $this->redact($record));
}
/**
* Self-hosted instances may turn off framework log redaction. Cloud always redacts.
*/
public static function enabled(): bool
{
if (! app()->bound('config')) {
return true;
}
return isCloud() || config('logging.redaction_enabled', true) !== false;
}
private function redact(LogRecord $record): LogRecord
{
try {
$message = $this->redactor->redactText($record->message);
$recordContext = $this->redactor->redactValue($this->normalizeThrowables($record->context));
$extra = $this->redactor->redactValue($this->normalizeThrowables($record->extra));
return $record->with(
message: $message,
context: is_array($recordContext) ? $recordContext : [],
extra: is_array($extra) ? $extra : [],
);
} catch (Throwable) {
return $record->with(
message: SensitiveDataRedactor::FAILURE_MESSAGE,
context: [],
extra: [],
);
}
}
private function normalizeThrowables(mixed $value): mixed
{
if ($value instanceof Throwable) {
return [
'class' => $value::class,
'message' => $value->getMessage(),
'code' => $value->getCode(),
'file' => $value->getFile(),
'line' => $value->getLine(),
'trace' => $value->getTraceAsString(),
'previous' => $this->normalizeThrowables($value->getPrevious()),
];
}
if (! is_array($value)) {
return $value;
}
return array_map($this->normalizeThrowables(...), $value);
}
}
+6 -19
View File
@@ -2,6 +2,7 @@
namespace App\Mcp\Concerns;
use App\Services\Security\SensitiveDataRedactor;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@@ -108,27 +109,13 @@ trait BuildsResponse
/**
* Best-effort redaction for free-form log text shown to MCP clients.
* Uses remove_iip plus common KEY=value / JSON secret patterns; not a guarantee.
* Uses SensitiveDataRedactor patterns plus the given resource values; not a guarantee.
*
* @param array{known?: array<int, string>, locked?: array<int, string>} $secrets Values from EnvironmentVariable::logRedactionSecretsFor().
*/
protected function redactLogText(string $text): string
protected function redactLogText(string $text, array $secrets = []): string
{
$text = remove_iip($text);
// password= / secret= / token= / "token":"..." style (shell or JSON; quoted or bare)
$text = preg_replace(
'/(?<![\w])["\']?(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token)["\']?\s*[=:]\s*["\']?[^\s"\']{3,}["\']?/i',
'$1='.REDACTED,
$text
) ?? $text;
// export FOO=bar / "API_KEY":"..." style for sensitive-looking names
$text = preg_replace(
'/(?<![\w])(export\s+)?["\']?([A-Z][A-Z0-9_]*(?:SECRET|PASSWORD|TOKEN|PASSWD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*)["\']?\s*[=:]\s*["\']?[^\s"\']{3,}["\']?/i',
'$1$2='.REDACTED,
$text
) ?? $text;
return $text;
return app(SensitiveDataRedactor::class)->redactText($text, $secrets['known'] ?? [], $secrets['locked'] ?? []);
}
/**
+4 -1
View File
@@ -13,6 +13,7 @@ use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Support\ResourceStartActivity;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@@ -70,7 +71,9 @@ class Control extends Tool
'service' => $this->controlService($resource, $action),
};
} catch (\Throwable $e) {
return $this->mcpError($request, $e->getMessage(), ['resource_uuid' => $uuid]);
$secrets = EnvironmentVariable::logRedactionSecretsFor($resource);
return $this->mcpError($request, $this->redactLogText($e->getMessage(), $secrets), ['resource_uuid' => $uuid]);
}
auditLog('mcp.control', [
+21 -3
View File
@@ -6,6 +6,7 @@ use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -65,14 +66,17 @@ class GetLogs extends Tool
return $this->mcpSuccess($request, $this->respond($failure), ['resource_uuid' => $uuid, 'outcome' => 'unavailable']);
}
$secrets = $this->logRedactionSecrets($resource);
try {
$logs = $this->redactLogText(
$this->fetchLogs($resource, $resourceType, $lines, $showTimestamps)
$this->fetchLogs($resource, $resourceType, $lines, $showTimestamps),
$secrets,
);
} catch (MultipleContainersException $e) {
$payload = $this->failurePayload(
reason: 'multiple_containers',
message: $e->getMessage(),
message: $this->redactLogText($e->getMessage(), $secrets),
resourceType: $resourceType,
uuid: $uuid,
status: $resource->status ?? null,
@@ -85,7 +89,7 @@ class GetLogs extends Tool
return $this->mcpSuccess($request, $this->respond(
$this->failurePayload(
reason: 'log_fetch_failed',
message: $e->getMessage(),
message: $this->redactLogText($e->getMessage(), $secrets),
resourceType: $resourceType,
uuid: $uuid,
status: $resource->status ?? null,
@@ -104,6 +108,20 @@ class GetLogs extends Tool
]), ['resource_uuid' => $uuid]);
}
/**
* Environment values and database credentials of the resource that owns the log output.
*
* @return array{known: array<int, string>, locked: array<int, string>}
*/
private function logRedactionSecrets(mixed $resource): array
{
if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) {
$resource = $resource->service;
}
return EnvironmentVariable::logRedactionSecretsFor($resource);
}
/**
* @return array<string, mixed>|null
*/
+8
View File
@@ -748,6 +748,14 @@ class Application extends BaseModel
return $this->hasOne(ApplicationSetting::class);
}
/**
* Whether logs hide every environment value, or only locked values.
*/
public function redactsAllEnvValuesInLogs(): bool
{
return $this->settings?->redact_all_env_values_in_logs ?? true;
}
public function persistentStorages()
{
return $this->morphMany(LocalPersistentVolume::class, 'resource');
+94 -60
View File
@@ -4,10 +4,12 @@ namespace App\Models;
use App\Casts\EncryptedArrayCast;
use App\Enums\ApplicationDeploymentStatus;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use OpenApi\Attributes as OA;
#[OA\Schema(
@@ -47,12 +49,21 @@ use OpenApi\Attributes as OA;
class ApplicationDeploymentQueue extends Model
{
/**
* Kept in memory only, never saved.
* Log redaction variants of remote secrets. Kept in memory only, never saved.
*
* @var array<array-key, mixed>
* @var array<int, string>
*/
private array $remoteSecretsForRedaction = [];
public const HIDDEN_ENV_VALUES_HINT = 'Some environment variable values are hidden in this log. To show values that are not locked, go to Environment Variables > Environment values in logs and select "Hide locked values only".';
/** @var array{known: array<int, string>, locked: array<int, string>}|null */
private ?array $logRedactionSecrets = null;
private bool $hiddenEnvValuesHintAdded = false;
private bool $corruptedLogsWarned = false;
protected static function booted(): void
{
static::created(function (ApplicationDeploymentQueue $deployment): void {
@@ -204,58 +215,49 @@ class ApplicationDeploymentQueue extends Model
*/
public function redactRemoteSecrets(array $secrets): void
{
$this->remoteSecretsForRedaction = $secrets;
$this->remoteSecretsForRedaction = EnvironmentVariable::remoteSecretLogRedactionValues($secrets);
}
private function redactSensitiveInfo($text)
/**
* Redact a deployment log text. The application secrets are collected once per deployment.
*/
public function redactLogText(string $text): string
{
try {
$text = remove_iip($text);
$app = $this->application;
if (! $app) {
return $text;
}
$lockedVars = collect([]);
if ($app->environment_variables) {
$lockedVars = $lockedVars->merge(
$app->environment_variables
->where('is_shown_once', true)
->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues())
->filter()
);
}
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
$lockedVars = $lockedVars->merge(
$app->environment_variables_preview
->where('is_shown_once', true)
->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues())
->filter()
);
}
$lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remoteSecretsForRedaction));
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return is_string($text) ? $text : REDACTED;
// The deployment job registers its remote secrets with redactRemoteSecrets(), so they are not fetched again.
$this->logRedactionSecrets ??= EnvironmentVariable::logRedactionSecretsFor($this->application, $this->pull_request_id !== 0, resolveRemoteSecrets: false);
} catch (\Throwable) {
return REDACTED;
}
return app(SensitiveDataRedactor::class)->redactText(
$text,
$this->logRedactionSecrets['known'],
array_merge($this->logRedactionSecrets['locked'], $this->remoteSecretsForRedaction),
);
}
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
/**
* Tell if the "redact all values" setting hides an environment value that is not locked in this text.
*/
private function hidesUnlockedEnvValues(string $text): bool
{
if ($this->logRedactionSecrets === null) {
return false;
}
$unlockedValues = array_diff($this->logRedactionSecrets['known'], $this->logRedactionSecrets['locked'], $this->remoteSecretsForRedaction);
return app(SensitiveDataRedactor::class)->containsKnownSecret($text, $unlockedValues);
}
public function addLogEntry(
string $message,
string $type = 'stdout',
bool $hidden = false,
?string $command = null,
int $batch = 1,
): void {
if ($type === 'error') {
$type = 'stderr';
}
@@ -264,30 +266,62 @@ class ApplicationDeploymentQueue extends Model
$message = "\n".$message;
}
$newLogEntry = [
'command' => null,
'output' => $this->redactSensitiveInfo($message),
'command' => $command === null ? null : $this->redactLogText($command),
'output' => $this->redactLogText($message),
'type' => $type,
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
'batch' => 1,
'batch' => $batch,
];
// Use a transaction to ensure atomicity
DB::transaction(function () use ($newLogEntry) {
// Reload the model to get the latest logs
$this->refresh();
$addHiddenEnvValuesHint = ! $this->hiddenEnvValuesHintAdded && $this->hidesUnlockedEnvValues($message.' '.$command);
if ($this->logs) {
$previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR);
$newLogEntry['order'] = count($previousLogs) + 1;
$previousLogs[] = $newLogEntry;
$this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR);
} else {
$this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR);
DB::transaction(function () use ($newLogEntry, $addHiddenEnvValuesHint): void {
$storedLogs = static::query()->whereKey($this->getKey())->lockForUpdate()->value('logs');
$previousLogs = $this->decodeLogs($storedLogs);
if ($previousLogs === null) {
// Keep the stored log history instead of overwriting it with a new list.
if (! $this->corruptedLogsWarned) {
$this->corruptedLogsWarned = true;
Log::warning('Skipped a deployment log entry because the stored deployment logs cannot be decoded.', [
'deployment_uuid' => $this->deployment_uuid,
]);
}
return;
}
$newLogEntry['order'] = count($previousLogs) + 1;
$previousLogs[] = $newLogEntry;
if ($addHiddenEnvValuesHint) {
$this->hiddenEnvValuesHintAdded = true;
if (! collect($previousLogs)->contains('output', self::HIDDEN_ENV_VALUES_HINT)) {
$previousLogs[] = [...$newLogEntry, 'command' => null, 'output' => self::HIDDEN_ENV_VALUES_HINT, 'type' => 'stdout', 'hidden' => false, 'order' => $newLogEntry['order'] + 1];
}
}
$logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE);
// Save without triggering events to prevent potential race conditions
$this->saveQuietly();
// Update only the logs column without model events to prevent race conditions.
static::query()->whereKey($this->getKey())->update(['logs' => $logs]);
$this->logs = $logs;
$this->syncOriginalAttribute('logs');
});
}
/**
* @return array<int, mixed>|null Null when the stored logs are not blank and cannot be decoded.
*/
private function decodeLogs(?string $logs): ?array
{
if (blank($logs)) {
return [];
}
try {
$decoded = json_decode($logs, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($decoded) ? array_values($decoded) : null;
}
}
+3
View File
@@ -43,6 +43,7 @@ use OpenApi\Attributes as OA;
'is_git_shallow_clone_enabled' => ['type' => 'boolean'],
'is_pr_deployments_public_enabled' => ['type' => 'boolean'],
'use_build_secrets' => ['type' => 'boolean'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Hide every environment variable value in logs. When false, only locked values are hidden.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean'],
'include_source_commit_in_build' => ['type' => 'boolean'],
'docker_images_to_keep' => ['type' => 'integer'],
@@ -87,6 +88,7 @@ class ApplicationSetting extends Model
'is_stripprefix_enabled' => 'boolean',
'connect_to_docker_network' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'disable_build_cache' => 'boolean',
];
@@ -125,6 +127,7 @@ class ApplicationSetting extends Model
'is_git_shallow_clone_enabled',
'is_pr_deployments_public_enabled',
'use_build_secrets',
'redact_all_env_values_in_logs',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'docker_images_to_keep',
+198 -10
View File
@@ -2,12 +2,16 @@
namespace App\Models;
use App\Exceptions\RemoteSecretException;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Support\RemoteSecretReferences;
use App\Support\RemoteSecretValueFormatter;
use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use OpenApi\Attributes as OA;
#[OA\Schema(
@@ -215,7 +219,10 @@ class EnvironmentVariable extends BaseModel
return $this->resolveRealValue(revealLockedSharedVariables: false);
}
private function resolveRealValue(bool $revealLockedSharedVariables = true, bool $escapeLegacyValue = true): ?string
/**
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables
*/
private function resolveRealValue(bool $revealLockedSharedVariables = true, bool $escapeLegacyValue = true, ?Collection $sharedVariables = null): ?string
{
if (! $this->relationLoaded('resourceable')) {
$this->load('resourceable');
@@ -236,7 +243,7 @@ class EnvironmentVariable extends BaseModel
$resource->load('destination.server');
}
$value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables);
$value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables);
return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value);
}
@@ -344,9 +351,12 @@ class EnvironmentVariable extends BaseModel
}
/** @return array<int, string> */
public function logRedactionValues(): array
/**
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables Preloaded shared variables, to avoid one query per reference.
*/
public function logRedactionValues(?Collection $sharedVariables = null): array
{
$resolvedValue = $this->resolveRealValue(escapeLegacyValue: false);
$resolvedValue = $this->resolveRealValue(escapeLegacyValue: false, sharedVariables: $sharedVariables);
if (! is_string($resolvedValue) || $resolvedValue === '') {
return [];
}
@@ -402,6 +412,177 @@ class EnvironmentVariable extends BaseModel
return $values;
}
/**
* Collect the values to hide in a resource's logs.
*
* Locked values, database passwords, and resolved remote secrets are always hidden,
* whatever their length. Other values are hidden unless the resource limits log redaction
* to locked values only.
*
* Remote secrets are fetched at most once per call, and only when a variable references one.
* Callers that already hold the fetched values, such as deployments, pass $resolveRemoteSecrets = false.
*
* @return array{known: array<int, string>, locked: array<int, string>}
*/
public static function logRedactionSecretsFor(Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite|null $resource, bool $includePreview = false, bool $resolveRemoteSecrets = true): array
{
if ($resource === null) {
return ['known' => [], 'locked' => []];
}
$isDatabase = ! $resource instanceof Application && ! $resource instanceof Service;
$secrets = ['known' => [], 'locked' => []];
if ($isDatabase) {
foreach (array_keys($resource->getAttributes()) as $attribute) {
if (str_contains($attribute, 'password')) {
$value = $resource->getAttribute($attribute);
if (is_string($value) && $value !== '') {
$secrets['locked'][] = $value;
}
}
}
}
$variables = collect($isDatabase ? $resource->runtime_environment_variables : $resource->environment_variables);
if ($includePreview && $resource instanceof Application) {
$variables = $variables->merge($resource->environment_variables_preview ?? []);
}
if ($variables->isEmpty()) {
return $secrets;
}
// Load the resource relations once and share them, so each variable does not load its own copy.
$resource->loadMissing('environment.project.team');
if (method_exists($resource, 'server')) {
$resource->loadMissing('server');
}
if (method_exists($resource, 'destination')) {
$resource->loadMissing('destination.server');
}
// Use copies so the resource's own loaded variables do not get a reference back to the resource.
$variables = $variables->map(fn (self $variable): self => (clone $variable)->setRelation('resourceable', $resource));
$sharedVariables = self::sharedVariablesReferencedBy($variables, $resource->team()?->id);
$redactAllValues = $resource->redactsAllEnvValuesInLogs();
$unresolvedKeys = [];
$remoteSecretFailure = null;
foreach ($variables as $variable) {
if ($resolveRemoteSecrets) {
$secrets['locked'] = array_merge(
$secrets['locked'],
self::remoteSecretLogRedactionValuesFor($resource, $variable, $sharedVariables, $remoteSecretFailure, $unresolvedKeys),
);
}
if ($variable->isLockedForLogs($sharedVariables)) {
$secrets['locked'] = array_merge($secrets['locked'], $variable->logRedactionValues($sharedVariables));
} elseif ($redactAllValues) {
$secrets['known'] = array_merge($secrets['known'], $variable->logRedactionValues($sharedVariables));
}
}
if ($remoteSecretFailure !== null) {
Log::warning('Could not resolve remote secrets for log redaction; their resolved values are not hidden.', [
'resource_type' => $resource->getMorphClass(),
'resource_uuid' => $resource->uuid,
'variables' => array_values(array_unique($unresolvedKeys)),
'exception' => $remoteSecretFailure::class,
]);
}
return $secrets;
}
/**
* Resolved remote secret values of a variable; these are always hidden.
*
* When the references cannot be resolved, export still works: the reference text and any keys
* that did resolve are hidden, and the variable is reported. After a failed or empty fetch the
* source is not asked again.
*
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables
* @param array<int, string> $unresolvedKeys
* @return array<int, string>
*/
private static function remoteSecretLogRedactionValuesFor(Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $resource, self $variable, ?Collection $sharedVariables, ?RemoteSecretException &$failure, array &$unresolvedKeys): array
{
// Cheap check first, so variables without references never query or contact a source.
$value = $variable->resolveRealValue(escapeLegacyValue: false, sharedVariables: $sharedVariables);
if (! RemoteSecretReferences::containsReference($value) || ! $resource->environmentVariableUsesSecretManager($variable)) {
return [];
}
if ($failure === null || $resource->resolvedSecretManagerValuesForRedaction() !== []) {
try {
return self::remoteSecretLogRedactionValues([$resource->resolveSecretManagerEnvironmentVariableValue($variable)]);
} catch (RemoteSecretException $e) {
$failure = $e;
}
}
$unresolvedKeys[] = $variable->key;
$available = array_intersect_key($resource->resolvedSecretManagerValuesForRedaction(), array_flip(RemoteSecretReferences::referencedKeys($value)));
return [...self::remoteSecretLogRedactionValues(array_values($available)), ...$variable->logRedactionValues($sharedVariables)];
}
/**
* A variable is locked when it is locked itself or references a locked shared variable.
*
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables Preloaded shared variables of the resource's team.
*/
public function isLockedForLogs(?Collection $sharedVariables = null): bool
{
if ($this->is_shown_once) {
return true;
}
$references = self::sharedVariableReferences($this->value);
if ($references === []) {
return false;
}
$sharedVariables ??= self::sharedVariablesReferencedBy(collect([$this]), $this->resourceable?->team()?->id);
if ($sharedVariables === null) {
return true;
}
return $sharedVariables->contains(fn (SharedEnvironmentVariable $shared): bool => $shared->is_shown_once
&& in_array(['type' => $shared->type, 'key' => $shared->key], $references, true));
}
/**
* Load every shared variable that the given variables reference, with one query.
*
* @param Collection<int, self> $variables
* @return Collection<int, SharedEnvironmentVariable>|null Null when the team is unknown.
*/
private static function sharedVariablesReferencedBy(Collection $variables, ?int $teamId): ?Collection
{
if ($teamId === null) {
return null;
}
$keys = $variables
->flatMap(fn (self $variable): array => self::sharedVariableReferences($variable->value))
->pluck('key')
->unique()
->values();
return $keys->isEmpty()
? collect()
: SharedEnvironmentVariable::query()->where('team_id', $teamId)->whereIn('key', $keys)->get();
}
/**
* @return array<int, array{type: string, key: string}>
*/
private static function sharedVariableReferences(?string $value): array
{
preg_match_all('/{{\s*(\w+)\.([^}\s]+)\s*}}/', (string) $value, $matches, PREG_SET_ORDER);
return array_map(fn (array $match): array => ['type' => $match[1], 'key' => $match[2]], $matches);
}
public function resolveReferencedValue(): ?string
{
$value = $this->value;
@@ -420,7 +601,10 @@ class EnvironmentVariable extends BaseModel
->first()?->value ?? $value;
}
private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null, bool $revealLockedSharedVariables = true)
/**
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables
*/
private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null, bool $revealLockedSharedVariables = true, ?Collection $sharedVariables = null)
{
if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) {
return $environment_variable;
@@ -455,11 +639,15 @@ class EnvironmentVariable extends BaseModel
if (is_null($id)) {
continue;
}
$found = SharedEnvironmentVariable::where('type', $type)
->where('key', $variable)
->where('team_id', $resource->team()->id)
->where("{$type}_id", $id)
->first();
$found = $sharedVariables !== null
? $sharedVariables->first(fn (SharedEnvironmentVariable $shared): bool => $shared->type === $type->value()
&& $shared->key === $variable->value()
&& (int) $shared->{"{$type}_id"} === (int) $id)
: SharedEnvironmentVariable::where('type', $type)
->where('key', $variable)
->where('team_id', $resource->team()->id)
->where("{$type}_id", $id)
->first();
if ($found && ($revealLockedSharedVariables || ! $found->is_shown_once)) {
$environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value);
}
+1 -1
View File
@@ -375,7 +375,7 @@ class Server extends BaseModel
public function setValidationLogsAttribute($value): void
{
$this->attributes['validation_logs'] = $value !== null
? Purify::config('validation_logs')->clean($value)
? Purify::config('validation_logs')->clean(remove_iip($value))
: null;
}
+14
View File
@@ -35,6 +35,7 @@ use OpenApi\Attributes as OA;
'destination_id' => ['type' => 'integer', 'description' => 'The unique identifier of the destination where the service is running.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'description' => 'The flag to enable the container label escape.'],
'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Hide every environment variable value in logs. When false, only locked values are hidden.'],
'is_container_label_readonly_enabled' => ['type' => 'boolean', 'description' => 'The flag to enable the container label readonly.'],
'config_hash' => ['type' => 'string', 'description' => 'The hash of the service configuration.'],
'service_type' => ['type' => 'string', 'description' => 'The type of the service.'],
@@ -60,6 +61,7 @@ class Service extends BaseModel
'config_hash',
'compose_parsing_version',
'is_container_label_escape_enabled',
'redact_all_env_values_in_logs',
'environment_id',
'server_id',
'destination_id',
@@ -68,6 +70,10 @@ class Service extends BaseModel
protected $appends = ['server_status', 'status'];
protected $casts = [
'redact_all_env_values_in_logs' => 'boolean',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
@@ -1599,6 +1605,14 @@ class Service extends BaseModel
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
}
/**
* Whether logs hide every environment value, or only locked values.
*/
public function redactsAllEnvValuesInLogs(): bool
{
return $this->redact_all_env_values_in_logs ?? true;
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -15,7 +16,7 @@ use Throwable;
class StandaloneClickhouse extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -15,7 +16,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasLogRedactionSetting;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -16,7 +17,7 @@ use InvalidArgumentException;
class StandaloneSqlite extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
public const DATA_DIRECTORY = '/var/lib/sqlite';
+4 -3
View File
@@ -5,6 +5,7 @@ namespace App\Notifications\Channels;
use App\Exceptions\NonReportableException;
use App\Models\EmailNotificationSettings;
use App\Models\Team;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\SmtpTransportFactory;
use Exception;
use Illuminate\Notifications\Notification;
@@ -151,9 +152,9 @@ class EmailChannel
send_internal_notification(sprintf(
"Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s",
$statusCode,
$e->getErrorMessage(),
remove_iip($e->getErrorMessage()),
get_class($notification),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
json_encode(app(SensitiveDataRedactor::class)->redactValue($emailSettings), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
// Don't report expected errors (invalid keys, validation) to Sentry
@@ -172,7 +173,7 @@ class EmailChannel
throw $e;
} catch (TransporterException $e) {
send_internal_notification("Resend Transport Error: {$e->getMessage()}");
send_internal_notification('Resend Transport Error: '.remove_iip($e->getMessage()));
throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
} catch (\Throwable $e) {
// Check if this is a Resend domain verification error on cloud instances
+31 -13
View File
@@ -4,9 +4,12 @@ namespace App\Services;
use App\Enums\ProcessStatus;
use App\Helpers\SshMultiplexingHelper;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Process\Exceptions\ProcessTimedOutException;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException;
class DatabaseStartCommandExecutor
{
@@ -25,13 +28,31 @@ class DatabaseStartCommandExecutor
$activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]);
$activity->save();
$process = Process::timeout(config('constants.ssh.command_timeout'))
->idleTimeout(3600)
->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void {
$this->appendOutput($activity, $type, $this->redact($output, $secrets));
});
$timeout = config('constants.ssh.command_timeout');
// Output is redacted per complete line, so a secret split across two output chunks is still found.
$pending = ['stdout' => '', 'stderr' => ''];
try {
$process = Process::timeout($timeout)
->idleTimeout(3600)
->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets, &$pending): void {
$pending[$type] = ($pending[$type] ?? '').$output;
$end = max(strrpos($pending[$type], "\n"), strrpos($pending[$type], "\r"));
if ($end === false) {
return;
}
$this->appendOutput($activity, $type, $this->redact(substr($pending[$type], 0, $end + 1), $secrets));
$pending[$type] = substr($pending[$type], $end + 1);
});
$result = $process->wait();
$result = $process->wait();
} catch (ProcessTimedOutException|SymfonyProcessTimedOutException) {
// The timeout message contains the full SSH command, which can contain secrets.
throw new \RuntimeException("The command on the server timed out after {$timeout} seconds.", 124);
} finally {
foreach ($pending as $type => $output) {
$this->appendOutput($activity, $type, $this->redact($output, $secrets));
}
}
$status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR;
$activity->properties = $activity->properties->merge([
'status' => $status->value,
@@ -46,15 +67,12 @@ class DatabaseStartCommandExecutor
return $activity;
}
/**
* @param array<array-key, mixed> $secrets
*/
private function redact(string $value, array $secrets): string
{
foreach ($secrets as $secret) {
if (is_string($secret) && $secret !== '') {
$value = str_replace($secret, REDACTED, $value);
}
}
return sanitize_utf8_text(remove_iip($value));
return sanitize_utf8_text(resolve(SensitiveDataRedactor::class)->redactText($value, lockedSecrets: $secrets));
}
private function appendOutput(Activity $activity, string $type, string $output): void
+8 -1
View File
@@ -6,10 +6,12 @@ use App\Helpers\SshMultiplexingHelper;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Process\Exceptions\ProcessTimedOutException;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException;
class DockerRegistryLogins
{
@@ -274,7 +276,12 @@ class DockerRegistryLogins
private static function run(Server $server, string $command, array $secrets = [], ?string $emptyErrorMessage = null): void
{
$commands = $server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $server) : [$command];
$process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)));
try {
$process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)));
} catch (ProcessTimedOutException|SymfonyProcessTimedOutException) {
// The timeout message contains the full SSH command, including the registry password.
throw new RuntimeException('The command on the server timed out after 60 seconds.', 124);
}
if ($process->exitCode() === 0) {
return;
@@ -0,0 +1,325 @@
<?php
namespace App\Services\Security;
use DateTimeInterface;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use RuntimeException;
use Stringable;
use Throwable;
use UnitEnum;
final class SensitiveDataRedactor
{
public const FAILURE_MESSAGE = '[LOG SUPPRESSED: REDACTION FAILED]';
private const CHUNK_BYTES = 262144;
private const MIN_KNOWN_SECRET_LENGTH = 8;
private const EMAIL_PATTERN = '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/';
private bool $redactEmails = true;
private const SENSITIVE_KEYS = ['password', 'passwd', 'secret', 'client_secret', 'api_key', 'apikey', 'access_key', 'private_key', 'token', 'access_token', 'refresh_token', 'auth_token', 'authorization', 'proxy_authorization', 'cookie', 'set_cookie', 'webhook_secret', 'signing_secret', 'credential', 'credentials', 'connection_string', 'database_url'];
/**
* Return a copy that keeps email addresses, for sinks such as the audit log that must record who acted.
*/
public function withoutEmailRedaction(): self
{
$redactor = clone $this;
$redactor->redactEmails = false;
return $redactor;
}
/**
* Tell if redactText() would hide one of these known secrets in the text.
*
* @param array<array-key, mixed> $knownSecrets
*/
public function containsKnownSecret(string $text, array $knownSecrets): bool
{
foreach ($knownSecrets as $secret) {
if (is_scalar($secret) && trim((string) $secret) !== '' && strlen((string) $secret) >= self::MIN_KNOWN_SECRET_LENGTH && str_contains($text, (string) $secret)) {
return true;
}
}
return false;
}
/**
* @param array<array-key, mixed> $knownSecrets Exact values to replace when they are long enough to be safe to match, such as unlocked environment values.
* @param array<array-key, mixed> $lockedSecrets Exact values to always replace, whatever their length, such as locked or remote secrets.
*/
public function redactText(string $text, array $knownSecrets = [], array $lockedSecrets = []): string
{
try {
$text = $this->normalizeText($text);
$text = $this->replaceKnownSecrets($text, $knownSecrets, $lockedSecrets);
$redacted = '';
foreach ($this->chunks($text) as $chunk) {
$redacted .= $this->redactChunk($chunk);
}
return $redacted;
} catch (Throwable) {
return self::FAILURE_MESSAGE;
}
}
/**
* @param array<array-key, mixed> $knownSecrets
*/
public function redactValue(mixed $value, array $knownSecrets = [], ?string $key = null): mixed
{
try {
if ($key !== null && $this->isSensitiveKey($key)) {
return REDACTED;
}
if (is_array($value)) {
$redacted = [];
foreach ($value as $itemKey => $itemValue) {
$redacted[$itemKey] = $this->redactValue($itemValue, $knownSecrets, is_string($itemKey) ? $itemKey : null);
}
return $redacted;
}
if (is_string($value)) {
return $this->redactText($value, $knownSecrets);
}
if (! is_object($value) || $value instanceof UnitEnum || $value instanceof DateTimeInterface) {
return $value;
}
if ($value instanceof Arrayable) {
return $this->redactValue($value->toArray(), $knownSecrets);
}
if ($value instanceof JsonSerializable) {
return $this->redactValue($value->jsonSerialize(), $knownSecrets);
}
if ($value instanceof Stringable) {
return $this->redactText((string) $value, $knownSecrets);
}
return '[object '.$value::class.']';
} catch (Throwable) {
return self::FAILURE_MESSAGE;
}
}
/**
* Redact one chunk. When a pattern hits a PCRE limit, redact the chunk line by line so one huge line
* or an unterminated private key does not suppress the whole log. A line that still fails is suppressed.
*/
private function redactChunk(string $chunk): string
{
try {
return $this->redactAssignments($this->redactPatterns($chunk));
} catch (RuntimeException) {
$redacted = '';
$insidePrivateKey = false;
foreach (preg_split('/(?<=\n)/', $chunk) ?: [$chunk] as $line) {
if (! $insidePrivateKey && preg_match('/-----BEGIN [A-Z ]*PRIVATE KEY-----/', $line) === 1 && preg_match('/-----END [A-Z ]*PRIVATE KEY-----/', $line) !== 1) {
$insidePrivateKey = true;
$redacted .= REDACTED.(str_ends_with($line, "\n") ? "\n" : '');
continue;
}
if ($insidePrivateKey) {
$insidePrivateKey = preg_match('/-----END [A-Z ]*PRIVATE KEY-----/', $line) !== 1;
continue;
}
try {
$redacted .= $this->redactAssignments($this->redactPatterns($line));
} catch (RuntimeException) {
$redacted .= self::FAILURE_MESSAGE.(str_ends_with($line, "\n") ? "\n" : '');
}
}
return $redacted;
}
}
private function normalizeText(string $text): string
{
$text = sanitize_utf8_text($text);
$text = preg_replace('/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -\/]*[@-~])/', '', $text);
if ($text === null) {
throw new RuntimeException('Log normalization failed.');
}
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
if ($text === null) {
throw new RuntimeException('Log normalization failed.');
}
return $text;
}
/**
* Split large text on line boundaries so the regular expressions stay inside PCRE limits.
* A chunk never ends inside a private key block.
*
* @return iterable<int, string>
*/
private function chunks(string $text): iterable
{
$length = strlen($text);
$offset = 0;
while ($offset < $length) {
$end = $this->lineEnd($text, $offset + self::CHUNK_BYTES);
$chunk = substr($text, $offset, $end - $offset);
if (preg_match('/-----BEGIN [A-Z ]*PRIVATE KEY-----(?![\s\S]*-----END [A-Z ]*PRIVATE KEY-----)/', $chunk) === 1) {
$end = preg_match('/-----END [A-Z ]*PRIVATE KEY-----/', $text, $match, PREG_OFFSET_CAPTURE, $end) === 1
? $this->lineEnd($text, $match[0][1] + strlen($match[0][0]))
: $length;
$chunk = substr($text, $offset, $end - $offset);
}
yield $chunk;
$offset = $end;
}
}
/**
* Find the end of the line that contains $position, without copying the text.
*/
private function lineEnd(string $text, int $position): int
{
if ($position >= strlen($text)) {
return strlen($text);
}
$newline = strpos($text, "\n", $position);
return $newline === false ? strlen($text) : $newline + 1;
}
/**
* @param array<array-key, mixed> $knownSecrets
* @param array<array-key, mixed> $lockedSecrets
*/
private function replaceKnownSecrets(string $text, array $knownSecrets, array $lockedSecrets): string
{
$variants = [];
$shortVariants = [];
foreach ([[$knownSecrets, self::MIN_KNOWN_SECRET_LENGTH], [$lockedSecrets, 1]] as [$secrets, $minimumLength]) {
foreach ($secrets as $secret) {
if (! is_scalar($secret) || trim((string) $secret) === '' || strlen((string) $secret) < $minimumLength) {
continue;
}
$secret = (string) $secret;
$json = json_encode($secret, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE);
// Short locked values such as "ab" would hit inside ordinary words, so they only match as whole tokens.
if (strlen($secret) < self::MIN_KNOWN_SECRET_LENGTH) {
array_push($shortVariants, $secret, rawurlencode($secret), substr($json, 1, -1));
} else {
array_push($variants, $secret, rawurlencode($secret), substr($json, 1, -1));
}
}
}
$byLengthDescending = fn (string $left, string $right): int => strlen($right) <=> strlen($left);
$variants = array_values(array_unique(array_filter($variants)));
usort($variants, $byLengthDescending);
$text = str_replace($variants, REDACTED, $text);
// The text is valid UTF-8 after normalization, so a variant that is not valid UTF-8 cannot appear in it.
$shortVariants = array_values(array_unique(array_filter($shortVariants, fn (string $variant): bool => $variant !== '' && mb_check_encoding($variant, 'UTF-8'))));
if ($shortVariants === []) {
return $text;
}
usort($shortVariants, $byLengthDescending);
$alternatives = implode('|', array_map(fn (string $variant): string => preg_quote($variant, '/'), $shortVariants));
$text = preg_replace('/(?<![\p{L}\p{M}\p{N}_])(?:'.$alternatives.')(?![\p{L}\p{M}\p{N}_])/u', REDACTED, $text);
if ($text === null) {
throw new RuntimeException('Log redaction failed.');
}
return $text;
}
private function redactAssignments(string $text): string
{
$assignmentKeys = array_diff(self::SENSITIVE_KEYS, ['authorization', 'proxy_authorization']);
$keys = implode('|', array_map(fn (string $key): string => preg_quote($key, '/'), $assignmentKeys));
// A key matches when it is a sensitive name or ends with one, such as smtp_password or github-token.
$keys = '(?i:(?:[a-z0-9]+[_-])*(?:'.$keys.'))|[A-Z][A-Z0-9_]*(?:SECRET|PASSWORD|TOKEN|PASSWD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*';
$value = '(?<value>"(?:[^"\\\\\r\n]|\\\\.)*"|\'(?:[^\'\\\\\r\n]|\\\\.)*\'|[^\s,;}\]]+)';
// Skip values that an earlier rule already replaced, such as the token in https://x-access-token:<REDACTED>@host.
$alreadyRedacted = '(?!'.preg_quote(REDACTED, '/').')';
// Set when nothing but a delimiter follows the value on its line, as in YAML or object literals.
$endsValue = '(?<end>(?=[^\S\r\n]*(?:$|[\r\n,;}\]])))?';
// A quote that opens before the key and closes after the value wraps the whole assignment, as in
// the shell argument 'DB_PASSWORD=a b'. Hide everything up to the closing quote and keep the quote.
$text = preg_replace('/(?<![\w-])(["\'])((?:'.$keys.')\s*=\s*)'.$alreadyRedacted.'(?:(?!\1)[^\r\n])*\1/', '$1$2'.REDACTED.'$1', $text);
if ($text === null) {
throw new RuntimeException('Log redaction failed.');
}
$result = preg_replace_callback('/(?<![\w-])(?<prefix>["\']?(?<key>'.$keys.')["\']?\s*(?<separator>[=:])\s*)'.$alreadyRedacted.$value.$endsValue.'/m', function (array $match): string {
$value = $match['value'];
$quote = strlen($value) > 1 && in_array($value[0], ['"', "'"], true) && str_ends_with($value, $value[0]) ? $value[0] : '';
// Prose such as "Failed to refresh token: Connection refused" uses a plain word key, a colon, and a plain word value.
$isProse = $quote === ''
&& $match['separator'] === ':'
&& ! in_array($match['prefix'][0], ['"', "'"], true)
&& preg_match('/^[A-Za-z][a-z]*$/', $match['key']) === 1
&& ctype_alpha($value)
&& $match['end'] === null;
return $isProse ? $match[0] : $match['prefix'].$quote.REDACTED.$quote;
}, $text, flags: PREG_UNMATCHED_AS_NULL);
if ($result === null) {
throw new RuntimeException('Log redaction failed.');
}
return $result;
}
private function redactPatterns(string $text): string
{
$patterns = [
'/((?:Authorization|Proxy-Authorization)\s*:\s*)[^\r\n]+/i' => '$1'.REDACTED,
'/x-access-token:.*?(?=@)/' => 'x-access-token:'.REDACTED,
'/oauth2:.*?(?=@)/' => 'oauth2:'.REDACTED,
'/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i' => '$1'.REDACTED.'$2',
self::EMAIL_PATTERN => REDACTED,
'/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i' => 'Bearer '.REDACTED,
'/Bearer\s+[^\s,;]+/i' => 'Bearer '.REDACTED,
'/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/' => REDACTED,
'/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/' => REDACTED,
'/\b(A(?:KIA|BIA|CCA|SIA)[A-Z0-9]{16})\b/' => REDACTED,
'/(aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[=:]\s*[\'\"]?([A-Za-z0-9\/+\=]{40})[\'\"]?/i' => '$1='.REDACTED,
'/(api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*[\'\"]?[A-Za-z0-9\-_]{16,}[\'\"]?/i' => '$1='.REDACTED,
'/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/' => REDACTED,
];
if (! $this->redactEmails) {
unset($patterns[self::EMAIL_PATTERN]);
}
foreach ($patterns as $pattern => $replacement) {
$text = preg_replace($pattern, $replacement, $text);
if ($text === null) {
throw new RuntimeException('Log redaction failed.');
}
}
return $text;
}
private function isSensitiveKey(string $key): bool
{
$normalized = strtolower(preg_replace(['/([a-z0-9])([A-Z])/', '/[-\s]+/'], ['$1_$2', '_'], $key) ?? $key);
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
if ($normalized === $sensitiveKey || str_ends_with($normalized, '_'.$sensitiveKey)) {
return true;
}
}
return false;
}
}
@@ -4,6 +4,8 @@ namespace App\Services\ServerTransfer;
use App\Models\Server;
use App\Rules\SafeWebhookUrl;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Database\QueryException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
@@ -80,8 +82,10 @@ class ServerTransferMigrator
targetInstanceUrl: $targetUrl,
);
} catch (Throwable $e) {
// Query errors include SQL and bindings, so they are never shown to the user.
$reason = $e instanceof QueryException ? 'a database error occurred' : remove_iip($e->getMessage());
throw new RuntimeException(
"Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$e->getMessage()}. ".
"Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$reason}. ".
'Disable management of this server here (or API: POST /api/v1/servers/{uuid}/transfer/complete) so automations stay disabled. Do not re-import on the target.',
previous: $e
);
@@ -91,7 +95,7 @@ class ServerTransferMigrator
'server_uuid' => $server->uuid,
'export_id' => is_string($exportId) ? $exportId : null,
'target_url' => $targetUrl,
'import' => $importBody,
'import' => app(SensitiveDataRedactor::class)->redactValue($importBody),
'complete' => $complete,
'warnings' => $warnings,
'message' => 'Server migrated to '.$targetUrl.'. Automations disabled on this instance.',
@@ -172,14 +176,14 @@ class ServerTransferMigrator
'adopt_mode' => $adoptMode,
]);
} catch (ConnectionException $e) {
throw new RuntimeException("Could not reach target instance at {$importUrl}: {$e->getMessage()}", previous: $e);
throw new RuntimeException('Could not reach target instance at '.remove_iip($importUrl).': '.remove_iip($e->getMessage()), previous: $e);
} catch (Throwable $e) {
throw new RuntimeException("Transfer to target failed: {$e->getMessage()}", previous: $e);
throw new RuntimeException('Transfer to target failed: '.remove_iip($e->getMessage()), previous: $e);
}
$importBody = $response->json();
if (! is_array($importBody)) {
$importBody = ['raw' => $response->body()];
$importBody = ['raw' => remove_iip($response->body())];
}
if (! $response->successful()) {
@@ -189,7 +193,7 @@ class ServerTransferMigrator
if (is_array($message)) {
$message = json_encode($message);
}
throw new RuntimeException("Target import failed (HTTP {$response->status()}): {$message}");
throw new RuntimeException("Target import failed (HTTP {$response->status()}): ".remove_iip((string) $message));
}
return $importBody;
+3 -1
View File
@@ -158,12 +158,14 @@ class ResourceStartActivity
}
/**
* Mark an activity as failed, append the reason to its log and stop log polling.
* Mark an activity as failed, append the redacted reason to its log and stop log polling.
*
* @param array<string, mixed> $extraProperties
*/
public static function markFailed(Activity $activity, string $message, array $extraProperties = []): void
{
// Callers can pass exception or command output text, which can contain secrets.
$message = remove_iip($message);
$properties = [
...$extraProperties,
'status' => ProcessStatus::ERROR->value,
+69 -119
View File
@@ -2,12 +2,13 @@
namespace App\Traits;
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Enums\ApplicationDeploymentStatus;
use App\Exceptions\DeploymentException;
use App\Helpers\SshMultiplexingHelper;
use App\Models\EnvironmentVariable;
use App\Models\Server;
use Carbon\Carbon;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Stringable;
@@ -22,52 +23,44 @@ trait ExecuteRemoteCommand
public static int $batch_counter = 0;
private function redact_sensitive_info($text)
/** @var array{known: array<int, string>, locked: array<int, string>}|null */
private ?array $logRedactionSecrets = null;
/** @var array<int, string>|null */
private ?array $remoteSecretLogRedactionValues = null;
private function redact_sensitive_info(string $text): string
{
try {
$text = remove_iip($text);
if (! isset($this->application)) {
return $text;
}
$lockedVars = collect([]);
if (isset($this->application->environment_variables)) {
$lockedVars = $lockedVars->merge(
$this->application->environment_variables
->where('is_shown_once', true)
->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues())
->filter()
);
}
if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
$lockedVars = $lockedVars->merge(
$this->application->environment_variables_preview
->where('is_shown_once', true)
->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues())
->filter()
);
}
if (isset($this->remote_secrets_cache)) {
$lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remote_secrets_cache));
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return is_string($text) ? $text : REDACTED;
// Remote secrets come from the job's remote_secrets_cache below, so they are not fetched again.
$this->logRedactionSecrets ??= EnvironmentVariable::logRedactionSecretsFor(
$this->application ?? null,
isset($this->pull_request_id) && $this->pull_request_id !== 0,
resolveRemoteSecrets: false,
);
} catch (\Throwable) {
return REDACTED;
}
$remoteSecrets = isset($this->remote_secrets_cache)
? ($this->remoteSecretLogRedactionValues ??= EnvironmentVariable::remoteSecretLogRedactionValues($this->remote_secrets_cache))
: [];
return app(SensitiveDataRedactor::class)->redactText(
$text,
$this->logRedactionSecrets['known'],
array_merge($this->logRedactionSecrets['locked'], $remoteSecrets),
);
}
/**
* Remote secrets are never stored in Coolify, so the deployment log model must get them from the job.
*/
private function registerRemoteSecretsForLogs(): void
{
if (isset($this->application_deployment_queue, $this->remote_secrets_cache)) {
$this->application_deployment_queue->redactRemoteSecrets($this->remote_secrets_cache);
}
}
public function execute_remote_command(...$commands)
@@ -81,9 +74,7 @@ trait ExecuteRemoteCommand
if ($this->server instanceof Server === false) {
throw new \RuntimeException('Server is not set or is not an instance of Server model');
}
if (isset($this->application_deployment_queue, $this->remote_secrets_cache)) {
$this->application_deployment_queue->redactRemoteSecrets($this->remote_secrets_cache);
}
$this->registerRemoteSecretsForLogs();
$commandsText->each(function ($single_command) {
$command = data_get($single_command, 'command') ?? $single_command[0] ?? null;
if ($command === null) {
@@ -181,57 +172,46 @@ trait ExecuteRemoteCommand
$remote_command = $input === null
? SshMultiplexingHelper::generateSshCommand($this->server, $command)
: SshMultiplexingHelper::generateSshStdinCommand($this->server, $command);
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->input($input)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
$addLogEntry = function (string $type, string $output) use ($command, $hidden, $customType, $command_hidden, $skip_command_log): void {
// Sanitize output to ensure valid UTF-8 encoding before JSON encoding
$sanitized_output = sanitize_utf8_text($output);
$log_output = str($sanitized_output)->trim();
$log_output = str(sanitize_utf8_text($output))->trim();
if ($log_output->startsWith('╔')) {
$log_output = "\n".$log_output;
}
$new_log_entry = [
'command' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command),
'output' => $this->redact_sensitive_info($skip_command_log ? $this->redactSensitiveCommandPayloads((string) $log_output, (string) $command) : $log_output),
'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'),
'timestamp' => Carbon::now('UTC'),
'hidden' => $hidden,
'batch' => static::$batch_counter,
];
if (! $this->application_deployment_queue->logs) {
$new_log_entry['order'] = 1;
} else {
try {
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
// If existing logs are corrupted, start fresh
$previous_logs = [];
$new_log_entry['order'] = 1;
}
if (is_array($previous_logs)) {
$new_log_entry['order'] = count($previous_logs) + 1;
} else {
$previous_logs = [];
$new_log_entry['order'] = 1;
}
}
$previous_logs[] = $new_log_entry;
try {
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
// If JSON encoding still fails, use fallback with invalid sequences replacement
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
$this->application_deployment_queue->addLogEntry(
message: $skip_command_log ? $this->redactSensitiveCommandPayloads((string) $log_output, (string) $command) : (string) $log_output,
type: $customType ?? ($type === 'err' ? 'stderr' : 'stdout'),
hidden: $hidden,
command: $skip_command_log || $command_hidden ? null : $command,
batch: static::$batch_counter,
);
};
// Output is logged per complete line, so a secret split across two output chunks is still redacted.
$pending = [];
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->input($input)->start($remote_command, function (string $type, string $output) use ($addLogEntry, $append, &$pending) {
$pending[$type] = ($pending[$type] ?? '').$output;
$complete_output = RunRemoteProcess::takeCompleteOutput($pending[$type]);
if ($complete_output !== '') {
$addLogEntry($type, $complete_output);
}
$this->application_deployment_queue->save();
$this->saveCommandOutput($sanitized_output, $append);
$this->saveCommandOutput(sanitize_utf8_text($output), $append);
});
$this->application_deployment_queue->update([
'current_process_id' => $process->id(),
]);
$process_result = $process->wait();
try {
$process_result = $process->wait();
} finally {
foreach ($pending as $type => $output) {
$remaining_output = RunRemoteProcess::takeCompleteOutput($pending[$type], final: true);
if ($remaining_output !== '') {
$addLogEntry($type, $remaining_output);
}
}
}
if ($process_result->exitCode() !== 0) {
if (! $ignore_errors) {
// Check if deployment was cancelled while command was running
@@ -324,40 +304,10 @@ trait ExecuteRemoteCommand
{
$retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
$new_log_entry = [
'output' => $this->redact_sensitive_info($retryMessage),
'type' => 'stdout',
'timestamp' => Carbon::now('UTC'),
'hidden' => false,
'batch' => static::$batch_counter,
];
if (! $this->application_deployment_queue->logs) {
$new_log_entry['order'] = 1;
$previous_logs = [];
} else {
try {
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$previous_logs = [];
$new_log_entry['order'] = 1;
}
if (is_array($previous_logs)) {
$new_log_entry['order'] = count($previous_logs) + 1;
} else {
$previous_logs = [];
$new_log_entry['order'] = 1;
}
}
$previous_logs[] = $new_log_entry;
try {
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
}
$this->application_deployment_queue->save();
$this->registerRemoteSecretsForLogs();
$this->application_deployment_queue->addLogEntry(
message: $retryMessage,
batch: static::$batch_counter,
);
}
}
+30 -2
View File
@@ -2,10 +2,15 @@
namespace App\Traits;
use App\Rules\SafeWebhookUrl;
use Closure;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
/**
* Shared response handling for the notification jobs. The job must have a
@@ -14,6 +19,28 @@ use RuntimeException;
*/
trait HandlesNotificationResponse
{
/**
* Send the request and replace HTTP client exceptions, whose messages contain
* the full URL (bot token, secret webhook path), with one that only names the
* scheme and host. The original exception is not kept as `previous`, so the
* URL never reaches failed_jobs or Sentry.
*
* @param Closure(): Response $send
*/
protected function sendNotificationRequest(string $channel, string $url, Closure $send): Response
{
try {
return $send();
} catch (ConnectionException|RequestException|TransferException $e) {
throw new RuntimeException(sprintf(
'%s notification request to %s failed (%s).',
$channel,
SafeWebhookUrl::redactedUrlForLog($url),
class_basename($e),
));
}
}
/**
* Rate limits and server errors are retried by the queue. A configuration
* error (invalid token, deleted webhook) counts as a delivery failure and
@@ -32,7 +59,8 @@ trait HandlesNotificationResponse
throw new RuntimeException("{$channel} notification failed with {$response->status()} status code.");
}
$reason = "{$channel} returned HTTP {$response->status()}: ".Str::limit($response->body(), 200);
// The response body is never part of the reason: it can echo tokens or webhook secrets.
$reason = "{$channel} returned HTTP {$response->status()} (".(HttpResponse::$statusTexts[$response->status()] ?? 'Client Error').').';
if ($isConfigurationError) {
$this->settings?->recordDeliveryFailure($reason);
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Traits;
/**
* The "Environment values in logs" setting of a standalone database.
*/
trait HasLogRedactionSetting
{
public function initializeHasLogRedactionSetting(): void
{
$this->mergeFillable(['redact_all_env_values_in_logs']);
$this->mergeCasts(['redact_all_env_values_in_logs' => 'boolean']);
}
/**
* Tell if every environment value is hidden in logs. When false, only locked values are hidden.
*/
public function redactsAllEnvValuesInLogs(): bool
{
return $this->redact_all_env_values_in_logs ?? true;
}
}
+5
View File
@@ -40,8 +40,13 @@ trait TracksDeliveryFailures
});
}
/**
* The reason must not contain a provider response body. It is shared with the
* team, the team admins, and the instance admins, so it is redacted once more.
*/
public function recordDeliveryFailure(string $reason): void
{
$reason = remove_iip($reason);
$this->increment('failure_count');
Log::warning('Notification delivery failed', [
+2
View File
@@ -203,6 +203,7 @@ function sharedDataApplications(?Application $application = null): array
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'is_pr_deployments_public_enabled' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
@@ -478,6 +479,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('disable_build_cache');
$request->offsetUnset('inject_build_args_to_dockerfile');
$request->offsetUnset('is_env_sorting_enabled');
$request->offsetUnset('redact_all_env_values_in_logs');
$request->offsetUnset('is_pr_deployments_public_enabled');
$request->offsetUnset('stop_grace_period');
$request->offsetUnset('docker_images_to_keep');
+2 -39
View File
@@ -9,6 +9,7 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\RemoteProcessCommand;
use Carbon\Carbon;
use Illuminate\Contracts\Process\ProcessResult;
@@ -371,45 +372,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
function remove_iip($text)
{
// Ensure the input is valid UTF-8 before processing
$text = sanitize_utf8_text($text);
// Git access tokens
$text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text);
$text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text);
// ANSI color codes
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
// Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.)
// (protocol://user:password@host → protocol://user:<REDACTED>@host)
$text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text);
// Email addresses
$text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text);
// Bearer/JWT tokens
$text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text);
// GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh)
$text = preg_replace('/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/', REDACTED, $text);
// GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token)
$text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text);
// AWS credentials (Access Key ID starts with AKIA, ABIA, ACCA, ASIA)
$text = preg_replace('/\b(A(?:KIA|BIA|CCA|SIA)[A-Z0-9]{16})\b/', REDACTED, $text);
// AWS Secret Access Key (40 character base64-ish string, typically follows access key)
$text = preg_replace('/(aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[=:]\s*[\'"]?([A-Za-z0-9\/+=]{40})[\'"]?/i', '$1='.REDACTED, $text);
// API keys (common patterns)
$text = preg_replace('/(api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*[\'"]?[A-Za-z0-9\-_]{16,}[\'"]?/i', '$1='.REDACTED, $text);
// Private key blocks
$text = preg_replace('/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/', REDACTED, $text);
return $text;
return app(SensitiveDataRedactor::class)->redactText((string) $text);
}
/**
+8 -3
View File
@@ -38,6 +38,7 @@ use App\Models\StandaloneSqlite;
use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\DnsRecordHints;
use Carbon\CarbonImmutable;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
@@ -1716,10 +1717,14 @@ function removeAnsiColors($text)
return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text);
}
function sanitizeLogsForExport(string $text): string
/**
* Redact log text before it leaves Coolify (copy, download, API, MCP).
*
* @param array{known?: array<int, string>, locked?: array<int, string>} $secrets Resource values to hide, usually from EnvironmentVariable::logRedactionSecretsFor().
*/
function sanitizeLogsForExport(string $text, array $secrets = []): string
{
// All sanitization is now handled by remove_iip()
return remove_iip($text);
return app(SensitiveDataRedactor::class)->redactText($text, $secrets['known'] ?? [], $secrets['locked'] ?? []);
}
function getTopLevelNetworks(Service|Application $resource): Collection
+25
View File
@@ -1,5 +1,7 @@
<?php
use App\Logging\RedactSensitiveAuditData;
use App\Logging\RedactSensitiveData;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
@@ -50,6 +52,19 @@ return [
|
*/
/*
|--------------------------------------------------------------------------
| Log Redaction
|--------------------------------------------------------------------------
|
| Secrets are removed from every log channel before a line is written.
| Self-hosted instances can set LOG_REDACTION_ENABLED=false to write the
| raw text, for example while debugging. Coolify Cloud always redacts.
|
*/
'redaction_enabled' => (bool) env('LOG_REDACTION_ENABLED', true),
'channels' => [
'stack' => [
'driver' => 'stack',
@@ -61,6 +76,7 @@ return [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'tap' => [RedactSensitiveData::class],
],
'daily' => [
@@ -68,6 +84,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
'tap' => [RedactSensitiveData::class],
],
'slack' => [
@@ -76,11 +93,13 @@ return [
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
'tap' => [RedactSensitiveData::class],
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'tap' => [RedactSensitiveData::class],
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
@@ -92,6 +111,7 @@ return [
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'tap' => [RedactSensitiveData::class],
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
@@ -102,12 +122,14 @@ return [
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'tap' => [RedactSensitiveData::class],
'facility' => LOG_USER,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'tap' => [RedactSensitiveData::class],
],
'null' => [
@@ -124,6 +146,7 @@ return [
'path' => storage_path('logs/scheduled.log'),
'level' => 'debug',
'days' => 7,
'tap' => [RedactSensitiveData::class],
],
'scheduled-errors' => [
@@ -131,6 +154,7 @@ return [
'path' => storage_path('logs/scheduled-errors.log'),
'level' => 'warning',
'days' => 14,
'tap' => [RedactSensitiveData::class],
],
'audit' => [
@@ -139,6 +163,7 @@ return [
'level' => env('LOG_AUDIT_LEVEL', 'info'),
'days' => env('LOG_AUDIT_DAYS', 90),
'replace_placeholders' => true,
'tap' => [RedactSensitiveAuditData::class],
],
],
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->boolean('redact_all_env_values_in_logs')->default(true);
});
Schema::table('services', function (Blueprint $table) {
$table->boolean('redact_all_env_values_in_logs')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('redact_all_env_values_in_logs');
});
Schema::table('services', function (Blueprint $table) {
$table->dropColumn('redact_all_env_values_in_logs');
});
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasIndex('application_settings', ['application_id'])) {
return;
}
Schema::table('application_settings', function (Blueprint $table) {
$table->index('application_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasIndex('application_settings', ['application_id'])) {
return;
}
Schema::table('application_settings', function (Blueprint $table) {
$table->dropIndex(['application_id']);
});
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private array $tables = [
'standalone_postgresqls',
'standalone_redis',
'standalone_mongodbs',
'standalone_mysqls',
'standalone_mariadbs',
'standalone_keydbs',
'standalone_dragonflies',
'standalone_clickhouses',
'standalone_sqlites',
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach ($this->tables as $table) {
if (Schema::hasColumn($table, 'redact_all_env_values_in_logs')) {
continue;
}
Schema::table($table, function (Blueprint $table) {
$table->boolean('redact_all_env_values_in_logs')->default(true);
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
foreach ($this->tables as $table) {
if (! Schema::hasColumn($table, 'redact_all_env_values_in_logs')) {
continue;
}
Schema::table($table, function (Blueprint $table) {
$table->dropColumn('redact_all_env_values_in_logs');
});
}
}
};
+52 -2
View File
@@ -886,6 +886,10 @@
"type": "boolean",
"description": "Sort environment variables."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
},
"is_pr_deployments_public_enabled": {
"type": "boolean",
"description": "Make pull request deployments public."
@@ -1512,6 +1516,10 @@
"type": "boolean",
"description": "Sort environment variables."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
},
"is_pr_deployments_public_enabled": {
"type": "boolean",
"description": "Make pull request deployments public."
@@ -2138,6 +2146,10 @@
"type": "boolean",
"description": "Sort environment variables."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
},
"is_pr_deployments_public_enabled": {
"type": "boolean",
"description": "Make pull request deployments public."
@@ -2659,6 +2671,10 @@
"type": "boolean",
"description": "Sort environment variables."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
},
"is_pr_deployments_public_enabled": {
"type": "boolean",
"description": "Make pull request deployments public."
@@ -3160,6 +3176,10 @@
"type": "boolean",
"description": "Sort environment variables."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
},
"is_pr_deployments_public_enabled": {
"type": "boolean",
"description": "Make pull request deployments public."
@@ -3932,6 +3952,10 @@
"type": "boolean",
"description": "Sort environment variables."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
},
"is_pr_deployments_public_enabled": {
"type": "boolean",
"description": "Make pull request deployments public."
@@ -7418,6 +7442,10 @@
"description": "Healthcheck start period in seconds.",
"minimum": 0,
"default": 5
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions."
}
},
"type": "object"
@@ -9112,7 +9140,7 @@
"Databases"
],
"summary": "List backup executions",
"description": "Get all executions for a specific backup configuration.",
"description": "Get all executions for a specific backup configuration. The execution `message` is only returned (redacted) to tokens with the `read:sensitive` or `root` ability; otherwise it is `null`.",
"operationId": "list-backup-executions",
"parameters": [
{
@@ -9158,7 +9186,11 @@
"type": "string"
},
"message": {
"type": "string"
"description": "Redacted execution output. Null unless the token has the `read:sensitive` or `root` ability.",
"type": [
"string",
"null"
]
},
"status": {
"type": "string"
@@ -19657,6 +19689,11 @@
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"default": true,
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
},
"tags": {
"type": "array",
"items": {
@@ -19984,6 +20021,11 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"default": true,
"description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions."
}
},
"type": "object"
@@ -24439,6 +24481,10 @@
"use_build_secrets": {
"type": "boolean"
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Hide every environment variable value in logs. When false, only locked values are hidden."
},
"inject_build_args_to_dockerfile": {
"type": "boolean"
},
@@ -25024,6 +25070,10 @@
"type": "boolean",
"description": "The flag to enable the container label escape."
},
"redact_all_env_values_in_logs": {
"type": "boolean",
"description": "Hide every environment variable value in logs. When false, only locked values are hidden."
},
"is_container_label_readonly_enabled": {
"type": "boolean",
"description": "The flag to enable the container label readonly."
+37 -2
View File
@@ -572,6 +572,9 @@ paths:
is_env_sorting_enabled:
type: boolean
description: 'Sort environment variables.'
redact_all_env_values_in_logs:
type: boolean
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
is_pr_deployments_public_enabled:
type: boolean
description: 'Make pull request deployments public.'
@@ -985,6 +988,9 @@ paths:
is_env_sorting_enabled:
type: boolean
description: 'Sort environment variables.'
redact_all_env_values_in_logs:
type: boolean
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
is_pr_deployments_public_enabled:
type: boolean
description: 'Make pull request deployments public.'
@@ -1398,6 +1404,9 @@ paths:
is_env_sorting_enabled:
type: boolean
description: 'Sort environment variables.'
redact_all_env_values_in_logs:
type: boolean
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
is_pr_deployments_public_enabled:
type: boolean
description: 'Make pull request deployments public.'
@@ -1752,6 +1761,9 @@ paths:
is_env_sorting_enabled:
type: boolean
description: 'Sort environment variables.'
redact_all_env_values_in_logs:
type: boolean
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
is_pr_deployments_public_enabled:
type: boolean
description: 'Make pull request deployments public.'
@@ -2092,6 +2104,9 @@ paths:
is_env_sorting_enabled:
type: boolean
description: 'Sort environment variables.'
redact_all_env_values_in_logs:
type: boolean
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
is_pr_deployments_public_enabled:
type: boolean
description: 'Make pull request deployments public.'
@@ -2605,6 +2620,9 @@ paths:
is_env_sorting_enabled:
type: boolean
description: 'Sort environment variables.'
redact_all_env_values_in_logs:
type: boolean
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
is_pr_deployments_public_enabled:
type: boolean
description: 'Make pull request deployments public.'
@@ -4805,6 +4823,9 @@ paths:
description: 'Healthcheck start period in seconds.'
minimum: 0
default: 5
redact_all_env_values_in_logs:
type: boolean
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions.'
type: object
responses:
'200':
@@ -5992,7 +6013,7 @@ paths:
tags:
- Databases
summary: 'List backup executions'
description: 'Get all executions for a specific backup configuration.'
description: 'Get all executions for a specific backup configuration. The execution `message` is only returned (redacted) to tokens with the `read:sensitive` or `root` ability; otherwise it is `null`.'
operationId: list-backup-executions
parameters:
-
@@ -6016,7 +6037,7 @@ paths:
application/json:
schema:
properties:
executions: { type: array, items: { properties: { uuid: { type: string }, filename: { type: string }, size: { type: integer }, created_at: { type: string }, message: { type: string }, status: { type: string } }, type: object } }
executions: { type: array, items: { properties: { uuid: { type: string }, filename: { type: string }, size: { type: integer }, created_at: { type: string }, message: { description: 'Redacted execution output. Null unless the token has the `read:sensitive` or `root` ability.', type: [string, 'null'] }, status: { type: string } }, type: object } }
type: object
'404':
description: 'Backup configuration not found.'
@@ -12438,6 +12459,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
redact_all_env_values_in_logs:
type: boolean
default: true
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
tags:
type: array
items: { type: string }
@@ -12624,6 +12649,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
redact_all_env_values_in_logs:
type: boolean
default: true
description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'
type: object
responses:
'200':
@@ -15609,6 +15638,9 @@ components:
type: boolean
use_build_secrets:
type: boolean
redact_all_env_values_in_logs:
type: boolean
description: 'Hide every environment variable value in logs. When false, only locked values are hidden.'
inject_build_args_to_dockerfile:
type: boolean
include_source_commit_in_build:
@@ -16028,6 +16060,9 @@ components:
is_container_label_escape_enabled:
type: boolean
description: 'The flag to enable the container label escape.'
redact_all_env_values_in_logs:
type: boolean
description: 'Hide every environment variable value in logs. When false, only locked values are hidden.'
is_container_label_readonly_enabled:
type: boolean
description: 'The flag to enable the container label readonly.'
+16 -4
View File
@@ -348,9 +348,9 @@ export function initializeRuntimeLogsComponent() {
});
},
visibleText() {
visibleText(source = filtered) {
const withTime = this.$wire.showTimeStamps;
return filtered
return source
.map((line) => (withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text))
.join('\n');
},
@@ -406,9 +406,21 @@ export function initializeRuntimeLogsComponent() {
});
},
downloadLogs() {
// The browser holds unredacted lines, so fetch the redacted copy from the server
// and apply the same level and search filters before downloading it.
async downloadLogs() {
let logs;
try {
logs = await this.$wire.copyLogs();
} catch {
window.Livewire.dispatch('error', ['Failed to prepare logs for download.']);
return;
}
if (!logs) return;
let id = 0;
const exported = filterLogLines(parseLogLines(logs, () => ++id), this.logFilters, this.searchQuery);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
downloadText(this.visibleText() + '\n', `${this.containerName}-logs-${timestamp}.txt`);
downloadText(this.visibleText(exported) + '\n', `${this.containerName}-logs-${timestamp}.txt`);
},
makeFullscreen() {
@@ -28,27 +28,40 @@
</x-slot:actions>
@endcan
@if ($view === 'normal')
@if ($resourceClass === 'App\Models\Application')
<div class="grid w-full items-end gap-4 sm:grid-cols-2">
@if (data_get($resource, 'build_pack') !== 'dockercompose')
<x-forms.listbox id="is_env_sorting_enabled" label="Environment variable order"
onChange="instantSave"
helper="Controls how environment variables are ordered in the list and written to the generated .env file on deploy."
<div class="flex w-full flex-col gap-4">
@if ($resourceClass === 'App\Models\Application')
<div class="grid w-full items-end gap-4 sm:grid-cols-2">
@if (data_get($resource, 'build_pack') !== 'dockercompose')
<x-forms.listbox id="is_env_sorting_enabled" label="Environment variable order"
onChange="instantSave"
helper="Controls how environment variables are ordered in the list and written to the generated .env file on deploy."
:options="[
['value' => false, 'label' => 'Creation order'],
['value' => true, 'label' => 'Alphabetical'],
]" :disabled="! auth()->user()->can('manageEnvironment', $resource)" />
@endif
<x-forms.listbox id="use_build_secrets" label="Build secrets" onChange="instantSave"
helper="Docker BuildKit secrets keep values out of the final image for enhanced security during builds. Requires Docker 18.09+ with BuildKit support."
:options="[
['value' => false, 'label' => 'Creation order'],
['value' => true, 'label' => 'Alphabetical'],
['value' => false, 'label' => 'Standard build arguments'],
['value' => true, 'label' => 'Docker BuildKit secrets'],
]" :disabled="! auth()->user()->can('manageEnvironment', $resource)" />
@endif
<x-forms.listbox id="use_build_secrets" label="Build secrets" onChange="instantSave"
helper="Docker BuildKit secrets keep values out of the final image for enhanced security during builds. Requires Docker 18.09+ with BuildKit support."
:options="[
['value' => false, 'label' => 'Standard build arguments'],
['value' => true, 'label' => 'Docker BuildKit secrets'],
]" :disabled="! auth()->user()->can('manageEnvironment', $resource)" />
</div>
@else
<p class="text-sm text-neutral-500 dark:text-fg-dim">Manage this resource's environment variables below.</p>
@endif
</div>
@else
<p class="text-sm text-neutral-500 dark:text-fg-dim">Manage this resource's environment variables below.</p>
@endif
@if ($this->hasLogRedactionSetting())
<div class="grid w-full items-end gap-4 sm:grid-cols-2">
<x-forms.listbox id="redact_all_env_values_in_logs" label="Environment values in logs"
onChange="saveLogRedactionSetting"
helper="Hide every environment variable value in deployment logs, scheduled task output, copied or downloaded logs, and logs from the API and MCP. Live log streaming is not changed. Locked variables and known secret formats are always hidden."
:options="[
['value' => true, 'label' => 'Hide all values'],
['value' => false, 'label' => 'Hide locked values only'],
]" :disabled="! auth()->user()->can('manageEnvironment', $resource)" />
</div>
@endif
</div>
@else
<form wire:submit.prevent='submit' class="flex w-full flex-col gap-4">
@can('manageEnvironment', $resource)
@@ -194,6 +194,7 @@
<x-reicon name="chevron-down" class="size-3 transition-transform" ::class="open ? 'rotate-180' : ''" />
</button></x-slot:trigger>
<div x-data="{ downloadingAllLogs: false }">
<p class="px-2 py-1.5 text-xs text-neutral-500 dark:text-fg-dim">Copied and downloaded logs hide secrets. The live view shows the raw output.</p>
<button type="button" role="menuitem" class="listbox-option"
x-on:click="
open = false;
@@ -215,7 +216,7 @@
</button>
<button type="button" role="menuitem" class="listbox-option"
x-on:click="downloadLogs(); open = false">
<span class="flex-1 text-left">Download displayed logs</span>
<span class="flex-1 text-left">Download logs</span>
</button>
<button type="button" role="menuitem" x-on:click="
downloadingAllLogs = true;
@@ -7,8 +7,14 @@
@if (! $link && $availableTokens->isEmpty())
<x-empty title="No secret manager tokens"
description="Add a Doppler, Infisical, or HashiCorp Vault token under Keys & Tokens > Integration Tokens first."
icon-name="keys" size="sm" />
description="Add a Doppler, Infisical, or HashiCorp Vault token first."
icon-name="keys" size="sm">
<x-slot:actions>
<a class="button" href="{{ route('security.integration-tokens') }}" {{ wireNavigate() }}>
Integration tokens
</a>
</x-slot:actions>
</x-empty>
@else
@can('update', $resource)
<div class="application-settings-form flex w-full flex-col gap-4">
@@ -155,7 +155,7 @@ it('keeps deployment logging available if value formatting fails', function () {
[$application, $server] = makeDeploymentControlVarFixture();
$variable = new class extends EnvironmentVariable
{
public function logRedactionValues(): array
public function logRedactionValues(?Collection $sharedVariables = null): array
{
throw new RuntimeException('Harmless formatting failure');
}
@@ -3,15 +3,20 @@
use App\Livewire\Project\Shared\EnvironmentVariable\All as EnvironmentVariableAll;
use App\Livewire\Project\Shared\EnvironmentVariable\Show as EnvironmentVariableShow;
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Livewire\Livewire;
@@ -344,3 +349,181 @@ test('member keeps isValueHidden after switching to a team they own', function (
expect($component->get('isValueHidden'))->toBeTrue();
});
test('admin can limit log redaction to locked values for one application', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application])
->assertSet('redact_all_env_values_in_logs', true)
->assertSee('Environment values in logs')
->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting')
->assertHasNoErrors();
expect($this->application->settings->fresh()->redact_all_env_values_in_logs)->toBeFalse();
});
test('admin can limit log redaction to locked values for one service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Livewire::test(EnvironmentVariableAll::class, ['resource' => $service])
->assertSet('redact_all_env_values_in_logs', true)
->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting');
expect($service->fresh()->redact_all_env_values_in_logs)->toBeFalse();
});
test('admin can limit log redaction to locked values for one database and a member cannot', function () {
$database = StandalonePostgresql::query()->create([
'name' => 'log-redaction-database',
'postgres_password' => 'database-password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
session(['currentTeam' => $this->team]);
$this->actingAs($this->member);
Livewire::test(EnvironmentVariableAll::class, ['resource' => $database])
->assertSee('Environment values in logs')
->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting')
->assertSet('redact_all_env_values_in_logs', true)
->assertNotDispatched('success');
expect($database->fresh()->redact_all_env_values_in_logs)->toBeTrue();
$this->actingAs($this->admin);
Livewire::test(EnvironmentVariableAll::class, ['resource' => $database])
->assertSet('redact_all_env_values_in_logs', true)
->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting')
->assertDispatched('success');
expect($database->fresh()->redact_all_env_values_in_logs)->toBeFalse();
});
test('member cannot change log redaction and the control returns to the stored value', function (string $resourceType, bool $storedValue) {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$resource = $resourceType === 'application' ? $this->application : Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$settings = $resource instanceof Application ? $resource->settings : $resource;
$settings->update(['redact_all_env_values_in_logs' => $storedValue]);
Livewire::test(EnvironmentVariableAll::class, ['resource' => $resource])
->set('redact_all_env_values_in_logs', ! $storedValue)
->call('saveLogRedactionSetting')
->assertSet('redact_all_env_values_in_logs', $storedValue)
->assertNotDispatched('success');
expect($settings->fresh()->redact_all_env_values_in_logs)->toBe($storedValue);
})->with(['application', 'service'])->with([true, false]);
test('log redaction control returns to the stored value after a write failure', function (string $resourceType, bool $storedValue) {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$resource = $resourceType === 'application' ? $this->application : Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$settings = $resource instanceof Application ? $resource->settings : $resource;
$settings->update(['redact_all_env_values_in_logs' => $storedValue]);
$component = Livewire::test(EnvironmentVariableAll::class, ['resource' => $resource]);
$event = 'eloquent.saving: '.get_class($settings);
Event::listen($event, function () {
throw new RuntimeException('Saving the log redaction setting failed.');
});
try {
$component->set('redact_all_env_values_in_logs', ! $storedValue)
->call('saveLogRedactionSetting')
->assertSet('redact_all_env_values_in_logs', $storedValue)
->assertNotDispatched('success');
expect($settings->fresh()->redact_all_env_values_in_logs)->toBe($storedValue);
} finally {
Event::forget($event);
}
})->with(['application', 'service'])->with([true, false]);
test('saving the log redaction setting records an audit event for the application', function () {
$this->withoutDefer();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application])
->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting');
$event = AuditEvent::query()->where('event', 'ui.application.settings_updated')->sole();
expect($event->metadata['changed_fields'])->toBe(['redact_all_env_values_in_logs'])
->and($event->team_id)->toBe($this->team->id);
});
test('saving the log redaction setting records an audit event for the service', function () {
$this->withoutDefer();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Livewire::test(EnvironmentVariableAll::class, ['resource' => $service])
->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting');
expect(AuditEvent::query()->where('event', 'ui.service.updated')->get()->pluck('metadata.changed_fields')->all())
->toContain(['redact_all_env_values_in_logs']);
});
test('member of the resource team cannot change log redaction after switching to a team they own', function () {
$ownTeam = Team::factory()->create();
$this->member->teams()->attach($ownTeam, ['role' => 'owner']);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application]);
session(['currentTeam' => $ownTeam]);
Cache::flush();
$component->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting');
expect($this->application->settings->fresh()->redact_all_env_values_in_logs)->toBeTrue();
});
test('admin of the resource team can change log redaction after switching to a team where they are a member', function () {
$otherTeam = Team::factory()->create();
$this->admin->teams()->attach($otherTeam, ['role' => 'member']);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application]);
session(['currentTeam' => $otherTeam]);
Cache::flush();
$component->set('redact_all_env_values_in_logs', false)
->call('saveLogRedactionSetting');
expect($this->application->settings->fresh()->redact_all_env_values_in_logs)->toBeFalse();
});
@@ -0,0 +1,263 @@
<?php
use App\Events\BackupCreated;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\SendMessageToTelegramJob;
use App\Jobs\SendWebhookJob;
use App\Jobs\VolumeBackupJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Internal\GeneralNotification;
use App\Notifications\VolumeBackup\BackupFailed as VolumeBackupFailed;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
config(['constants.ssh.mux_enabled' => false]);
Event::fake([BackupCreated::class]);
Notification::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => true]);
$this->team = Team::factory()->create();
// The notification fake only records notifications that have at least one enabled channel.
$this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
$this->s3 = S3Storage::create([
'name' => 'Backups',
'region' => 'us-east-1',
'key' => 'redaction-s3-access-key',
'secret' => 'redaction-s3-secret-value',
'bucket' => 'bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
});
function redactionMysqlBackup(Team $team, Environment $environment, StandaloneDocker $destination, ?S3Storage $s3 = null): ScheduledDatabaseBackup
{
$database = create_standalone_mysql($environment->id, $destination, [
'mysql_root_password' => str_repeat('x', 5),
'mysql_database' => 'app',
'status' => 'running:healthy',
]);
return ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => $s3 !== null,
's3_storage_id' => $s3?->id,
'database_type' => StandaloneMysql::class,
'database_id' => $database->id,
'team_id' => $team->id,
'databases_to_backup' => 'app',
]);
}
/**
* @return array<string, string>
*/
function backupRedactionToken(User $user, Team $team, array $abilities): array
{
session(['currentTeam' => $team]);
$token = $user->createToken('backup-redaction', $abilities);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]);
return ['Authorization' => 'Bearer '.$token->plainTextToken];
}
it('redacts the database password from the stored execution and the failure notification', function () {
$backup = redactionMysqlBackup($this->team, $this->environment, $this->destination);
$password = $backup->database->mysql_root_password;
Process::fake(function ($process) use ($password) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
if (str_contains($command, 'mysqldump')) {
return Process::result(errorOutput: "mysqldump: Got error: 1045: Access denied for user 'root' with password {$password}", exitCode: 1);
}
return Process::result(output: '');
});
(new DatabaseBackupJob($backup))->handle();
$execution = $backup->executions()->latest('id')->firstOrFail();
expect($execution->status)->toBe('failed')
->and($execution->message)->toContain('Access denied')
->and($execution->message)->not->toContain($password);
Notification::assertSentTo($this->team, BackupFailed::class, fn (BackupFailed $notification) => str_contains((string) $notification->output, 'Access denied')
&& ! str_contains((string) $notification->output, $password));
});
it('redacts the S3 key and secret from a permanently failed database backup', function () {
$backup = redactionMysqlBackup($this->team, $this->environment, $this->destination, $this->s3);
ScheduledDatabaseBackupExecution::create([
'uuid' => 'redaction-run',
'scheduled_database_backup_id' => $backup->id,
'database_name' => 'app',
'status' => 'running',
]);
$job = new DatabaseBackupJob($backup);
$job->executionUuidPrefix = 'redaction-run';
$job->failed(new RuntimeException('mc: alias set failed for key redaction-s3-access-key and secret redaction-s3-secret-value'));
$message = $backup->executions()->sole()->message;
expect($message)->toContain('mc: alias set failed')
->and($message)->not->toContain('redaction-s3-access-key')
->and($message)->not->toContain('redaction-s3-secret-value');
Notification::assertSentTo($this->team, BackupFailed::class, fn (BackupFailed $notification) => ! str_contains((string) $notification->output, 'redaction-s3-secret-value')
&& ! str_contains((string) $notification->output, 'redaction-s3-access-key'));
});
it('redacts the S3 key and secret from a failed volume backup', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$volume = LocalPersistentVolume::create([
'name' => 'app-data',
'mount_path' => '/data',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$backup = $volume->scheduledBackups()->create([
'team_id' => $this->team->id,
's3_storage_id' => $this->s3->id,
'save_s3' => true,
'frequency' => '0 0 * * *',
]);
$execution = $backup->executions()->create(['s3_storage_id' => $this->s3->id]);
(new VolumeBackupJob($backup))->failed(new RuntimeException('mc: upload failed with secret redaction-s3-secret-value'));
expect($execution->fresh()->message)->toContain('mc: upload failed')
->and($execution->fresh()->message)->not->toContain('redaction-s3-secret-value');
Notification::assertSentTo($this->team, VolumeBackupFailed::class, fn (VolumeBackupFailed $notification) => str_contains($notification->output, 'mc: upload failed')
&& ! str_contains($notification->output, 'redaction-s3-secret-value'));
});
it('does not put the telegram bot token into the connection error', function () {
Http::fake(['*' => Http::failedConnection()]);
try {
(new SendMessageToTelegramJob('Message', [], '123456:telegram-bot-secret-token', '42'))->handle();
$this->fail('The connection error was not reported.');
} catch (RuntimeException $exception) {
expect($exception->getMessage())->toContain('https://api.telegram.org')
->and($exception->getMessage())->not->toContain('telegram-bot-secret-token')
->and($exception->getPrevious())->toBeNull();
}
});
it('does not put the secret webhook path into the connection error', function () {
Http::fake(['*' => Http::failedConnection()]);
try {
(new SendWebhookJob(['event' => 'test'], 'https://example.com/hooks/webhook-secret-path'))->handle();
$this->fail('The connection error was not reported.');
} catch (RuntimeException $exception) {
expect($exception->getMessage())->toContain('https://example.com')
->and($exception->getMessage())->not->toContain('webhook-secret-path')
->and($exception->getPrevious())->toBeNull();
}
});
it('does not share the provider response body when a channel is disabled', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
foreach ([$rootTeam, $this->team] as $team) {
$team->discordNotificationSettings->update(['discord_enabled' => true, 'discord_webhook_url' => 'https://example.com/discord']);
}
$settings = tap($this->team->telegramNotificationSettings)->update(['telegram_enabled' => true, 'telegram_token' => 'token', 'telegram_chat_id' => '123']);
Http::fake(['*' => Http::response(['ok' => false, 'description' => 'Unauthorized provider-body-marker'], 401)]);
foreach (range(1, 3) as $attempt) {
(new SendMessageToTelegramJob('Message', [], 'token', '123', null, $settings->fresh()))->handle();
}
expect($settings->fresh()->telegram_enabled)->toBeFalse();
Notification::assertSentTo($rootTeam, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'HTTP 401')
&& ! str_contains($notification->message, 'provider-body-marker'));
Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => ! str_contains($notification->message, 'provider-body-marker'));
});
it('redacts secrets from server validation logs', function () {
$this->server->update(['validation_logs' => 'Could not connect: postgres://coolify:validation-db-secret@10.0.0.5/app']);
expect($this->server->fresh()->validation_logs)->toContain('Could not connect')
->and($this->server->fresh()->validation_logs)->not->toContain('validation-db-secret');
});
describe('backup executions api', function () {
beforeEach(function () {
$this->owner = User::factory()->create();
$this->team->members()->attach($this->owner, ['role' => 'owner']);
$this->backup = redactionMysqlBackup($this->team, $this->environment, $this->destination);
ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $this->backup->id,
'database_name' => 'app',
'status' => 'failed',
'message' => 'dump failed: mysql://root:api-db-secret@db:3306/app',
]);
$this->executionsUrl = "/api/v1/databases/{$this->backup->database->uuid}/backups/{$this->backup->uuid}/executions";
$this->backupsUrl = "/api/v1/databases/{$this->backup->database->uuid}/backups";
});
it('hides the execution message from a token without read:sensitive', function () {
$headers = backupRedactionToken($this->owner, $this->team, ['read']);
$executions = $this->withHeaders($headers)->getJson($this->executionsUrl)->assertOk();
$backups = $this->withHeaders($headers)->getJson($this->backupsUrl)->assertOk();
expect($executions->json('executions.0.status'))->toBe('failed')
->and($executions->json('executions.0.message'))->toBeNull()
->and($backups->json('0.executions.0.message'))->toBeNull()
->and($executions->getContent().$backups->getContent())->not->toContain('dump failed');
});
it('returns the redacted execution message to a token with read:sensitive', function () {
$headers = backupRedactionToken($this->owner, $this->team, ['read', 'read:sensitive']);
$message = $this->withHeaders($headers)->getJson($this->executionsUrl)->assertOk()->json('executions.0.message');
expect($message)->toContain('dump failed')
->and($message)->not->toContain('api-db-secret');
});
it('does not return executions of another team', function () {
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($this->owner, ['role' => 'owner']);
$headers = backupRedactionToken($this->owner, $otherTeam, ['read', 'read:sensitive']);
$this->withHeaders($headers)->getJson($this->executionsUrl)->assertNotFound();
});
});
@@ -0,0 +1,70 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
const BACKUP_MESSAGE_DB_PASSWORD = 'supersecretdbpw';
beforeEach(function () {
Queue::fake();
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
$this->database = StandalonePostgresql::query()->create([
'name' => 'backup-message-database',
'postgres_password' => BACKUP_MESSAGE_DB_PASSWORD,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$this->backup = ScheduledDatabaseBackup::query()->create([
'team_id' => $this->team->id,
'frequency' => '0 0 * * *',
'database_type' => $this->database->getMorphClass(),
'database_id' => $this->database->id,
]);
ScheduledDatabaseBackupExecution::query()->create([
'scheduled_database_backup_id' => $this->backup->id,
'status' => 'failed',
'message' => 'pg_dump failed: password '.BACKUP_MESSAGE_DB_PASSWORD.' rejected',
]);
$user = User::factory()->create();
$this->team->members()->attach($user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['read', 'read:sensitive'],
'team_id' => $this->team->id,
]);
$this->bearer = $token->getKey().'|'.$plainTextToken;
});
it('redacts the database password from backup execution messages', function (Closure $path, string $messageKey) {
$response = $this->withToken($this->bearer)->getJson($path())->assertOk();
expect($response->json($messageKey))->toBe('pg_dump failed: password '.REDACTED.' rejected');
})->with([
'database list' => [fn () => '/api/v1/databases', '0.backup_configs.0.latest_log.message'],
'database backups' => [fn () => '/api/v1/databases/'.test()->database->uuid.'/backups', '0.executions.0.message'],
'backup executions' => [fn () => '/api/v1/databases/'.test()->database->uuid.'/backups/'.test()->backup->uuid.'/executions', 'executions.0.message'],
]);
@@ -0,0 +1,168 @@
<?php
use App\Events\BackupCreated;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\VolumeBackupJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\Team;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Notifications\VolumeBackup\BackupSuccess as VolumeBackupSuccess;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
const S3_DESTINATION_SECRETS = [
'first-access-id-7731',
'first-hidden-value-9921',
'second-access-id-4482',
'second-hidden-value-5513',
];
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-mux');
config(['constants.ssh.mux_enabled' => false, 'broadcasting.default' => 'null']);
defined('CURLOPT_RESOLVE') || define('CURLOPT_RESOLVE', 10203);
Event::fake([BackupCreated::class]);
Notification::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
// The notification fake only records notifications that have at least one enabled channel.
$this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
'ip' => '203.0.113.10',
]);
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
$this->storages = collect(['first', 'second'])->map(fn (string $name): S3Storage => S3Storage::create([
'name' => "{$name} storage",
'region' => 'us-east-1',
'key' => "{$name}-access-id-".($name === 'first' ? '7731' : '4482'),
'secret' => "{$name}-hidden-value-".($name === 'first' ? '9921' : '5513'),
'bucket' => "{$name}-bucket",
'endpoint' => 'https://s3.amazonaws.com',
'team_id' => $this->team->id,
'is_usable' => true,
]));
$this->uploadError = 'mc: upload rejected for '.implode(' / ', S3_DESTINATION_SECRETS);
});
function expectNoS3DestinationSecrets(?string $text): void
{
foreach (S3_DESTINATION_SECRETS as $secret) {
expect((string) $text)->not->toContain($secret);
}
}
it('redacts the credentials of every S3 destination from a database backup upload failure', function () {
$database = create_standalone_mysql($this->environment->id, $this->destination, [
'mysql_root_password' => str_repeat('x', 5),
'mysql_database' => 'app',
'status' => 'running:healthy',
]);
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $this->storages[0]->id,
'database_type' => StandaloneMysql::class,
'database_id' => $database->id,
'team_id' => $this->team->id,
'databases_to_backup' => 'app',
]);
$backup->syncS3Storages($this->storages->pluck('id')->all());
Process::fake(function ($process) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return Process::result(output: str_contains($command, 'du -b') ? '128' : '');
});
$uploadError = $this->uploadError;
$job = Mockery::mock(DatabaseBackupJob::class.'[upload_to_s3]', [$backup->fresh()])->shouldAllowMockingProtectedMethods();
$job->shouldReceive('upload_to_s3')->andReturnUsing(fn () => throw new RuntimeException($uploadError));
$job->handle();
$execution = $backup->executions()->sole();
expect($execution->status)->toBe('success')
->and($execution->message)->toContain('mc: upload rejected')
->and($execution->s3Replicas()->count())->toBe(2);
expectNoS3DestinationSecrets($execution->message);
$execution->s3Replicas()->get()->each(function ($replica) {
expect($replica->message)->toContain('mc: upload rejected');
expectNoS3DestinationSecrets($replica->message);
});
Notification::assertSentTo($this->team, BackupSuccessWithS3Warning::class, function (BackupSuccessWithS3Warning $notification) {
expect($notification->s3_error)->toContain('mc: upload rejected');
expectNoS3DestinationSecrets($notification->s3_error);
return true;
});
});
it('redacts the credentials of every S3 destination from a volume backup upload failure', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$volume = LocalPersistentVolume::create([
'name' => 'app-data',
'mount_path' => '/data',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$backup = $volume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
'save_s3' => true,
]);
$backup->syncS3Storages($this->storages->pluck('id')->all());
$sshDisk = Storage::fake('ssh-keys');
$disk = Mockery::mock(FilesystemAdapter::class);
$disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]);
$disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue();
Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk);
Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk);
Process::fake([
'*du -b*' => '128',
'*mc cp*' => Process::result(errorOutput: $this->uploadError, exitCode: 1),
'*' => '',
]);
(new VolumeBackupJob($backup->fresh()))->handle();
$execution = ScheduledVolumeBackupExecution::query()->sole();
expect($execution->status)->toBe('success')
->and($execution->message)->toContain('mc: upload rejected')
->and($execution->s3Replicas()->count())->toBe(2);
expectNoS3DestinationSecrets($execution->message);
$execution->s3Replicas()->get()->each(function ($replica) {
expect($replica->message)->toContain('mc: upload rejected');
expectNoS3DestinationSecrets($replica->message);
});
Notification::assertSentTo($this->team, VolumeBackupSuccess::class, function (VolumeBackupSuccess $notification) {
expect($notification->warning)->toContain('mc: upload rejected');
expectNoS3DestinationSecrets($notification->warning);
return true;
});
});
@@ -0,0 +1,60 @@
<?php
use App\Livewire\Server\DockerCleanupExecutions;
use App\Models\DockerCleanupExecution;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'member']);
$this->otherTeam = Team::factory()->create();
$this->user->teams()->attach($this->otherTeam, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->execution = DockerCleanupExecution::create([
'server_id' => $this->server->id,
'status' => 'running',
'message' => implode("\n", array_fill(0, 150, 'cleanup output')),
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('denies log actions after server team access is removed', function (string $action) {
$component = Livewire::test(DockerCleanupExecutions::class, ['server' => $this->server])
->call('selectExecution', $this->execution->id);
$this->team->members()->detach($this->user->id);
$this->user->refresh();
session(['currentTeam' => $this->otherTeam]);
$arguments = $action === 'selectExecution' ? [$this->execution->id] : [];
$component->call($action, ...$arguments)->assertForbidden();
})->with(['refreshExecutions', 'selectExecution', 'polling', 'loadMoreLogs']);
it('allows log actions for a server team member after switching session teams', function () {
$component = Livewire::test(DockerCleanupExecutions::class, ['server' => $this->server]);
session(['currentTeam' => $this->otherTeam]);
$component->call('refreshExecutions')
->call('selectExecution', $this->execution->id)
->assertSet('selectedExecution.id', $this->execution->id)
->assertSet('isPollingActive', true)
->call('loadMoreLogs')
->assertSet('currentPage', 2);
$this->execution->update(['status' => 'success', 'message' => 'finished cleanup']);
$component->call('polling')
->assertSet('isPollingActive', false)
->assertSee('finished cleanup');
});
@@ -0,0 +1,220 @@
<?php
use App\Livewire\Project\Shared\GetLogs;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
const GET_LOGS_EXPORT_CANARY = 'coolify-canary-exported-env-value';
beforeEach(function () {
Server::flushIdentityMap();
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$this->server->settings->fill(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false])->save();
$this->server = Server::with('settings')->findOrFail($this->server->id);
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
$destinationAttributes = ['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()];
$this->application = Application::factory()->create(['environment_id' => $environment->id, ...$destinationAttributes]);
$this->service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $this->server->id, ...$destinationAttributes]);
$this->serviceApplication = ServiceApplication::create(['name' => 'web', 'service_id' => $this->service->id]);
foreach ([$this->application, $this->service] as $resource) {
EnvironmentVariable::query()->create([
'key' => 'EXPORT_CANARY',
'value' => GET_LOGS_EXPORT_CANARY,
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->id,
'is_preview' => false,
]);
}
$this->actingAs($user);
session(['currentTeam' => $team]);
Process::fake(['*' => Process::result(output: '2026-09-28T10:00:01Z booted with '.GET_LOGS_EXPORT_CANARY)]);
});
it('redacts the resource environment values when copying or downloading logs', function (string $resource, string $action) {
$parameters = $resource === 'application'
? ['resource' => $this->application]
: ['resource' => $this->service, 'servicesubtype' => $this->serviceApplication];
Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => 'test-container', ...$parameters])
->call($action)
->assertReturned('2026-09-28T10:00:01Z booted with '.REDACTED);
})->with(['application', 'service'])->with(['copyLogs', 'downloadAllLogs']);
it('redacts stored and effective database passwords in exported logs', function (string $path, bool $remote) {
$database = StandalonePostgresql::query()->create([
'name' => 'log-redaction-database',
'environment_id' => $this->application->environment_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
'postgres_password' => 'xy',
'status' => 'running:healthy',
]);
EnvironmentVariable::query()->create([
'key' => 'POSTGRES_PASSWORD',
'value' => $remote ? '{{vault.DB_PASSWORD}}' : 'effective-database-canary',
'resourceable_type' => $database->getMorphClass(),
'resourceable_id' => $database->id,
'is_preview' => false,
]);
if ($remote) {
Http::preventStrayRequests();
Http::fake([
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['DB_PASSWORD' => 'effective-database-canary']),
]);
$token = IntegrationToken::factory()->create([
'team_id' => $database->team()->id,
'provider' => 'doppler',
'capabilities' => ['secrets'],
]);
$database->secretManagerLink()->create(['integration_token_id' => $token->id]);
}
Process::fake(fn ($process) => match (true) {
str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])),
str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])),
str_contains($process->command, 'docker logs') => Process::result(output: 'booted xy effective-database-canary'),
default => Process::result(output: ''),
});
if ($path === 'api') {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$user = auth()->user();
$accessToken = $user->createToken('database-logs', ['read', 'read:sensitive']);
$accessToken->accessToken->update(['team_id' => $database->team()->id]);
$token = $accessToken->plainTextToken;
auth()->forgetGuards();
$this->withToken($token)->getJson("/api/v1/databases/{$database->uuid}/logs")
->assertOk()->assertJsonPath('logs', 'booted '.REDACTED.' '.REDACTED);
} else {
Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => $database->uuid, 'resource' => $database])
->call($path)->assertReturned('booted '.REDACTED.' '.REDACTED);
}
})->with(['copyLogs', 'downloadAllLogs', 'api'])->with([false, true]);
it('still exports database logs when its remote secret source fails', function (string $path) {
$database = StandalonePostgresql::query()->create([
'name' => 'log-redaction-failing-source',
'environment_id' => $this->application->environment_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
'postgres_password' => 'xy',
'status' => 'running:healthy',
]);
EnvironmentVariable::query()->create([
'key' => 'POSTGRES_PASSWORD',
'value' => '{{vault.DB_PASSWORD}}',
'resourceable_type' => $database->getMorphClass(),
'resourceable_id' => $database->id,
'is_preview' => false,
]);
Http::preventStrayRequests();
Http::fake(['https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['message' => 'down'], 500)]);
$token = IntegrationToken::factory()->create([
'team_id' => $database->team()->id,
'provider' => 'doppler',
'capabilities' => ['secrets'],
]);
$database->secretManagerLink()->create(['integration_token_id' => $token->id]);
Process::fake(fn ($process) => match (true) {
str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])),
str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])),
str_contains($process->command, 'docker logs') => Process::result(output: 'booted xy {{vault.DB_PASSWORD}}'),
default => Process::result(output: ''),
});
if ($path === 'api') {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$accessToken = auth()->user()->createToken('database-logs', ['read', 'read:sensitive']);
$accessToken->accessToken->update(['team_id' => $database->team()->id]);
auth()->forgetGuards();
$this->withToken($accessToken->plainTextToken)->getJson("/api/v1/databases/{$database->uuid}/logs")
->assertOk()->assertJsonPath('logs', 'booted '.REDACTED.' '.REDACTED);
} else {
Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => $database->uuid, 'resource' => $database])
->call($path)->assertReturned('booted '.REDACTED.' '.REDACTED);
}
})->with(['copyLogs', 'downloadAllLogs', 'api']);
it('redacts database environment values with the same rules as other resources', function (bool $redactAll, string $expected) {
$database = StandalonePostgresql::query()->create([
'name' => 'log-redaction-rules-database',
'environment_id' => $this->application->environment_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
'postgres_password' => 'xy',
]);
$database->update(['redact_all_env_values_in_logs' => $redactAll]);
foreach ([['TZ', 'UTC', false], ['APP_MODE', 'production-database-mode', false], ['LOCKED', 'q7', true]] as [$key, $value, $locked]) {
EnvironmentVariable::query()->create([
'key' => $key,
'value' => $value,
'resourceable_type' => $database->getMorphClass(),
'resourceable_id' => $database->id,
'is_preview' => false,
'is_shown_once' => $locked,
]);
}
$secrets = EnvironmentVariable::logRedactionSecretsFor($database->fresh());
expect(sanitizeLogsForExport('tz UTC mode production-database-mode locked q7 password xy', $secrets))->toBe($expected);
})->with([
'hide all values' => [true, 'tz UTC mode '.REDACTED.' locked '.REDACTED.' password '.REDACTED],
'hide locked values only' => [false, 'tz UTC mode production-database-mode locked '.REDACTED.' password '.REDACTED],
]);
it('redacts stored database credentials even without environment variables', function (string $model, array $attributes) {
$database = new $model($attributes);
if ($database instanceof StandaloneMongodb) {
$database->mongo_initdb_root_password = encrypt($attributes['mongo_initdb_root_password']);
}
$database->setRelation('runtime_environment_variables', collect());
$secrets = EnvironmentVariable::logRedactionSecretsFor($database);
expect(sanitizeLogsForExport('booted '.implode(' ', array_values($attributes)), $secrets))
->toBe('booted '.implode(' ', array_fill(0, count($attributes), REDACTED)));
})->with([
'postgres' => [StandalonePostgresql::class, ['postgres_password' => 'xy']],
'mysql' => [StandaloneMysql::class, ['mysql_password' => 'xy', 'mysql_root_password' => 'zw']],
'mariadb' => [StandaloneMariadb::class, ['mariadb_password' => 'xy', 'mariadb_root_password' => 'zw']],
'mongodb' => [StandaloneMongodb::class, ['mongo_initdb_root_password' => 'xy']],
'clickhouse' => [StandaloneClickhouse::class, ['clickhouse_admin_password' => 'xy']],
'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'xy']],
'dragonfly' => [StandaloneDragonfly::class, ['dragonfly_password' => 'xy']],
]);
+30
View File
@@ -0,0 +1,30 @@
<?php
use Illuminate\Log\LogManager;
use Monolog\Handler\TestHandler;
it('redacts secrets when Laravel creates a configured log channel', function (string $channel) {
config(['logging.redaction_enabled' => true, 'constants.coolify.self_hosted' => true]);
$configuration = config("logging.channels.{$channel}");
$configuration['driver'] = 'monolog';
$configuration['handler'] = TestHandler::class;
config([
"logging.channels.{$channel}" => $configuration,
'logging.channels.emergency.path' => '/dev/null',
]);
$logger = (new LogManager(app()))->channel($channel);
$handler = $logger->getLogger()->getHandlers()[0];
expect($handler)->toBeInstanceOf(TestHandler::class);
$logger->error('password=coolify-canary-channel-secret', [
'token' => 'coolify-canary-context-secret',
'user_email' => 'admin@example.com',
]);
$record = $handler->getRecords()[0];
expect($record->message)->toBe('password='.REDACTED);
expect($record->context['token'])->toBe(REDACTED);
expect($record->context['user_email'])->toBe($channel === 'audit' ? 'admin@example.com' : REDACTED);
})->with(['single', 'audit']);
@@ -0,0 +1,152 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\IntegrationToken;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
const DOPPLER_DOWNLOAD_URL = 'https://api.doppler.com/v3/configs/config/secrets/download*';
beforeEach(function () {
Http::preventStrayRequests();
$this->team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]);
$this->resourceAttributes = [
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
$this->serverId = $server->id;
});
function makeRemoteSecretResource(string $type): Application|Service|StandalonePostgresql
{
$attributes = test()->resourceAttributes;
return match ($type) {
'application' => Application::factory()->create($attributes),
'service' => Service::factory()->create([...$attributes, 'server_id' => test()->serverId]),
'database' => StandalonePostgresql::query()->create([...$attributes, 'name' => 'remote-secret-db', 'postgres_password' => 'stored-db-password']),
};
}
function addRemoteSecretVariable(Application|Service|StandalonePostgresql $resource, string $key, string $value, bool $locked = false, bool $preview = false): void
{
EnvironmentVariable::query()->create([
'key' => $key,
'value' => $value,
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->id,
'is_preview' => $preview,
'is_shown_once' => $locked,
]);
}
function linkDopplerSource(Application|Service|StandalonePostgresql $resource): void
{
$token = IntegrationToken::factory()->create([
'team_id' => test()->team->id,
'provider' => 'doppler',
'capabilities' => ['secrets'],
]);
$resource->secretManagerLink()->create(['integration_token_id' => $token->id]);
}
it('hides resolved remote secrets of applications and services as locked values', function (string $type, bool $locked) {
Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['API_TOKEN' => 'resolved-remote-api-token'])]);
$resource = makeRemoteSecretResource($type);
if ($type === 'application') {
$resource->settings->update(['redact_all_env_values_in_logs' => false]);
} else {
$resource->update(['redact_all_env_values_in_logs' => false]);
}
linkDopplerSource($resource);
addRemoteSecretVariable($resource, 'API_TOKEN', '{{vault.API_TOKEN}}', locked: $locked);
$secrets = EnvironmentVariable::logRedactionSecretsFor($resource->fresh());
expect($secrets['locked'])->toContain('resolved-remote-api-token')
->and(sanitizeLogsForExport('token resolved-remote-api-token', $secrets))->toBe('token '.REDACTED);
})->with(['application', 'service'])->with(['locked' => true, 'unlocked' => false]);
it('hides resolved remote secrets of application preview variables', function () {
Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['PREVIEW_TOKEN' => 'resolved-preview-token'])]);
$application = makeRemoteSecretResource('application');
linkDopplerSource($application);
addRemoteSecretVariable($application, 'PREVIEW_TOKEN', '{{vault.PREVIEW_TOKEN}}', preview: true);
expect(EnvironmentVariable::logRedactionSecretsFor($application->fresh(), includePreview: true)['locked'])
->toContain('resolved-preview-token');
});
it('fetches the secret source once per call', function () {
Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['FIRST' => 'first-remote-value', 'SECOND' => 'second-remote-value'])]);
$application = makeRemoteSecretResource('application');
linkDopplerSource($application);
addRemoteSecretVariable($application, 'FIRST', '{{vault.FIRST}}');
addRemoteSecretVariable($application, 'SECOND', '{{vault.SECOND}}', locked: true);
$secrets = EnvironmentVariable::logRedactionSecretsFor($application->fresh());
expect($secrets['locked'])->toContain('first-remote-value', 'second-remote-value');
Http::assertSentCount(1);
});
it('does not contact a secret source for resources without remote references', function (string $type) {
$resource = makeRemoteSecretResource($type);
linkDopplerSource($resource);
addRemoteSecretVariable($resource, 'PLAIN', 'plain-value', locked: true);
expect(EnvironmentVariable::logRedactionSecretsFor($resource->fresh())['locked'])->toContain('plain-value');
Http::assertNothingSent();
})->with(['application', 'service', 'database']);
it('keeps exporting logs when remote secrets cannot be resolved', function (string $type, string $failure) {
Log::spy();
$resource = makeRemoteSecretResource($type);
if ($failure === 'source fails') {
Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['message' => 'unavailable'], 500)]);
linkDopplerSource($resource);
} elseif ($failure === 'missing key') {
Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['PRESENT' => 'present-remote-value'])]);
linkDopplerSource($resource);
}
addRemoteSecretVariable($resource, 'LOCKED_PLAIN', 'locked-plain-value', locked: true);
addRemoteSecretVariable($resource, 'BROKEN', '{{vault.PRESENT}}-{{vault.MISSING}}');
addRemoteSecretVariable($resource, 'ALSO_BROKEN', '{{vault.MISSING}}');
$secrets = EnvironmentVariable::logRedactionSecretsFor($resource->fresh());
expect($secrets['locked'])->toContain('locked-plain-value', '{{vault.PRESENT}}-{{vault.MISSING}}', '{{vault.MISSING}}');
if ($failure === 'missing key') {
expect($secrets['locked'])->toContain('present-remote-value');
}
if ($type === 'database') {
expect($secrets['locked'])->toContain('stored-db-password');
}
Http::assertSentCount($failure === 'no source' ? 0 : 1);
Log::shouldHaveReceived('warning')->once()->withArgs(fn (string $message, array $context): bool => ! str_contains(json_encode($context), 'present-remote-value'));
})->with(['application', 'service', 'database'])->with(['source fails', 'missing key', 'no source']);
it('skips the secret source when the caller already holds the remote values', function () {
$application = makeRemoteSecretResource('application');
linkDopplerSource($application);
addRemoteSecretVariable($application, 'API_TOKEN', '{{vault.API_TOKEN}}', locked: true);
expect(EnvironmentVariable::logRedactionSecretsFor($application->fresh(), resolveRemoteSecrets: false)['locked'])
->toContain('{{vault.API_TOKEN}}');
Http::assertNothingSent();
});
@@ -0,0 +1,106 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
function makeLogRedactionQueryFixture(int $plainVariables, int $sharedVariables): Application
{
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
for ($i = 0; $i < $plainVariables; $i++) {
EnvironmentVariable::query()->create([
'key' => "PLAIN_{$i}",
'value' => "plain-value-number-{$i}",
'resourceable_type' => $application->getMorphClass(),
'resourceable_id' => $application->id,
'is_preview' => false,
]);
}
for ($i = 0; $i < $sharedVariables; $i++) {
SharedEnvironmentVariable::query()->create([
'key' => "SHARED_{$i}",
'value' => "shared-value-number-{$i}",
'type' => 'team',
'team_id' => $team->id,
'is_shown_once' => $i % 2 === 0,
]);
EnvironmentVariable::query()->create([
'key' => "REF_{$i}",
'value' => "{{team.SHARED_{$i}}}",
'resourceable_type' => $application->getMorphClass(),
'resourceable_id' => $application->id,
'is_preview' => false,
]);
}
return Application::query()->findOrFail($application->id);
}
function countLogRedactionQueries(Application $application): int
{
DB::flushQueryLog();
DB::enableQueryLog();
EnvironmentVariable::logRedactionSecretsFor($application, includePreview: true);
$count = count(DB::getQueryLog());
DB::disableQueryLog();
return $count;
}
it('collects log redaction secrets with a query count that does not grow with plain variables', function () {
$small = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 2, sharedVariables: 0));
$large = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 40, sharedVariables: 0));
expect($large)->toBe($small);
});
it('collects log redaction secrets with a query count that does not grow with shared references', function () {
$small = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 0, sharedVariables: 2));
$large = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 0, sharedVariables: 20));
expect($large)->toBe($small);
});
it('still classifies plain, shared, and locked shared values correctly', function () {
$application = makeLogRedactionQueryFixture(plainVariables: 1, sharedVariables: 2);
$secrets = EnvironmentVariable::logRedactionSecretsFor($application);
expect($secrets['known'])->toContain('plain-value-number-0', 'shared-value-number-1')
->and($secrets['locked'])->toContain('shared-value-number-0')
->and($secrets['locked'])->not->toContain('shared-value-number-1');
});
it('does not create a circular relation on the resource', function () {
$application = makeLogRedactionQueryFixture(plainVariables: 2, sharedVariables: 1);
EnvironmentVariable::logRedactionSecretsFor($application);
expect($application->environment_variables->every(fn (EnvironmentVariable $variable): bool => ! $variable->relationLoaded('resourceable')))->toBeTrue()
->and(json_encode($application->toArray()))->toBeString();
});
it('indexes application settings by application', function () {
expect(Schema::hasIndex('application_settings', ['application_id']))->toBeTrue();
});
@@ -0,0 +1,125 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
config()->set('app.maintenance.store', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$this->service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $this->server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
$this->database = StandalonePostgresql::query()->create([
'name' => 'log-redaction-database',
'postgres_password' => 'database-password',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
/**
* @param list<string> $abilities
*/
function logRedactionApiToken(Team $team, string $role, array $abilities = ['write']): string
{
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => $role]);
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function logRedactionSettingOf(string $resource): bool
{
return match ($resource) {
'application' => test()->application->settings->fresh()->redact_all_env_values_in_logs,
'service' => test()->service->fresh()->redact_all_env_values_in_logs,
'database' => test()->database->fresh()->redact_all_env_values_in_logs,
};
}
function logRedactionPath(string $resource): string
{
return match ($resource) {
'application' => '/api/v1/applications/'.test()->application->uuid,
'service' => '/api/v1/services/'.test()->service->uuid,
'database' => '/api/v1/databases/'.test()->database->uuid,
};
}
it('lets a team admin limit log redaction to locked values', function (string $resource) {
$this->withToken(logRedactionApiToken($this->team, 'admin'))
->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false])
->assertOk();
expect(logRedactionSettingOf($resource))->toBeFalse();
})->with(['application', 'service', 'database']);
it('rejects a non-boolean log redaction value', function (string $resource) {
$this->withToken(logRedactionApiToken($this->team, 'admin'))
->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => 'nope'])
->assertUnprocessable();
expect(logRedactionSettingOf($resource))->toBeTrue();
})->with(['application', 'service', 'database']);
it('forbids a team member from changing log redaction', function (string $resource) {
$this->withToken(logRedactionApiToken($this->team, 'member'))
->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false])
->assertForbidden();
expect(logRedactionSettingOf($resource))->toBeTrue();
})->with(['application', 'service', 'database']);
it('forbids a read-only token from changing log redaction', function (string $resource) {
$this->withToken(logRedactionApiToken($this->team, 'owner', ['read']))
->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false])
->assertForbidden();
expect(logRedactionSettingOf($resource))->toBeTrue();
})->with(['application', 'service', 'database']);
it('does not let an admin of another team change log redaction', function (string $resource) {
$this->withToken(logRedactionApiToken(Team::factory()->create(), 'owner'))
->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false])
->assertNotFound();
expect(logRedactionSettingOf($resource))->toBeTrue();
})->with(['application', 'service', 'database']);
+29
View File
@@ -0,0 +1,29 @@
<?php
use App\Logging\RedactSensitiveData;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Log\Logger;
use Monolog\Handler\TestHandler;
use Monolog\Logger as MonologLogger;
it('writes raw framework logs when a self-hosted instance turns redaction off', function () {
config(['logging.redaction_enabled' => false, 'constants.coolify.self_hosted' => true]);
$handler = new TestHandler;
$logger = new Logger(new MonologLogger('test', [$handler]));
(new RedactSensitiveData(new SensitiveDataRedactor))($logger);
$logger->error('password=coolify-canary-raw-secret');
expect($handler->getRecords()[0]->message)->toBe('password=coolify-canary-raw-secret');
});
it('always redacts framework logs on cloud', function () {
config(['logging.redaction_enabled' => false, 'constants.coolify.self_hosted' => false]);
$handler = new TestHandler;
$logger = new Logger(new MonologLogger('test', [$handler]));
(new RedactSensitiveData(new SensitiveDataRedactor))($logger);
$logger->error('password=coolify-canary-cloud-secret');
expect($handler->getRecords()[0]->message)->toBe('password='.REDACTED);
});
+157
View File
@@ -0,0 +1,157 @@
<?php
use App\Actions\Database\RestartDatabase;
use App\Actions\Service\RestartService;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
const MCP_LOG_CANARY = 'coolify-canary-mcp-env-value';
beforeEach(function () {
Server::flushIdentityMap();
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.store' => 'array',
'constants.ssh.mux_enabled' => false,
]);
$settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
$destinationAttributes = ['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()];
$this->application = Application::factory()->create(['environment_id' => $environment->id, 'status' => 'running:healthy', ...$destinationAttributes]);
$this->service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $this->server->id, ...$destinationAttributes]);
foreach ([$this->application, $this->service] as $resource) {
EnvironmentVariable::query()->create([
'key' => 'MCP_CANARY',
'value' => MCP_LOG_CANARY,
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->id,
'is_preview' => false,
]);
}
});
/**
* @param list<string> $abilities
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
function mcpLogRedactionCall(array $abilities, string $tool, array $arguments): array
{
auth()->forgetGuards();
$token = test()->user->createToken('mcp-log-redaction', $abilities)->plainTextToken;
$response = test()->withToken($token)->withHeaders(['Accept' => 'application/json, text/event-stream'])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => ['name' => $tool, 'arguments' => (object) $arguments],
])->assertOk();
return $response->json();
}
function fakeMcpApplicationContainer(Closure $logs): void
{
Process::fake(fn ($process) => match (true) {
str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => 'app', 'Labels' => '', 'State' => 'running'])),
str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])),
str_contains($process->command, 'docker logs') => $logs(),
default => Process::result(output: ''),
});
}
it('redacts the application environment values in get_logs output', function () {
fakeMcpApplicationContainer(fn () => Process::result(output: 'booted with '.MCP_LOG_CANARY));
$response = mcpLogRedactionCall(['read', 'read:sensitive'], 'get_logs', ['resource' => 'application', 'uuid' => $this->application->uuid]);
$data = json_decode($response['result']['content'][0]['text'], true)['data'];
expect($data['logs'])->toBe('booted with '.REDACTED)
->and(json_encode($response))->not->toContain(MCP_LOG_CANARY);
});
it('redacts the application environment values in get_logs error messages', function () {
fakeMcpApplicationContainer(fn () => Process::result(errorOutput: 'logs failed near '.MCP_LOG_CANARY, exitCode: 1));
$response = mcpLogRedactionCall(['read', 'read:sensitive'], 'get_logs', ['resource' => 'application', 'uuid' => $this->application->uuid]);
$data = json_decode($response['result']['content'][0]['text'], true)['data'];
expect($data['reason'])->toBe('log_fetch_failed')
->and($data['message'])->toBe('logs failed near '.REDACTED)
->and(json_encode($response))->not->toContain(MCP_LOG_CANARY);
});
it('redacts the service environment values in control error messages', function () {
RestartService::partialMock()->shouldReceive('handle')->andThrow(new RuntimeException('restart failed near '.MCP_LOG_CANARY));
$response = mcpLogRedactionCall(['read', 'deploy'], 'control', ['resource' => 'service', 'action' => 'restart', 'uuid' => $this->service->uuid]);
expect(json_encode($response))->toContain('restart failed near')
->not->toContain(MCP_LOG_CANARY);
});
it('redacts standalone database passwords in get_logs output and errors', function (bool $fails) {
$database = StandalonePostgresql::query()->create([
'name' => 'log-redaction-database',
'environment_id' => $this->application->environment_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
'postgres_password' => 'xy',
'status' => 'running:healthy',
]);
fakeMcpApplicationContainer(fn () => $fails
? Process::result(errorOutput: 'failed near xy', exitCode: 1)
: Process::result(output: 'booted xy'));
$response = mcpLogRedactionCall(['read', 'read:sensitive'], 'get_logs', ['resource' => 'database', 'uuid' => $database->uuid]);
$data = json_decode($response['result']['content'][0]['text'], true)['data'];
expect($data[$fails ? 'message' : 'logs'])->toBe(($fails ? 'failed near ' : 'booted ').REDACTED);
})->with([false, true]);
it('redacts standalone database passwords in control error messages', function () {
$database = StandalonePostgresql::query()->create([
'name' => 'control-redaction-database',
'environment_id' => $this->application->environment_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
'postgres_password' => 'control-db-password',
]);
RestartDatabase::partialMock()->shouldReceive('handle')->andThrow(new RuntimeException('restart failed near control-db-password'));
$response = mcpLogRedactionCall(['read', 'deploy'], 'control', ['resource' => 'database', 'action' => 'restart', 'uuid' => $database->uuid]);
expect(json_encode($response))->toContain('restart failed near')
->not->toContain('control-db-password');
});
+2 -2
View File
@@ -2516,8 +2516,8 @@ test('redactLogText redacts JSON secret fields in log lines', function () {
->and($redacted)->toContain('ok')
->and($redacted)->not->toContain('redactme01')
->and($redacted)->not->toContain('redactme02')
->and($redacted)->toContain('token=')
->and($redacted)->toContain('API_KEY=')
->and($redacted)->toContain('token')
->and($redacted)->toContain('API_KEY')
->and($redacted)->toContain(REDACTED);
// Shell-style still works alongside JSON
@@ -0,0 +1,233 @@
<?php
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Jobs\CoolifyTask;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use App\Services\DatabaseStartCommandExecutor;
use App\Services\DockerRegistryLogins;
use App\Support\ResourceStartActivity;
use Illuminate\Contracts\Process\InvokedProcess;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\Exceptions\ProcessTimedOutException;
use Illuminate\Process\FakeProcessResult;
use Illuminate\Process\ProcessResult;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('cache.default', 'array');
config()->set('constants.ssh.mux_enabled', false);
InstanceSettings::forceCreate(['id' => 0]);
Queue::fake();
$this->team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'user' => 'root',
]);
});
function remoteProcessRedactionService(Team $team, bool $redactAll): Service
{
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'redact_all_env_values_in_logs' => $redactAll,
]);
foreach (['VISIBLE_VALUE' => ['visible-service-value', false], 'LOCKED_VALUE' => ['locked-service-value', true]] as $key => [$value, $locked]) {
EnvironmentVariable::query()->create([
'key' => $key,
'value' => $value,
'resourceable_type' => $service->getMorphClass(),
'resourceable_id' => $service->id,
'is_preview' => false,
'is_shown_once' => $locked,
]);
}
return $service;
}
function remoteProcessTimeout(): ProcessTimedOutException
{
$process = new SymfonyProcess(['ssh', 'root@host', 'echo SECRET_COMMAND_VALUE']);
$process->setTimeout(5);
return new ProcessTimedOutException(new SymfonyProcessTimedOutException($process, SymfonyProcessTimedOutException::TYPE_GENERAL), new ProcessResult($process));
}
it('hides service environment values in remote process output', function (bool $redactAll, string $expected) {
$service = remoteProcessRedactionService($this->team, $redactAll);
Process::fake(fn () => Process::result(output: 'visible-service-value locked-service-value'));
$activity = remote_process(['docker compose up -d'], $this->server, type_uuid: $service->uuid);
(new RunRemoteProcess(Activity::query()->findOrFail($activity->id)))();
$stored = Activity::query()->findOrFail($activity->id);
expect(RunRemoteProcess::decodeOutput($stored))->toBe($expected)
->and($stored->getExtraProperty('stdout'))->toBe($expected);
})->with([
'redact all values' => [true, REDACTED.' '.REDACTED."\n"],
'redact locked values only' => [false, 'visible-service-value '.REDACTED."\n"],
]);
it('does not put the command of a timed out remote process in the error', function () {
Process::fake(fn () => throw remoteProcessTimeout());
$activity = remote_process(['echo SECRET_COMMAND_VALUE'], $this->server);
$run = new RunRemoteProcess(Activity::query()->findOrFail($activity->id));
expect(fn () => $run())->toThrow(fn (RuntimeException $exception) => expect($exception->getMessage())
->not->toContain('SECRET_COMMAND_VALUE')
->and($exception->getCode())->toBe(124)
->and($exception->getPrevious())->toBeNull());
});
it('does not put the command of a timed out registry login in the error', function () {
Process::fake(fn () => throw remoteProcessTimeout());
expect(fn () => DockerRegistryLogins::login($this->server, 'ghcr.io', 'user', 'SECRET_COMMAND_VALUE'))
->toThrow(fn (RuntimeException $exception) => expect($exception->getMessage())
->not->toContain('SECRET_COMMAND_VALUE')
->and($exception->getCode())->toBe(124));
});
it('stores a redacted error when a coolify task fails permanently', function () {
$activity = remote_process(['echo ok'], $this->server);
$task = new CoolifyTask(Activity::query()->findOrFail($activity->id), ignore_errors: false, call_event_on_finish: null, call_event_data: null);
$task->failed(new RuntimeException('failed: password=task-secret-value'));
expect(Activity::query()->findOrFail($activity->id)->getExtraProperty('error'))
->not->toContain('task-secret-value')
->toContain(REDACTED);
});
it('stores a redacted message when an activity is marked as failed', function () {
$activity = remote_process(['echo ok'], $this->server);
ResourceStartActivity::markFailed($activity, 'failed: password=mark-failed-secret');
$stored = Activity::query()->findOrFail($activity->id);
expect($stored->getExtraProperty('error'))->not->toContain('mark-failed-secret')->toContain(REDACTED)
->and($stored->description)->not->toContain('mark-failed-secret');
});
it('redacts a database start secret that is split across output chunks', function () {
$database = new class extends Model
{
public function resolvedSecretManagerValuesForRedaction(): array
{
return ['split-secret-value'];
}
};
$database->setRelation('destination', (object) ['server' => $this->server]);
$activity = remote_process(['echo ok'], $this->server);
// The process fake ends every output chunk with a newline, so the chunks are sent by hand.
$invoked = Mockery::mock(InvokedProcess::class);
Process::shouldReceive('timeout->idleTimeout->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked) {
$invoked->shouldReceive('wait')->andReturnUsing(function () use ($output) {
foreach (['value is split-sec', "ret-value\nnext ", 'line split-secret-value'] as $chunk) {
$output('out', $chunk);
}
return new FakeProcessResult;
});
return $invoked;
});
app(DatabaseStartCommandExecutor::class)->execute(['echo ok'], $database, $activity);
expect(RunRemoteProcess::decodeOutput(Activity::query()->findOrFail($activity->id)))
->toBe('value is '.REDACTED."\nnext line ".REDACTED);
});
/**
* Runs a remote process whose output arrives in the given chunks, like a real SSH stream.
*
* @param list<array{0: string, 1: string}> $chunks
*/
function runRemoteProcessWithChunks(Activity $activity, array $chunks, ?Throwable $failure = null): void
{
// The process fake ends every output chunk with a newline, so the chunks are sent by hand.
$invoked = Mockery::mock(InvokedProcess::class);
$invoked->shouldReceive('id')->andReturn(123);
Process::shouldReceive('timeout->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked, $chunks, $failure) {
$invoked->shouldReceive('wait')->andReturnUsing(function () use ($output, $chunks, $failure) {
foreach ($chunks as [$type, $chunk]) {
$output($type, $chunk);
}
if ($failure !== null) {
throw $failure;
}
return new FakeProcessResult;
});
return $invoked;
});
(new RunRemoteProcess(Activity::query()->findOrFail($activity->id)))();
}
it('redacts a remote process secret that is split across output chunks', function () {
$service = remoteProcessRedactionService($this->team, redactAll: false);
$activity = remote_process(['docker compose up -d'], $this->server, type_uuid: $service->uuid);
runRemoteProcessWithChunks($activity, [
['out', 'value is locked-ser'],
['err', 'error locked-se'],
['out', "vice-value\nnext "],
['err', "rvice-value\n"],
['out', 'line locked-service-value'],
]);
$entries = collect(json_decode(Activity::query()->findOrFail($activity->id)->description, true));
expect($entries->where('type', 'out')->pluck('output')->implode(''))->toBe('value is '.REDACTED."\nnext line ".REDACTED)
->and($entries->where('type', 'err')->pluck('output')->implode(''))->toBe('error '.REDACTED."\n")
->and($entries->pluck('output')->implode(''))->not->toContain('locked-');
});
it('redacts a private key that is split across remote process output chunks', function () {
$activity = remote_process(['cat key'], $this->server);
runRemoteProcessWithChunks($activity, [
['out', "before\n-----BEGIN OPENSSH PRIVATE KEY-----\nKEYBODYLINEONE\n"],
['out', "KEYBODYLINETWO\n-----END OPENSSH PRIVATE KEY-----\nafter\n"],
]);
expect(RunRemoteProcess::decodeOutput(Activity::query()->findOrFail($activity->id)))
->toBe("before\n".REDACTED."\nafter\n");
});
it('hides an unfinished private key and keeps the last partial line of a timed out remote process', function () {
$service = remoteProcessRedactionService($this->team, redactAll: false);
$activity = remote_process(['cat key'], $this->server, type_uuid: $service->uuid);
expect(fn () => runRemoteProcessWithChunks($activity, [
['out', "-----BEGIN PRIVATE KEY-----\nKEYBODYLINEONE\n"],
['err', 'tail locked-service-value'],
], remoteProcessTimeout()))->toThrow(RuntimeException::class);
$output = RunRemoteProcess::decodeOutput(Activity::query()->findOrFail($activity->id));
expect($output)->not->toContain('KEYBODYLINEONE')
->not->toContain('locked-service-value')
->toContain('tail '.REDACTED);
});
+421
View File
@@ -0,0 +1,421 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use App\Traits\ExecuteRemoteCommand;
use Illuminate\Contracts\Process\InvokedProcess;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\FakeProcessResult;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function makeSafeDeploymentLogFixture(): array
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
$deployment = ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => fake()->uuid(),
]);
return [$application, $deployment];
}
function createSafeDeploymentLogVariable(Application $application, string $key, string $value, bool $locked): void
{
EnvironmentVariable::query()->create([
'key' => $key,
'value' => $value,
'resourceable_type' => $application->getMorphClass(),
'resourceable_id' => $application->id,
'is_preview' => false,
'is_shown_once' => $locked,
]);
}
it('redacts every application environment value before storing deployment logs', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
$secret = 'coolify-canary-normal-environment-secret';
EnvironmentVariable::query()->create([
'key' => 'NORMAL_ENVIRONMENT_VALUE',
'value' => $secret,
'resourceable_type' => $application->getMorphClass(),
'resourceable_id' => $application->id,
'is_preview' => false,
'is_shown_once' => false,
]);
$deployment = ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => fake()->uuid(),
]);
$deployment->addLogEntry(
message: "process returned {$secret}",
command: "deploy --credential={$secret}",
);
$storedLogs = $deployment->refresh()->getRawOriginal('logs');
expect($storedLogs)
->not->toContain($secret)
->toContain(REDACTED);
});
it('redacts only locked environment values when the application allows it', function () {
[$application, $deployment] = makeSafeDeploymentLogFixture();
$application->settings->update(['redact_all_env_values_in_logs' => false]);
createSafeDeploymentLogVariable($application, 'APP_ENV_NAME', 'visible-production-value', locked: false);
createSafeDeploymentLogVariable($application, 'LOCKED_VALUE', 'coolify-canary-locked-value', locked: true);
$deployment->addLogEntry('env visible-production-value coolify-canary-locked-value');
$output = json_decode($deployment->refresh()->logs, true)[0]['output'];
expect($output)->toBe('env visible-production-value '.REDACTED);
});
it('adds one hint to the deployment log when values that are not locked are hidden', function () {
[$application, $deployment] = makeSafeDeploymentLogFixture();
createSafeDeploymentLogVariable($application, 'APP_ENV_NAME', 'hidden-production-value', locked: false);
$deployment->addLogEntry('env hidden-production-value');
$deployment->addLogEntry('again hidden-production-value');
ApplicationDeploymentQueue::query()->findOrFail($deployment->id)->addLogEntry('other worker hidden-production-value');
$outputs = collect(json_decode($deployment->refresh()->logs, true))->pluck('output');
expect($outputs->all())->toBe([
'env '.REDACTED,
ApplicationDeploymentQueue::HIDDEN_ENV_VALUES_HINT,
'again '.REDACTED,
'other worker '.REDACTED,
]);
});
it('adds no hidden values hint when only locked values are hidden', function (bool $redactAll) {
[$application, $deployment] = makeSafeDeploymentLogFixture();
$application->settings->update(['redact_all_env_values_in_logs' => $redactAll]);
createSafeDeploymentLogVariable($application, 'APP_ENV_NAME', 'plain-production-value', locked: false);
createSafeDeploymentLogVariable($application, 'LOCKED_VALUE', 'coolify-canary-locked-value', locked: true);
$deployment->addLogEntry('env coolify-canary-locked-value');
$deployment->addLogEntry($redactAll ? 'no env value here' : 'env plain-production-value');
expect(collect(json_decode($deployment->refresh()->logs, true))->pluck('output'))
->not->toContain(ApplicationDeploymentQueue::HIDDEN_ENV_VALUES_HINT);
})->with(['redact all on' => true, 'redact all off' => false]);
it('keeps redacting all values for other applications in the same team', function () {
[$application, $deployment] = makeSafeDeploymentLogFixture();
createSafeDeploymentLogVariable($application, 'APP_ENV_NAME', 'hidden-production-value', locked: false);
$otherApplication = Application::factory()->create(['environment_id' => $application->environment_id]);
$otherApplication->settings->update(['redact_all_env_values_in_logs' => false]);
$deployment->addLogEntry('env hidden-production-value');
expect($application->settings->fresh()->redact_all_env_values_in_logs)->toBeTrue()
->and(json_decode($deployment->refresh()->logs, true)[0]['output'])->toBe('env '.REDACTED);
});
it('redacts remote secrets registered on the deployment', function () {
[, $deployment] = makeSafeDeploymentLogFixture();
$deployment->redactRemoteSecrets(['API_TOKEN' => 'coolify-canary-remote-secret', 'PIN' => '4821']);
$deployment->addLogEntry(
message: 'output coolify-canary-remote-secret pin 4821',
command: 'echo coolify-canary-remote-secret',
);
expect($deployment->refresh()->getRawOriginal('logs'))
->not->toContain('coolify-canary-remote-secret')
->not->toContain('4821')
->toContain(REDACTED);
});
it('always redacts short locked values and values of locked shared variables', function () {
[$application, $deployment] = makeSafeDeploymentLogFixture();
$application->settings->update(['redact_all_env_values_in_logs' => false]);
createSafeDeploymentLogVariable($application, 'PIN', '4821', locked: true);
SharedEnvironmentVariable::query()->create([
'key' => 'SHARED_DB_PASS',
'value' => 'coolify-canary-shared-locked',
'type' => 'team',
'team_id' => $application->team()->id,
'is_shown_once' => true,
]);
createSafeDeploymentLogVariable($application, 'DB_PASS', '{{team.SHARED_DB_PASS}}', locked: false);
$deployment->addLogEntry('pin 4821 password coolify-canary-shared-locked');
expect(json_decode($deployment->refresh()->logs, true)[0]['output'])
->toBe('pin '.REDACTED.' password '.REDACTED);
});
it('passes remote secrets from the deployment job into stored log entries', function () {
[$application, $deployment] = makeSafeDeploymentLogFixture();
$job = new class
{
use ExecuteRemoteCommand;
public $application;
public $application_deployment_queue;
public ?array $remote_secrets_cache = ['API_TOKEN' => 'coolify-canary-vault-secret'];
};
$job->application = $application;
$job->application_deployment_queue = $deployment;
(new ReflectionMethod($job, 'addRetryLogEntry'))->invoke($job, 1, 3, 1, 'failed near coolify-canary-vault-secret');
expect($deployment->refresh()->getRawOriginal('logs'))
->not->toContain('coolify-canary-vault-secret')
->toContain(REDACTED);
});
it('caches normalized remote secrets only after they are loaded', function () {
$job = new class
{
use ExecuteRemoteCommand;
public ?array $remote_secrets_cache = null;
};
$redact = new ReflectionMethod($job, 'redact_sensitive_info');
expect($redact->invoke($job, 'ordinary output'))->toBe('ordinary output');
$job->remote_secrets_cache = ['PIN' => '4821'];
expect($redact->invoke($job, 'pin 4821'))->toBe('pin '.REDACTED);
$cache = new ReflectionProperty($job, 'remoteSecretLogRedactionValues');
$normalized = $cache->getValue($job);
expect($normalized)->toContain('4821');
expect($redact->invoke($job, 'another pin 4821'))->toBe('another pin '.REDACTED)
->and($cache->getValue($job))->toBe($normalized);
$otherJob = new ($job::class);
$otherJob->remote_secrets_cache = ['PIN' => '7392'];
expect($redact->invoke($otherJob, 'pin 7392'))->toBe('pin '.REDACTED);
});
it('keeps corrupted stored logs and warns once instead of overwriting them', function () {
[, $deployment] = makeSafeDeploymentLogFixture();
ApplicationDeploymentQueue::query()->whereKey($deployment->id)->update(['logs' => '{not json']);
Log::shouldReceive('warning')->once()->withArgs(fn (string $message, array $context) => $context === ['deployment_uuid' => $deployment->deployment_uuid]);
$deployment->addLogEntry('fresh entry');
$deployment->addLogEntry('second entry');
expect($deployment->refresh()->getRawOriginal('logs'))->toBe('{not json');
});
/**
* A deployment job whose remote command sends its output in the given chunks, like a real SSH stream.
*
* @param list<array{0: string, 1: string}> $chunks
*/
function runDeploymentCommandWithChunks(ApplicationDeploymentQueue $deployment, array $chunks, bool $hidden = false): void
{
$team = $deployment->application->team();
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$job = new class
{
use ExecuteRemoteCommand;
public $application;
public $application_deployment_queue;
public $server;
public $saved_outputs;
};
$job->application = $deployment->application;
$job->application_deployment_queue = $deployment;
$job->server = $server;
$job->saved_outputs = collect();
// The process fake ends every output chunk with a newline, so the chunks are sent by hand.
$invoked = Mockery::mock(InvokedProcess::class);
$invoked->shouldReceive('id')->andReturn(123);
Process::shouldReceive('timeout->idleTimeout->input->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked, $chunks) {
$invoked->shouldReceive('wait')->andReturnUsing(function () use ($output, $chunks) {
foreach ($chunks as [$type, $chunk]) {
$output($type, $chunk);
}
return new FakeProcessResult;
});
return $invoked;
});
(new ReflectionMethod($job, 'executeCommandWithProcess'))->invoke($job, 'cat output', $hidden, null, true, false);
}
it('redacts a deployment output secret that is split across output chunks', function () {
[$application, $deployment] = makeSafeDeploymentLogFixture();
$application->settings->update(['redact_all_env_values_in_logs' => false]);
createSafeDeploymentLogVariable($application, 'LOCKED_VALUE', 'coolify-canary-locked-value', locked: true);
runDeploymentCommandWithChunks($deployment, [
['out', 'value is coolify-canary-lo'],
['err', 'error coolify-canary-'],
['out', "cked-value\nnext "],
['err', "locked-value\n"],
['out', 'line coolify-canary-locked-value'],
], hidden: true);
$logs = collect(json_decode($deployment->refresh()->logs, true));
expect($logs->map(fn (array $entry) => [$entry['type'], $entry['output']])->all())->toBe([
['stdout', 'value is '.REDACTED],
['stderr', 'error '.REDACTED],
['stdout', 'next line '.REDACTED],
])
->and($logs->pluck('hidden')->unique()->all())->toBe([true])
->and($logs->pluck('command')->unique()->all())->toBe(['cat output']);
});
it('redacts a private key that is split across deployment output chunks', function () {
[, $deployment] = makeSafeDeploymentLogFixture();
runDeploymentCommandWithChunks($deployment, [
['out', "before\n-----BEGIN RSA PRIVATE KEY-----\nKEYBODYLINEONE\n"],
['out', "KEYBODYLINETWO\n-----END RSA PRIVATE KEY-----\nafter\n"],
]);
$outputs = collect(json_decode($deployment->refresh()->logs, true))->pluck('output');
expect($outputs->implode("\n"))->not->toContain('KEYBODYLINE')
->and($outputs->all())->toBe(['before', REDACTED."\nafter"]);
});
/**
* A team with an API token, a functional server, and running containers whose logs print an environment value
* and a pattern secret. Returns the endpoint path for the requested resource.
*/
function makeRestLogRedactionEndpoint(string $resource, string $envSecret): array
{
Server::flushIdentityMap();
config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'logs',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['root'],
'team_id' => $team->id,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$destinationAttributes = ['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()];
if ($resource === 'database') {
$owner = null;
$database = StandalonePostgresql::create([
'name' => 'logs-db',
'postgres_user' => 'postgres',
'postgres_password' => 'postgres',
'postgres_db' => 'app',
'image' => 'postgres:16-alpine',
'environment_id' => $environment->id,
...$destinationAttributes,
]);
$path = "/api/v1/databases/{$database->uuid}/logs";
$containerName = $database->uuid;
} elseif ($resource === 'application') {
$owner = Application::factory()->create(['environment_id' => $environment->id, ...$destinationAttributes]);
$path = "/api/v1/applications/{$owner->uuid}/logs";
$containerName = $owner->uuid;
} else {
$owner = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $server->id, ...$destinationAttributes]);
$child = $resource === 'service database'
? ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'service_id' => $owner->id])
: ServiceApplication::create(['uuid' => (string) Str::uuid(), 'name' => 'web', 'service_id' => $owner->id]);
$containerName = "{$child->name}-{$owner->uuid}";
$path = match ($resource) {
'service' => "/api/v1/services/{$owner->uuid}/logs?sub_service_name=web",
'service application' => "/api/v1/services/{$owner->uuid}/applications/{$child->uuid}/logs",
'service database' => "/api/v1/services/{$owner->uuid}/databases/{$child->uuid}/logs",
};
}
if ($owner !== null) {
EnvironmentVariable::query()->create([
'key' => 'LOG_CANARY',
'value' => $envSecret,
'resourceable_type' => $owner->getMorphClass(),
'resourceable_id' => $owner->id,
'is_preview' => false,
]);
}
Process::fake(function ($process) use ($containerName, $envSecret) {
return match (true) {
str_contains($process->command, 'docker ps') => Process::result(output: json_encode([
'ID' => 'abc123',
'Names' => $containerName,
'Labels' => "coolify.name={$containerName}",
'State' => 'running',
])),
str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])),
str_contains($process->command, 'docker logs') => Process::result(output: "booted with {$envSecret}\npassword=coolify-canary-pattern-secret"),
default => Process::result(output: ''),
};
});
return [$path, $token->getKey().'|'.$plainTextToken];
}
it('redacts environment values and secrets in each REST container log endpoint', function (string $resource) {
$envSecret = 'coolify-canary-env-value-in-logs';
[$path, $bearerToken] = makeRestLogRedactionEndpoint($resource, $envSecret);
$logs = $this->withToken($bearerToken)->getJson($path)->assertOk()->json('logs');
expect($logs)
->not->toContain('coolify-canary-pattern-secret')
->toContain('password='.REDACTED);
if ($resource !== 'database') {
expect($logs)->toContain('booted with '.REDACTED)->not->toContain($envSecret);
}
})->with(['application', 'database', 'service', 'service application', 'service database']);
@@ -0,0 +1,236 @@
<?php
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Enums\ActivityTypes;
use App\Events\ScheduledTaskDone;
use App\Exceptions\NonReportableException;
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
it('redacts remote process output before encoding it for activity storage', function () {
$activity = new Activity;
$activity->description = '[]';
$activity->properties = collect([
'type' => ActivityTypes::COMMAND->value,
]);
$process = new RunRemoteProcess($activity);
$encoded = $process->encodeOutput('stderr', 'password=coolify-canary-secret-remote');
expect($encoded)
->not->toContain('coolify-canary-secret-remote')
->toContain(REDACTED);
});
it('redacts scheduled task output before it reaches persistent sinks', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
EnvironmentVariable::query()->create([
'key' => 'TASK_VALUE',
'value' => 'coolify-canary-secret-scheduled',
'resourceable_type' => $application->getMorphClass(),
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$job = new ScheduledTaskJob(new ScheduledTask);
$job->resource = $application;
$method = new ReflectionMethod($job, 'redact');
$redacted = $method->invoke($job, 'unstructured coolify-canary-secret-scheduled output');
expect($redacted)
->not->toContain('coolify-canary-secret-scheduled')
->toContain(REDACTED);
});
it('redacts only locked values in scheduled task output when the service allows it', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'redact_all_env_values_in_logs' => false,
]);
foreach (['VISIBLE_VALUE' => ['visible-service-value', false], 'LOCKED_VALUE' => ['coolify-canary-locked-service', true]] as $key => [$value, $locked]) {
EnvironmentVariable::query()->create([
'key' => $key,
'value' => $value,
'resourceable_type' => $service->getMorphClass(),
'resourceable_id' => $service->id,
'is_preview' => false,
'is_shown_once' => $locked,
]);
}
$job = new ScheduledTaskJob(new ScheduledTask);
$job->resource = $service;
$redacted = (new ReflectionMethod($job, 'redact'))->invoke($job, 'visible-service-value coolify-canary-locked-service');
expect($redacted)->toBe('visible-service-value '.REDACTED);
});
it('keeps scheduled task exceptions whose messages need no redaction', function () {
$job = new ScheduledTaskJob(new ScheduledTask);
$exception = new NonReportableException('No valid container was found.');
$result = (new ReflectionMethod($job, 'redactedException'))->invoke($job, $exception, $exception->getMessage());
expect($result)->toBe($exception);
});
it('rebuilds redacted scheduled task exceptions with the same reportability', function () {
$job = new ScheduledTaskJob(new ScheduledTask);
$method = new ReflectionMethod($job, 'redactedException');
$secret = 'password=coolify-canary-secret-exception';
$nonReportable = $method->invoke($job, new NonReportableException($secret, 3), 'password='.REDACTED);
$reportable = $method->invoke($job, new RuntimeException($secret, 4), 'password='.REDACTED);
expect($nonReportable)->toBeInstanceOf(NonReportableException::class)
->and($nonReportable->getMessage())->toBe('password='.REDACTED)
->and($nonReportable->getCode())->toBe(3)
->and($nonReportable->getPrevious())->toBeNull()
->and($reportable)->toBeInstanceOf(RuntimeException::class)
->and($reportable->getPrevious())->toBeNull();
});
/**
* A running service container with an environment value that must not reach any scheduled task sink.
*
* @return array{0: ScheduledTask, 1: Team}
*/
function makeScheduledTaskRedactionFixture(string $secret): array
{
Server::flushIdentityMap();
Notification::fake();
Event::fake([ScheduledTaskDone::class]);
InstanceSettings::forceCreate(['id' => 0]);
config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
$team = Team::factory()->create();
$team->emailNotificationSettings->update([
'smtp_enabled' => true,
'scheduled_task_success_email_notifications' => true,
'scheduled_task_failure_email_notifications' => true,
]);
$server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
ServiceApplication::create(['name' => 'app', 'service_id' => $service->id, 'status' => 'running:healthy']);
EnvironmentVariable::query()->create([
'key' => 'TASK_SECRET',
'value' => $secret,
'resourceable_type' => $service->getMorphClass(),
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$task = ScheduledTask::factory()->create([
'team_id' => $team->id,
'service_id' => $service->id,
'command' => 'print-secret',
]);
return [$task, $team];
}
it('redacts scheduled task output in the execution log and success notification', function () {
$secret = 'coolify-canary-task-success-output';
[$task, $team] = makeScheduledTaskRedactionFixture($secret);
Process::fake(fn () => Process::result(output: "value {$secret}"));
(new ScheduledTaskJob($task))->handle();
expect($task->executions()->sole())
->status->toBe('success')
->message->toBe('value '.REDACTED);
Notification::assertSentTo($team, TaskSuccess::class, fn (TaskSuccess $notification): bool => $notification->output === 'value '.REDACTED);
});
it('redacts scheduled task failures in the execution log, error log, and rethrown exception', function () {
$secret = 'coolify-canary-task-failure-output';
[$task] = makeScheduledTaskRedactionFixture($secret);
Process::fake(fn () => Process::result(errorOutput: "failed near {$secret}", exitCode: 1));
$errorChannel = Mockery::mock();
$errorChannel->shouldReceive('error')->once()->withArgs(fn (string $message, array $context): bool => ! str_contains(serialize($context), $secret));
Log::shouldReceive('channel')->with('scheduled-errors')->andReturn($errorChannel);
try {
(new ScheduledTaskJob($task))->handle();
$thrown = null;
} catch (Throwable $exception) {
$thrown = $exception;
}
expect($thrown)->not->toBeNull()
->and($thrown->getMessage())->toBe('failed near '.REDACTED)
->and($thrown->getPrevious())->toBeNull()
->and($task->executions()->sole())
->status->toBe('failed')
->message->toBe('failed near '.REDACTED);
});
it('redacts permanent scheduled task failures in the execution, error log, and failure notification', function () {
$secret = 'coolify-canary-task-permanent-failure';
[$task, $team] = makeScheduledTaskRedactionFixture($secret);
$job = new ScheduledTaskJob($task);
ScheduledTaskExecution::forceCreate(['uuid' => $job->executionUuid, 'scheduled_task_id' => $task->id, 'started_at' => now()]);
$errorChannel = Mockery::mock();
$errorChannel->shouldReceive('error')->once()->withArgs(fn (string $message, array $context): bool => ! str_contains(serialize($context), $secret));
Log::shouldReceive('channel')->with('scheduled-errors')->andReturn($errorChannel);
$job->failed(new RuntimeException("password leaked {$secret}"));
$execution = ScheduledTaskExecution::query()->where('uuid', $job->executionUuid)->sole();
expect($execution->status)->toBe('failed')
->and($execution->message)->toContain('password leaked '.REDACTED)
->and($execution->message.$execution->error_details)->not->toContain($secret);
Notification::assertSentTo($team, TaskFailed::class, fn (TaskFailed $notification): bool => $notification->output === 'password leaked '.REDACTED);
});
it('records scheduled tasks that print literal null as successful with empty output', function () {
[$task, $team] = makeScheduledTaskRedactionFixture('coolify-canary-null-output');
Process::fake(fn () => Process::result(output: "null\n"));
(new ScheduledTaskJob($task))->handle();
expect($task->executions()->sole())
->status->toBe('success')
->message->toBe('');
Notification::assertSentTo($team, TaskSuccess::class, fn (TaskSuccess $notification): bool => $notification->output === '');
});
@@ -234,7 +234,7 @@ test('a service start stops before the service when a non-literal variable refer
->toThrow(RemoteSecretException::class, 'no secret manager source is configured');
});
test('a service restart fetches remote secrets only once', function () {
test('a service restart fetches remote secrets once to start and once to redact the start output', function () {
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => 'value'])]);
Process::fake(['*' => Process::result(output: '')]);
StopService::shouldRun();
@@ -243,7 +243,8 @@ test('a service restart fetches remote secrets only once', function () {
StartService::run($service, stopBeforeStart: true);
Http::assertSentCount(1);
// The start output is redacted in the activity job, which loads the service again.
Http::assertSentCount(2);
});
test('a database restart keeps the database running and reports the secret manager error', function () {
@@ -17,7 +17,8 @@ use Illuminate\Queue\TimeoutExceededException;
* - The exception code (if present)
* - The file and line where the error occurred
* - Previous exception details (if chained)
* - Stack trace (first 5 lines)
*
* Stack traces are not written, because their truncated arguments can hold secrets.
*/
it('logs comprehensive error details when failed() is called', function () {
// Create a mock exception with all properties
@@ -111,7 +112,7 @@ it('logs comprehensive error details when failed() is called', function () {
expect($errorMessageString)->toContain('Location:');
expect($errorMessageString)->toContain('Caused by:');
expect($errorMessageString)->toContain('RuntimeException: Connection refused');
expect($errorMessageString)->toContain('Stack trace');
expect($errorMessageString)->not->toContain('Stack trace');
// Verify stderr type is used for error logging
$stderrEntries = array_filter($logEntries, fn ($entry) => $entry['type'] === 'stderr');
@@ -131,9 +132,6 @@ it('logs comprehensive error details when failed() is called', function () {
$locationEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Location:'));
expect($locationEntry['hidden'])->toBeTrue();
$stackTraceEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Stack trace'));
expect($stackTraceEntry['hidden'])->toBeTrue();
$causedByEntry = collect($logEntries)->first(fn ($entry) => str_contains($entry['message'], 'Caused by:'));
expect($causedByEntry['hidden'])->toBeTrue();
});
@@ -456,7 +456,7 @@ it('creates an empty build-time env file for railpack when there are no generate
foreach ([
'application_deployment_queue' => new class extends ApplicationDeploymentQueue
{
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false): void {}
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false, ?string $command = null, int $batch = 1): void {}
},
'build_pack' => 'railpack',
'pull_request_id' => 0,
+52
View File
@@ -1,10 +1,20 @@
<?php
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Traits\ExecuteRemoteCommand;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, LazilyRefreshDatabase::class);
afterEach(function () {
Mockery::close();
@@ -66,6 +76,48 @@ it('skips logging the docker deploy key materialization command before ls-remote
->and(substr($source, $commandPosition, 200))->toContain("'skip_command_log' => true");
});
it('supports skipping command log entries without adding a hidden command entry', function () {
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
config(['constants.ssh.mux_enabled' => false]);
InstanceSettings::forceCreate(['id' => 0]);
$privateKey = PrivateKey::factory()->create();
$server = Server::factory()->create([
'team_id' => $privateKey->team_id,
'private_key_id' => $privateKey->id,
]);
$application = Application::factory()->create();
$deployment = ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => fake()->uuid(),
]);
$payload = base64_encode('private-deploy-key-material');
$command = "echo '{$payload}' | base64 -d | tee /root/.ssh/id_rsa_coolify_test";
Process::fake(['*' => Process::result(output: "materialized {$payload}")]);
$job = new class($server, $application, $deployment)
{
use ExecuteRemoteCommand;
public function __construct(
public Server $server,
public Application $application,
public ApplicationDeploymentQueue $application_deployment_queue,
) {}
};
(new ReflectionMethod($job, 'executeCommandWithProcess'))->invoke(
$job, $command, false, null, true, false, true, true,
);
$storedLogs = $deployment->refresh()->getRawOriginal('logs');
$logs = json_decode($storedLogs, true, flags: JSON_THROW_ON_ERROR);
expect($logs)->toHaveCount(1)
->and($logs[0]['command'])->toBeNull()
->and($logs[0]['output'])->toBe('materialized '.REDACTED);
expect($storedLogs)->not->toContain($payload)->not->toContain($command);
Process::assertRan(fn ($process) => str_contains($process->command, $command));
});
it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) {
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
@@ -0,0 +1,85 @@
<?php
use App\Logging\RedactSensitiveAuditData;
use App\Logging\RedactSensitiveData;
use App\Services\Security\SensitiveDataRedactor;
use Illuminate\Log\Logger;
use Monolog\Handler\TestHandler;
use Monolog\Logger as MonologLogger;
it('redacts messages, nested context, and exceptions before a handler receives the record', function () {
$secret = 'coolify-canary-secret-a91f0c';
$handler = new TestHandler;
$logger = new Logger(new MonologLogger('test', [$handler]));
(new RedactSensitiveData(new SensitiveDataRedactor))($logger);
$logger->error(
"Deployment failed with password={$secret}",
[
'headers' => ['Authorization' => "Bearer {$secret}"],
'exception' => new RuntimeException("token={$secret}"),
],
);
$record = $handler->getRecords()[0];
$serializedRecord = serialize([$record->message, $record->context, $record->extra]);
expect($serializedRecord)
->not->toContain($secret)
->toContain(REDACTED)
->and($record->context['exception']['trace'])->toBeString()->not->toBeEmpty();
});
it('keeps redacted traces and previous exceptions', function () {
$secret = 'coolify-canary-secret-previous';
$handler = new TestHandler;
$logger = new Logger(new MonologLogger('test', [$handler]));
(new RedactSensitiveData(new SensitiveDataRedactor))($logger);
$logger->error('Failed', [
'exception' => new RuntimeException('outer', 0, new RuntimeException("password={$secret}")),
]);
$exception = $handler->getRecords()[0]->context['exception'];
expect($exception['trace'])->toContain('#0')
->and($exception['previous']['message'])->toBe('password='.REDACTED)
->and(serialize($exception))->not->toContain($secret);
});
it('wires redaction into all persistent and remote log channels', function () {
$configuration = file_get_contents(__DIR__.'/../../config/logging.php');
expect($configuration)
->toContain("'channels' => ['single']")
->toContain("env('LOG_LEVEL', 'debug')")
->not->toContain("env('LOG_LEVEL', 'info')")
->and(substr_count($configuration, 'RedactSensitiveData::class'))->toBe(9)
->and($configuration)->toMatch("/'audit' => \\[[^\\]]*'tap' => \\[RedactSensitiveAuditData::class\\]/s");
});
it('keeps email addresses in the audit log while still redacting secrets', function () {
$secret = 'coolify-canary-secret-audit';
$handler = new TestHandler;
$logger = new Logger(new MonologLogger('audit', [$handler]));
(new RedactSensitiveAuditData(new SensitiveDataRedactor))($logger);
$logger->info("User changed password={$secret}", ['user_email' => 'admin@example.com', 'token' => $secret]);
$record = $handler->getRecords()[0];
expect($record->context['user_email'])->toBe('admin@example.com')
->and($record->context['token'])->toBe(REDACTED)
->and($record->message)->toBe('User changed password='.REDACTED);
});
it('redacts email addresses in the default channels', function () {
$redactor = new SensitiveDataRedactor;
expect($redactor->redactText('mail admin@example.com'))->toBe('mail '.REDACTED)
->and($redactor->withoutEmailRedaction()->redactText('mail admin@example.com'))->toBe('mail admin@example.com')
->and($redactor->redactText('mail admin@example.com'))->toBe('mail '.REDACTED);
});
+1 -1
View File
@@ -15,7 +15,7 @@ it('removes JWT/Bearer tokens', function () {
$result = sanitizeLogsForExport($input);
expect($result)->not->toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
expect($result)->toContain('Bearer '.REDACTED);
expect($result)->toBe('Authorization: '.REDACTED);
});
it('removes API keys with common patterns', function () {
+228
View File
@@ -0,0 +1,228 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Services\Security\SensitiveDataRedactor;
it('redacts known secret values and encoded variants longest first', function () {
$redactor = new SensitiveDataRedactor;
$knownSecrets = ['coolify-secret', 'coolify-secret-longer', 'secret with spaces', 'quote"secret'];
$result = $redactor->redactText('coolify-secret-longer coolify-secret secret%20with%20spaces quote\"secret', $knownSecrets);
expect($result)->not->toContain('coolify-secret')
->not->toContain('secret%20with%20spaces')
->not->toContain('quote\"secret')
->toBe(REDACTED.' '.REDACTED.' '.REDACTED.' '.REDACTED);
});
it('does not replace short known values in unstructured text', function () {
$result = (new SensitiveDataRedactor)->redactText('mode=dev enabled=yes count=1', ['dev', 'yes', 1]);
expect($result)->toBe('mode=dev enabled=yes count=1');
});
it('redacts sensitive keys recursively including short values', function () {
$result = (new SensitiveDataRedactor)->redactValue([
'name' => 'demo',
'PASSWORD' => 'x',
'request' => ['Authorization' => 'Bearer short', 'set-cookie' => 'session=secret', 'status' => 200],
]);
expect($result)->toBe([
'name' => 'demo',
'PASSWORD' => REDACTED,
'request' => ['Authorization' => REDACTED, 'set-cookie' => REDACTED, 'status' => 200],
]);
});
it('redacts complete authorization headers with opaque bearer tokens', function () {
$secret = 'coolify-canary-secret-scheduled';
$result = (new SensitiveDataRedactor)->redactText("Authorization: Bearer {$secret}\nstatus=ok");
expect($result)
->not->toContain($secret)
->toBe('Authorization: '.REDACTED."\nstatus=ok");
});
it('preserves current generic redaction behavior', function () {
$privateKey = "-----BEGIN PRIVATE KEY-----\nsecret-key-data\n-----END PRIVATE KEY-----";
$result = (new SensitiveDataRedactor)->redactText("\e[31mhttps://user:password@example.com\e[0m Bearer aaa.bbb.ccc {$privateKey}");
expect($result)->not->toContain("\e[31m")
->not->toContain('password')
->not->toContain('aaa.bbb.ccc')
->not->toContain('secret-key-data')
->toContain('https://user:'.REDACTED.'@example.com')
->toContain('Bearer '.REDACTED);
});
it('normalizes invalid utf8 and removes unsafe control characters', function () {
$result = (new SensitiveDataRedactor)->redactText("valid\xC3\x28\x00\x07\nnext");
expect(mb_check_encoding($result, 'UTF-8'))->toBeTrue()
->and($result)->not->toContain("\x00")
->and($result)->not->toContain("\x07")
->and($result)->toContain("\nnext");
});
it('suppresses logs when ANSI normalization exceeds the PCRE backtrack limit', function () {
$backtrackLimit = ini_get('pcre.backtrack_limit');
ini_set('pcre.backtrack_limit', '0');
set_error_handler(static fn (): bool => true, E_DEPRECATED);
try {
$result = (new SensitiveDataRedactor)->redactText("\e[31msecret");
} finally {
restore_error_handler();
ini_set('pcre.backtrack_limit', $backtrackLimit);
}
expect($result)->toBe(SensitiveDataRedactor::FAILURE_MESSAGE);
});
it('redacts large text completely without truncating it', function () {
$line = str_repeat('a', 1000)."\n";
$text = str_repeat($line, 600).'password=coolify-canary-tail-secret'."\n".'last line';
$result = (new SensitiveDataRedactor)->redactText($text);
expect(strlen($result))->toBeGreaterThan(600000)
->and($result)->not->toContain('coolify-canary-tail-secret')
->and($result)->toEndWith('password='.REDACTED."\nlast line");
});
it('keeps a private key that crosses a chunk boundary redacted', function () {
$privateKey = "-----BEGIN PRIVATE KEY-----\n".str_repeat("secret-key-line\n", 20000).'-----END PRIVATE KEY-----';
$text = str_repeat(str_repeat('a', 1000)."\n", 250).$privateKey."\nafter";
$result = (new SensitiveDataRedactor)->redactText($text);
expect($result)->not->toContain('secret-key-line')
->and($result)->toEndWith(REDACTED."\nafter");
});
it('always redacts short locked secrets but not short unlocked values', function () {
$result = (new SensitiveDataRedactor)->redactText('pin 4821 mode dev', ['dev'], ['4821']);
expect($result)->toBe('pin '.REDACTED.' mode dev');
});
it('redacts complete quoted assignment values that contain spaces', function () {
$result = (new SensitiveDataRedactor)->redactText("password=\"first second\" PASSWORD='third fourth' next=1");
expect($result)
->not->toContain('first')
->not->toContain('second')
->not->toContain('third')
->not->toContain('fourth')
->toBe('password="'.REDACTED.'" PASSWORD=\''.REDACTED.'\' next=1');
});
it('redacts arrayable objects and hides unknown objects', function () {
$secret = 'coolify-canary-object-secret';
$unknown = new class
{
public string $value = 'coolify-canary-object-secret';
};
$result = (new SensitiveDataRedactor)->redactValue([
'collection' => collect(['password' => $secret, 'name' => 'demo']),
'unknown' => $unknown,
'status' => ApplicationDeploymentStatus::FINISHED,
]);
expect(serialize($result))->not->toContain($secret)
->and($result['collection'])->toBe(['password' => REDACTED, 'name' => 'demo'])
->and($result['unknown'])->toStartWith('[object ')
->and($result['status'])->toBe(ApplicationDeploymentStatus::FINISHED);
});
it('redacts keys that end with a sensitive name', function () {
$redactor = new SensitiveDataRedactor;
expect($redactor->redactValue(['smtp_password' => 'hunter2', 'githubToken' => 'abc', 'X-Webhook-Secret' => 'def', 'token_count' => 5, 'password_reset_at' => 'today']))
->toBe(['smtp_password' => REDACTED, 'githubToken' => REDACTED, 'X-Webhook-Secret' => REDACTED, 'token_count' => 5, 'password_reset_at' => 'today'])
->and($redactor->redactText('smtp_password=hunter2 "db-password": "p w" max_tokens=100'))
->toBe('smtp_password='.REDACTED.' "db-password": "'.REDACTED.'" max_tokens=100');
});
it('keeps quotes around redacted assignment values so JSON stays valid', function () {
$redactor = new SensitiveDataRedactor;
$json = $redactor->redactText('{"token": "abc", "password": "a\\"b", "name": "demo"}');
expect($json)->toBe('{"token": "'.REDACTED.'", "password": "'.REDACTED.'", "name": "demo"}')
->and(json_decode($json, true))->toBe(['token' => REDACTED, 'password' => REDACTED, 'name' => 'demo'])
->and($redactor->redactText("{'secret': 'abc'}"))->toBe("{'secret': '".REDACTED."'}");
});
it('does not treat prose or working directories as secret assignments', function () {
$redactor = new SensitiveDataRedactor;
expect($redactor->redactText('Failed to refresh token: Connection refused'))->toBe('Failed to refresh token: Connection refused')
->and($redactor->redactText('PWD=/artifacts/abc'))->toBe('PWD=/artifacts/abc');
});
it('still redacts real secret assignments', function (string $text, string $expected) {
expect((new SensitiveDataRedactor)->redactText($text))->toBe($expected);
})->with([
'equals' => ['password=abc', 'password='.REDACTED],
'quoted key and value' => ['"password": "abc"', '"password": "'.REDACTED.'"'],
'quoted key with bare value' => ['"token": abc123,', '"token": '.REDACTED.','],
'env style' => ['API_TOKEN=abc', 'API_TOKEN='.REDACTED],
'env style with colon' => ['POSTGRES_PASSWORD: abc next', 'POSTGRES_PASSWORD: '.REDACTED.' next'],
'compound key with colon' => ['smtp_password: abc next', 'smtp_password: '.REDACTED.' next'],
'yaml value at line end' => ["password: correcthorse\nnext", 'password: '.REDACTED."\nnext"],
'value that looks like a secret' => ['Password: hunter2 was set', 'Password: '.REDACTED.' was set'],
'authorization header' => ['Authorization: Bearer x', 'Authorization: '.REDACTED],
]);
it('replaces short locked values only as whole tokens', function () {
$redactor = new SensitiveDataRedactor;
expect($redactor->redactText('database ab_c cab 9ab', lockedSecrets: ['ab']))->toBe('database ab_c cab 9ab')
->and($redactor->redactText('password xy user=xy "xy" postgres://u:xy@host', lockedSecrets: ['xy']))
->toBe('password '.REDACTED.' user='.REDACTED.' "'.REDACTED.'" postgres://u:'.REDACTED.'@host')
->and($redactor->redactText('éxyé xy', lockedSecrets: ['xy']))->toBe('éxyé '.REDACTED);
});
it('replaces encoded short locked values and long locked values longest first', function () {
$result = (new SensitiveDataRedactor)->redactText('p%40s p\\"s\\"q long-locked-value long-locked-value-2 inside-long-locked-valueX', lockedSecrets: ['p@s', 'p"s"q', 'long-locked-value', 'long-locked-value-2']);
expect($result)->toBe(REDACTED.' '.REDACTED.' '.REDACTED.' '.REDACTED.' inside-'.REDACTED.'X');
});
it('falls back to line by line redaction when a chunk exceeds PCRE limits', function () {
$text = "password=first-secret\n".str_repeat('a-', 40000)."\nToken=locked-value-123 next=1\nlast line";
$result = (new SensitiveDataRedactor)->redactText($text, lockedSecrets: ['locked-value-123']);
expect($result)->toBe('password='.REDACTED."\n".SensitiveDataRedactor::FAILURE_MESSAGE."\nToken=".REDACTED." next=1\nlast line");
});
it('redacts an unterminated private key followed by a lot of output without suppressing other lines', function () {
$text = "before password=abc\n-----BEGIN PRIVATE KEY-----\n".str_repeat("secret-key-line\n", 70000).'tail';
$result = (new SensitiveDataRedactor)->redactText($text);
expect($result)->not->toContain('secret-key-line')
->not->toContain(SensitiveDataRedactor::FAILURE_MESSAGE)
->toStartWith('before password='.REDACTED."\n");
});
it('keeps the quotes around a quoted shell assignment and hides its whole value', function (string $text, string $expected) {
expect((new SensitiveDataRedactor)->redactText($text))->toBe($expected);
})->with([
'single quoted build arg' => [
"docker build --build-arg 'COOLIFY_BUILD_SECRETS_HASH=abc123def' --build-arg 'COOLIFY_URL'",
"docker build --build-arg 'COOLIFY_BUILD_SECRETS_HASH=".REDACTED."' --build-arg 'COOLIFY_URL'",
],
'double quoted value with a space' => [
'export "DB_PASSWORD=first second" && next',
'export "DB_PASSWORD='.REDACTED.'" && next',
],
'single quoted value with a space' => [
"--env 'api_token=abc def' --rm",
"--env 'api_token=".REDACTED."' --rm",
],
]);