feat: copy resource logs with PII/secret sanitization (#7648)
This commit is contained in:
commit
96f2e81191
4 changed files with 147 additions and 0 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' : '' }}">
|
||||
|
|
|
|||
103
tests/Unit/SanitizeLogsForExportTest.php
Normal file
103
tests/Unit/SanitizeLogsForExportTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue