diff --git a/README.md b/README.md index 9ca9412..b647567 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,26 @@ Http::log($context, ['example-config-key' => 'value'])->get('https://example.com Http::logWhen($condition, $context, ['example-config-key' => 'value'])->get('https://example.com'); ``` +### Removing sensitive data from logs + +Sensitive information should be masked or replaced with placeholders before being written to log files. The configuration allows you to replace header values, query parameters, and specific strings in the response. You can also define custom, on-demand configurations to remove sensitive data. + +For example: + +```php +Http::log([], [ + 'replace' => ['3566002020360505' => '************0505'], + 'replace_headers' => ['Authorization'] + ]) + ->withToken('my-token') + ->post('https://www.example.com/verify-credit-card', ['card' => '3566002020360505']); +``` + +In this case: + +- The authorization token is completely removed from the logs. +- The credit card number is partially masked, preserving only the last four digits (`************0505`). + ### Specifying a logger The default logger and filter are specified in the package configuration `logger` and `filter` respectively but can be changed at runtime using: ```php diff --git a/config/http-client-logger.php b/config/http-client-logger.php index 871c8b1..6610b8e 100644 --- a/config/http-client-logger.php +++ b/config/http-client-logger.php @@ -46,6 +46,29 @@ 'filter_slow' => env('HTTP_CLIENT_LOGGER_FILTER_SLOW', 1.5), // Log requests that took longer than the setting (in sec) + /* + |-------------------------------------------------------------------------- + | Replace sensitive data with a placeholder before logging + |-------------------------------------------------------------------------- + | + | These settings determine what data should be replaced with a placeholder. + | + | - replace contains an associative array of strings, where the key will be replaced with the value everywhere in the request/response + | - replace_values will be replaced in headers, query parameters and json data (but not json keys) + | - replace_headers contains an array of header names whose values are replaced with placeholders + | - replace_query contains an array of query parameter names whose values are replaced with placeholders + | - replace_json contains an array of json paths whose values are replaced with placeholders + */ + 'replace' => [], + + 'replace_values' => [], + + 'replace_headers' => [], + + 'replace_query' => [], + + 'replace_json' => [], + /* |-------------------------------------------------------------------------- | Logger class diff --git a/src/HttpLogger.php b/src/HttpLogger.php index 7543f6e..e597692 100644 --- a/src/HttpLogger.php +++ b/src/HttpLogger.php @@ -37,8 +37,19 @@ public function log( $this->response = $response; $this->sec = $sec; $this->context = $context; + $this->config = array_merge(config('http-client-logger'), $config); + // set up custom message accessor based on current config + $messageAccessorClass = $this->config['message_accessor_class'] ?? MessageAccessor::class; + $messageAccessor = new $messageAccessorClass( + $this->config['replace_json'] ?? [], + $this->config['replace_query'] ?? [], + $this->config['replace_headers'] ?? [], + $this->config['replace_values'] ?? [], + ); + $this->psrMessageStringConverter->setMessageAccessor($messageAccessor); + if (Arr::get($this->config, 'channel')) { $this->logToChannel(($channel = Arr::get($this->config, 'channel')) == 'default' ? config('logging.default') : $channel); } diff --git a/src/LaravelHttpClientLoggerServiceProvider.php b/src/LaravelHttpClientLoggerServiceProvider.php index de62aaf..7d65a04 100644 --- a/src/LaravelHttpClientLoggerServiceProvider.php +++ b/src/LaravelHttpClientLoggerServiceProvider.php @@ -79,5 +79,14 @@ public function packageRegistered() $this->app->bind(HttpLoggingFilterInterface::class, function ($app) { return $app->make(config('http-client-logger.filter')); }); + + $this->app->singleton(MessageAccessor::class, function ($app) { + return new MessageAccessor( + config('http-client-logger.replace_json', []), + config('http-client-logger.replace_query', []), + config('http-client-logger.replace_headers', []), + config('http-client-logger.replace_values', []), + ); + }); } } diff --git a/src/MessageAccessor.php b/src/MessageAccessor.php index 6b728ec..5a5a00d 100644 --- a/src/MessageAccessor.php +++ b/src/MessageAccessor.php @@ -15,20 +15,20 @@ class MessageAccessor private array $queryFilters; private array $headersFilters; private array $jsonFilters; - private string $replace; + private string $placeholder; public function __construct( - array $jsonFilers = [], + array $jsonFilters = [], array $queryFilters = [], array $headersFilters = [], array $values = [], - string $replace = '********' + string $placeholder = '********' ) { $this->values = $values; $this->queryFilters = $queryFilters; $this->headersFilters = $headersFilters; - $this->jsonFilters = $jsonFilers; - $this->replace = $replace; + $this->jsonFilters = $jsonFilters; + $this->placeholder = $placeholder; } public function getUri(RequestInterface $request): UriInterface @@ -37,10 +37,10 @@ public function getUri(RequestInterface $request): UriInterface parse_str($uri->getQuery(), $query); return $uri - ->withUserInfo($this->replace($this->values, $this->replace, $uri->getUserInfo())) - ->withHost($this->replace($this->values, $this->replace, $uri->getHost())) - ->withPath($this->replace($this->values, $this->replace, $uri->getPath())) - ->withQuery(Arr::query($this->replaceParameters($query, $this->queryFilters, $this->values, $this->replace))); + ->withUserInfo($this->replace($this->values, $this->placeholder, $uri->getUserInfo())) + ->withHost($this->replace($this->values, $this->placeholder, $uri->getHost())) + ->withPath($this->replace($this->values, $this->placeholder, $uri->getPath())) + ->withQuery(Arr::query($this->replaceParameters($query, $this->queryFilters, $this->values, $this->placeholder))); } public function getBase(RequestInterface $request): string @@ -75,12 +75,12 @@ public function getHeaders(MessageInterface $message): array { foreach ($this->headersFilters as $headersFilter) { if ($message->hasHeader($headersFilter)) { - $message = $message->withHeader($headersFilter, $this->replace); + $message = $message->withHeader($headersFilter, $this->placeholder); } } // Header filter applied above as this is an array with two layers - return $this->replaceParameters($message->getHeaders(), [], $this->values, $this->replace, false); + return $this->replaceParameters($message->getHeaders(), [], $this->values, $this->placeholder, false); } /** @@ -104,7 +104,7 @@ public function getJson(MessageInterface $message): ?array json_decode($message->getBody()->__toString(), true), $this->jsonFilters, $this->values, - $this->replace + $this->placeholder ); } @@ -115,7 +115,7 @@ public function getContent(MessageInterface $message): string } else { $body = $message->getBody()->__toString(); foreach ($this->values as $value) { - $body = str_replace($value, $this->replace, $body); + $body = str_replace($value, $this->placeholder, $body); } } diff --git a/src/PsrMessageToStringConverter.php b/src/PsrMessageToStringConverter.php index 175e945..7ef5ef2 100644 --- a/src/PsrMessageToStringConverter.php +++ b/src/PsrMessageToStringConverter.php @@ -9,9 +9,23 @@ class PsrMessageToStringConverter { - public function toString(MessageInterface $message, array $placeholders): string + protected MessageAccessor $messageAccessor; + + public function __construct(MessageAccessor $messageAccessor) + { + $this->messageAccessor = $messageAccessor; + } + + public function setMessageAccessor(MessageAccessor $messageAccessor): void { - return strtr(Message::toString($message), $placeholders); + $this->messageAccessor = $messageAccessor; + } + + public function toString(MessageInterface $message, array $replace): string + { + $filteredMessage = $message instanceof Request ? $this->messageAccessor->filterRequest($message) : $this->messageAccessor->filterMessage($message); + + return strtr(Message::toString($filteredMessage), $replace); } public function toRequest(string $message): Request diff --git a/tests/HttpLoggerE2eTest.php b/tests/HttpLoggerE2eTest.php new file mode 100644 index 0000000..51543e7 --- /dev/null +++ b/tests/HttpLoggerE2eTest.php @@ -0,0 +1,83 @@ + Http::response(['authentication_token' => 'SECRET_TOKEN'], 200), + 'https://api.example.com/documents' => function ($request) { + $authorizationHeader = $request->header('Authorization'); + + if (($authorizationHeader[0] ?? '') === 'Bearer SECRET_TOKEN') { + return Http::response([ + ['id' => '1', 'title' => 'Document Title 1', 'author' => 'Author Name 1'], + ['id' => '2', 'title' => 'Document Title 2', 'author' => 'Author Name 2'] + ], 200); + } else { + return Http::response(['error' => 'Unauthorized'], 401); + } + }, + '*' => Http::response(['error' => 'Not Found'], 404), + ]); + } + + public function test_accessor_adhoc_config() + { + LogFake::bind(); + + $pendingRequest = Http::log( + [], + [ 'replace_json' => [ 'authentication_token'], + 'replace_headers' => ['Authorization'], + 'replace_query' => ['username', 'pass'] + ] + ); + + $responses = [ + $pendingRequest->get('https://api.example.com/login?username=SECRET_USER&pass=SECRET_PASSWORD'), + $pendingRequest->withToken("SECRET_TOKEN")->get('https://api.example.com/documents') + ]; + + Log::assertLogged(fn (LogEntry $log) => !Str::contains($log->message, 'SECRET_')); + + } + + public function test_accessor_custom_class() + { + LogFake::bind(); + + Http::log([], [ + 'message_accessor_class' => MockMessageAccessor::class, + ])->get('https://api.example.com/login?username=SECRET_USER&pass=SECRET_PASSWORD'); + + Log::assertLogged(fn (LogEntry $log) => Str::contains($log->message, 'TOP SECRET')); + } +} + +class MockMessageAccessor extends MessageAccessor +{ + public function getContent(MessageInterface $message) : string + { + return "TOP SECRET"; + } +} \ No newline at end of file diff --git a/tests/HttpLoggerTest.php b/tests/HttpLoggerTest.php index 790d76e..daf1f59 100644 --- a/tests/HttpLoggerTest.php +++ b/tests/HttpLoggerTest.php @@ -3,6 +3,7 @@ namespace Bilfeldt\LaravelHttpClientLogger\Tests; use Bilfeldt\LaravelHttpClientLogger\HttpLogger; +use Bilfeldt\LaravelHttpClientLogger\MessageAccessor; use Bilfeldt\LaravelHttpClientLogger\PsrMessageToStringConverter; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; @@ -21,7 +22,7 @@ public function setUp(): void { parent::setUp(); - $this->logger = new HttpLogger(new PsrMessageToStringConverter()); + $this->logger = new HttpLogger(new PsrMessageToStringConverter(new MessageAccessor())); $this->request = new Request('GET', 'https://example.com/path?query=ABCDEF', ['header1' => 'HIJKL'], 'TestRequestBody'); } diff --git a/tests/PsrMessageToStringConverterTest.php b/tests/PsrMessageToStringConverterTest.php new file mode 100644 index 0000000..239dd19 --- /dev/null +++ b/tests/PsrMessageToStringConverterTest.php @@ -0,0 +1,88 @@ +converter = new PsrMessageToStringConverter($messageAccessor); + + $this->request = new Request( + 'POST', + 'https://user:secret@secret.example.com:9000/some-path/secret/should-not-be-removed?test=true&search=foo&filter[field1]=A&filter[field2]=B#anchor', + [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer 1234567890', + ], + json_encode([ + 'data' => [ + 'foo' => 'bar', + 'baz' => [ + [ + 'field_1' => 'value1', + 'field_2' => 'value2', + 'password' => '123456', + 'secret' => 'this is not for everyone', + 'legacy_replace' => 'replace array is also still used', + ], + ], + ], + ]) + ); + } + + public function test_to_string_replaces_sensitive_data() + { + $string = $this->converter->toString($this->request, ['legacy_replace' => '********']); + + $this->assertStringContainsString( + 'POST /some-path/********/should-not-be-removed?test=true&search=%2A%2A%2A%2A%2A%2A%2A%2A', + $string, + 'sensitive data not replaced in URI' + ); + + $this->assertStringNotContainsString( + '123456', + $string, + 'sensitive data not replaced in json' + ); + + $this->assertStringContainsString( + 'Host: ********.example.com:9000', + $string, + 'sensitive data not replaced in Host header' + ); + + $this->assertStringContainsString( + 'Authorization: ********', + $string, + 'sensitive header not masked' + ); + + $this->assertStringNotContainsString( + 'legacy_replace', + $string, + 'replace array not used' + ); + } +}