Improve deployment log handling (#11974)

This commit is contained in:
Andras Bacsai
2026-09-24 09:41:31 +02:00
committed by GitHub
4 changed files with 209 additions and 76 deletions
+39 -35
View File
@@ -179,43 +179,47 @@ class ApplicationDeploymentQueue extends Model
private function redactSensitiveInfo($text)
{
$text = remove_iip($text);
try {
$text = remove_iip($text);
$app = $this->application;
if (! $app) {
return $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()
);
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return is_string($text) ? $text : REDACTED;
} catch (\Throwable) {
return REDACTED;
}
$lockedVars = collect([]);
if ($app->environment_variables) {
$lockedVars = $lockedVars->merge(
$app->environment_variables
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
$lockedVars = $lockedVars->merge(
$app->environment_variables_preview
->where('is_shown_once', true)
->pluck('real_value', 'key')
->filter()
);
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return $text;
}
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
+24
View File
@@ -309,6 +309,30 @@ class EnvironmentVariable extends BaseModel
return $real_value;
}
/** @return array<int, string> */
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;
+46 -41
View File
@@ -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;
@@ -21,49 +22,53 @@ trait ExecuteRemoteCommand
private function redact_sensitive_info($text)
{
$text = remove_iip($text);
try {
$text = remove_iip($text);
if (! isset($this->application)) {
return $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(array_values(array_filter(
$this->remote_secrets_cache,
static fn (mixed $value): bool => is_string($value) && $value !== ''
)));
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return is_string($text) ? $text : REDACTED;
} catch (\Throwable) {
return REDACTED;
}
$lockedVars = collect([]);
if (isset($this->application->environment_variables)) {
$lockedVars = $lockedVars->merge(
$this->application->environment_variables
->where('is_shown_once', true)
->pluck('real_value', 'key')
->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)
->pluck('real_value', 'key')
->filter()
);
}
if (isset($this->remote_secrets_cache)) {
$lockedVars = $lockedVars->merge(array_values(array_filter(
$this->remote_secrets_cache,
static fn (mixed $value): bool => is_string($value) && $value !== ''
)));
}
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
'/'.$escapedValue.'/',
REDACTED,
$text
);
}
return $text;
}
public function execute_remote_command(...$commands)
@@ -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,105 @@ 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('keeps deployment logging available if value formatting fails', function () {
[$application, $server] = makeDeploymentControlVarFixture();
$variable = new class extends EnvironmentVariable
{
public function logRedactionValues(): array
{
throw new RuntimeException('Harmless formatting failure');
}
};
$variable->is_shown_once = true;
$application->setRelation('environment_variables', collect([$variable]));
$deployment = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'harmless-formatting-failure',
'application_id' => $application->id,
'server_id' => $server->id,
]);
$deployment->setRelation('application', $application);
$deployment->addLogEntry('Harmless log text');
expect(json_decode($deployment->fresh()->logs, true)[0]['output'])->toBe(REDACTED);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
readDeploymentJobProperty($job, $reflection, 'application')
->setRelation('environment_variables', collect([$variable]));
expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'Harmless command text'))
->toBe(REDACTED);
});
it('ignores empty and non-string remote secrets when redacting command output', function () {
[$application, $server] = makeDeploymentControlVarFixture();
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [