diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index f16f7f8f96..5bf64b2998 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -192,7 +192,7 @@ class ApplicationDeploymentQueue extends Model $lockedVars = $lockedVars->merge( $app->environment_variables ->where('is_shown_once', true) - ->pluck('real_value', 'key') + ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) ->filter() ); } @@ -201,7 +201,7 @@ class ApplicationDeploymentQueue extends Model $lockedVars = $lockedVars->merge( $app->environment_variables_preview ->where('is_shown_once', true) - ->pluck('real_value', 'key') + ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) ->filter() ); } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index e7dd8564bc..176ffee98f 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -309,6 +309,30 @@ class EnvironmentVariable extends BaseModel return $real_value; } + /** @return array */ + public function logRedactionValues(): array + { + $value = $this->real_value; + if (! is_string($value) || $value === '') { + return []; + } + + $values = [$value]; + if ($this->is_multiline || $this->is_literal) { + $unquoted = str_starts_with($value, "'") && str_ends_with($value, "'") + ? substr($value, 1, -1) + : $value; + $values[] = $unquoted; + $values[] = escapeBashEnvValue($unquoted); + $values[] = str_replace(["\r\n", "\r", "\n"], ['\\n', '\\n', '\\n'], $unquoted); + if ($this->is_multiline) { + $values = array_merge($values, preg_split('/\r\n|\r|\n/', $unquoted) ?: []); + } + } + + return array_values(array_unique(array_filter($values, static fn (string $item): bool => $item !== ''))); + } + public function resolveReferencedValue(): ?string { $value = $this->value; diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 87bd7f8748..9b8dd02dfd 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -5,6 +5,7 @@ namespace App\Traits; use App\Enums\ApplicationDeploymentStatus; use App\Exceptions\DeploymentException; use App\Helpers\SshMultiplexingHelper; +use App\Models\EnvironmentVariable; use App\Models\Server; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -33,7 +34,7 @@ trait ExecuteRemoteCommand $lockedVars = $lockedVars->merge( $this->application->environment_variables ->where('is_shown_once', true) - ->pluck('real_value', 'key') + ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) ->filter() ); } @@ -42,7 +43,7 @@ trait ExecuteRemoteCommand $lockedVars = $lockedVars->merge( $this->application->environment_variables_preview ->where('is_shown_once', true) - ->pluck('real_value', 'key') + ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) ->filter() ); } diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index e8fb9cb84d..1e76bf58d6 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -10,6 +10,7 @@ use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\Server; use App\Models\Team; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -64,6 +65,75 @@ it('redacts resolved remote secrets from command output', function () { ->toBe('token='.REDACTED); }); +it('does not retain locked values from generated build-time debug logs', function () { + config()->set('app.env', 'local'); + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'SINGLE_MARKER', + 'value' => 'harmless-single-marker', + 'is_shown_once' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'MULTILINE_MARKER', + 'value' => "harmless-first-marker\nharmless-second-marker", + 'is_multiline' => true, + 'is_shown_once' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server); + invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + + $deployment = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'harmless-debug-log-deployment', + 'application_id' => $application->id, + 'server_id' => $server->id, + ]); + foreach ($job->recordedLogEntries as $entry) { + $deployment->addLogEntry($entry); + } + + $retainedLogs = $deployment->fresh()->logs; + expect($retainedLogs) + ->not->toContain('harmless-single-marker') + ->not->toContain('harmless-first-marker') + ->not->toContain('harmless-second-marker') + ->toContain(REDACTED); + + $member = User::factory()->create(); + $application->team()->members()->attach($member->id, ['role' => 'member']); + $application->settings->update(['is_debug_enabled' => true]); + $this->actingAs($member); + + $visibleLines = decode_remote_command_output($deployment->fresh())->pluck('line')->implode("\n"); + expect($visibleLines) + ->toContain('[DEBUG]') + ->not->toContain('harmless-first-marker') + ->not->toContain('harmless-second-marker'); +}); + +it('redacts generated multiline forms in remote command and output logging', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + createApplicationEnvironmentVariable($application, [ + 'key' => 'MULTILINE_MARKER', + 'value' => "harmless-first-marker\nharmless-second-marker", + 'is_multiline' => true, + 'is_shown_once' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server); + $generated = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables') + ->first(fn (string $line): bool => str_starts_with($line, 'MULTILINE_MARKER=')); + + foreach ([$generated, str_replace("\n", '\\n', $generated), 'harmless-second-marker'] as $loggedForm) { + $redacted = invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'output: '.$loggedForm); + expect($redacted) + ->not->toContain('harmless-first-marker') + ->not->toContain('harmless-second-marker') + ->toContain('output: '); + } +}); + it('ignores empty and non-string remote secrets when redacting command output', function () { [$application, $server] = makeDeploymentControlVarFixture(); [$job, $reflection] = makeControlVarFilteringJob($application, $server, [