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:
Duane Adam 2025-12-16 10:43:18 +08:00
parent 6b88481ce2
commit 327e8181af
No known key found for this signature in database
GPG key ID: 8ECBF303D9B14D71
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

@ -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) {

View file

@ -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"

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);
});