feat: copy resource logs with PII/secret sanitization (#7648)

This commit is contained in:
Andras Bacsai 2025-12-17 16:05:13 +01:00 committed by GitHub
commit 96f2e81191
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 147 additions and 0 deletions

View file

@ -179,6 +179,11 @@ public function getLogs($refresh = false)
}
}
public function copyLogs(): string
{
return sanitizeLogsForExport($this->outputs);
}
public function render()
{
return view('livewire.project.shared.get-logs');

View file

@ -672,6 +672,30 @@ function removeAnsiColors($text)
return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text);
}
function sanitizeLogsForExport(string $text): string
{
// Use existing helper for tokens and ANSI codes
$text = remove_iip($text);
// Database URLs with passwords - must run before email regex to prevent false matches
// (postgres://user:password@host → postgres://user:<REDACTED>@host)
$text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb):\/\/[^:]+:)[^@]+(@)/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);
// 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;
}
function getTopLevelNetworks(Service|Application $resource)
{
if ($resource->getMorphClass() === \App\Models\Service::class) {

View file

@ -271,6 +271,21 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
<button
x-on:click="
$wire.copyLogs().then(logs => {
navigator.clipboard.writeText(logs);
$dispatch('success', 'Logs copied to clipboard.');
});
"
title="Copy Logs"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
</button>
<button wire:click="toggleStreamLogs"
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">

View file

@ -0,0 +1,103 @@
<?php
it('removes email addresses', function () {
$input = 'User email is test@example.com and another@domain.org';
$result = sanitizeLogsForExport($input);
expect($result)->not->toContain('test@example.com');
expect($result)->not->toContain('another@domain.org');
expect($result)->toContain(REDACTED);
});
it('removes JWT/Bearer tokens', function () {
$jwt = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
$input = "Authorization: {$jwt}";
$result = sanitizeLogsForExport($input);
expect($result)->not->toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
expect($result)->toContain('Bearer '.REDACTED);
});
it('removes API keys with common patterns', function () {
$testCases = [
'api_key=abcdef1234567890abcdef1234567890',
'api-key: abcdef1234567890abcdef1234567890',
'apikey=abcdef1234567890abcdef1234567890',
'api_secret="abcdef1234567890abcdef1234567890"',
'secret_key=abcdef1234567890abcdef1234567890',
];
foreach ($testCases as $input) {
$result = sanitizeLogsForExport($input);
expect($result)->not->toContain('abcdef1234567890abcdef1234567890');
expect($result)->toContain(REDACTED);
}
});
it('removes database URLs with passwords', function () {
$testCases = [
'postgres://user:secretpassword@localhost:5432/db' => 'postgres://user:'.REDACTED.'@localhost:5432/db',
'mysql://admin:mysecret123@db.example.com/app' => 'mysql://admin:'.REDACTED.'@db.example.com/app',
'mongodb://user:pass123@mongo:27017' => 'mongodb://user:'.REDACTED.'@mongo:27017',
'redis://default:redispass@redis:6379' => 'redis://default:'.REDACTED.'@redis:6379',
'rediss://default:redispass@redis:6379' => 'rediss://default:'.REDACTED.'@redis:6379',
'mariadb://root:rootpass@mariadb:3306/test' => 'mariadb://root:'.REDACTED.'@mariadb:3306/test',
];
foreach ($testCases as $input => $expected) {
$result = sanitizeLogsForExport($input);
expect($result)->toBe($expected);
}
});
it('removes private key blocks', function () {
$privateKey = <<<'KEY'
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAyZ3xL8v4xK3z9Z3
some-key-content-here
-----END RSA PRIVATE KEY-----
KEY;
$input = "Config: {$privateKey} more text";
$result = sanitizeLogsForExport($input);
expect($result)->not->toContain('-----BEGIN RSA PRIVATE KEY-----');
expect($result)->not->toContain('MIIEowIBAAKCAQEAyZ3xL8v4xK3z9Z3');
expect($result)->toContain(REDACTED);
expect($result)->toContain('more text');
});
it('removes x-access-token from git URLs', function () {
$input = 'git clone https://x-access-token:gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/user/repo.git';
$result = sanitizeLogsForExport($input);
expect($result)->not->toContain('gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
expect($result)->toContain('x-access-token:'.REDACTED.'@github.com');
});
it('removes ANSI color codes', function () {
$input = "\e[32mGreen text\e[0m and \e[31mred text\e[0m";
$result = sanitizeLogsForExport($input);
expect($result)->toBe('Green text and red text');
});
it('preserves normal log content', function () {
$input = "2025-12-16T10:30:45.123456Z INFO: Application started\n2025-12-16T10:30:46.789012Z DEBUG: Processing request";
$result = sanitizeLogsForExport($input);
expect($result)->toBe($input);
});
it('handles empty string', function () {
expect(sanitizeLogsForExport(''))->toBe('');
});
it('handles multiple sensitive items in same string', function () {
$input = 'Email: user@test.com, DB: postgres://admin:secret@localhost/db, API: api_key=12345678901234567890';
$result = sanitizeLogsForExport($input);
expect($result)->not->toContain('user@test.com');
expect($result)->not->toContain('secret');
expect($result)->not->toContain('12345678901234567890');
expect($result)->toContain(REDACTED);
});