Add copy logs button with PII/secret sanitization
Add a copy button to individual container logs that strips sensitive data before copying to clipboard. Includes sanitization for emails, database URLs with passwords, JWT tokens, API keys, private key blocks, and git access tokens.
This commit is contained in:
parent
6b88481ce2
commit
327e8181af
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');
|
||||
|
|
|
|||
|
|
@ -653,6 +653,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) {
|
||||
|
|
|
|||
|
|
@ -235,6 +235,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 x-on:click="downloadLogs()" title="Download 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"
|
||||
|
|
|
|||
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