From 45027993ed6de9bbbaab762949f068e59530b5cd Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Wed, 2 Oct 2024 06:54:09 +0000 Subject: [PATCH 1/9] naming --- src/MessageAccessor.php | 26 +++++++++++++------------- src/PsrMessageToStringConverter.php | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) 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..68e65df 100644 --- a/src/PsrMessageToStringConverter.php +++ b/src/PsrMessageToStringConverter.php @@ -9,9 +9,9 @@ class PsrMessageToStringConverter { - public function toString(MessageInterface $message, array $placeholders): string + public function toString(MessageInterface $message, array $replace): string { - return strtr(Message::toString($message), $placeholders); + return strtr(Message::toString($message), $replace); } public function toRequest(string $message): Request From 31b3fe8c33d74da01f89db0f69fd4f820b2631ee Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Wed, 2 Oct 2024 07:31:23 +0000 Subject: [PATCH 2/9] add message accessor singleton; use message accessor in the message to string converter --- src/LaravelHttpClientLoggerServiceProvider.php | 9 +++++++++ src/PsrMessageToStringConverter.php | 10 +++++++++- tests/HttpLoggerTest.php | 3 ++- 3 files changed, 20 insertions(+), 2 deletions(-) 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/PsrMessageToStringConverter.php b/src/PsrMessageToStringConverter.php index 68e65df..685156f 100644 --- a/src/PsrMessageToStringConverter.php +++ b/src/PsrMessageToStringConverter.php @@ -9,9 +9,17 @@ class PsrMessageToStringConverter { + protected MessageAccessor $messageAccessor; + + public function __construct(MessageAccessor $messageAccessor) + { + $this->messageAccessor = $messageAccessor; + } + public function toString(MessageInterface $message, array $replace): string { - return strtr(Message::toString($message), $replace); + $filteredMessage = $this->messageAccessor->filterMessage($message); + return strtr(Message::toString($filteredMessage), $replace); } public function toRequest(string $message): Request 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'); } From 3a841a8bf073d5a619e348fba864d53f796b1d31 Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Wed, 2 Oct 2024 07:33:22 +0000 Subject: [PATCH 3/9] add replace stubs to config --- config/http-client-logger.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/config/http-client-logger.php b/config/http-client-logger.php index 871c8b1..387338f 100644 --- a/config/http-client-logger.php +++ b/config/http-client-logger.php @@ -46,6 +46,26 @@ '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_values contains an array of strings that will be replaced anywhere in the request/response + | - 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_values' => [], + + 'replace_headers' => [], + + 'replace_query' => [], + + 'replace_json' => [], + + /* |-------------------------------------------------------------------------- | Logger class From a323e8b215c493079070f08435a62bb8e4d2add6 Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Wed, 2 Oct 2024 07:48:31 +0000 Subject: [PATCH 4/9] test message to string converter with accessor --- src/PsrMessageToStringConverter.php | 2 +- tests/PsrMessageToStringConverterTest.php | 85 +++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/PsrMessageToStringConverterTest.php diff --git a/src/PsrMessageToStringConverter.php b/src/PsrMessageToStringConverter.php index 685156f..3c026b6 100644 --- a/src/PsrMessageToStringConverter.php +++ b/src/PsrMessageToStringConverter.php @@ -18,7 +18,7 @@ public function __construct(MessageAccessor $messageAccessor) public function toString(MessageInterface $message, array $replace): string { - $filteredMessage = $this->messageAccessor->filterMessage($message); + $filteredMessage = $message instanceof Request ? $this->messageAccessor->filterRequest($message) : $this->messageAccessor->filterMessage($message); return strtr(Message::toString($filteredMessage), $replace); } diff --git a/tests/PsrMessageToStringConverterTest.php b/tests/PsrMessageToStringConverterTest.php new file mode 100644 index 0000000..4d13d7c --- /dev/null +++ b/tests/PsrMessageToStringConverterTest.php @@ -0,0 +1,85 @@ +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->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' + ); + } +} \ No newline at end of file From 9830fab2880ca9f5feac88307ba552f87d88ccd6 Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Wed, 2 Oct 2024 07:50:44 +0000 Subject: [PATCH 5/9] make config comments more precise --- config/http-client-logger.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/http-client-logger.php b/config/http-client-logger.php index 387338f..656663a 100644 --- a/config/http-client-logger.php +++ b/config/http-client-logger.php @@ -53,10 +53,14 @@ | | These settings determine what data should be replaced with a placeholder. | - | - replace_values contains an array of strings that will be replaced anywhere in the request/response + | - replace contains an array of strings that will be replaced anywhere 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' => [], From 6374dbdf59d455faa01e958cce6b0cedde6e2089 Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Wed, 2 Oct 2024 12:18:49 +0000 Subject: [PATCH 6/9] apply style ci fixes --- config/http-client-logger.php | 1 - src/PsrMessageToStringConverter.php | 1 + tests/PsrMessageToStringConverterTest.php | 17 +++++++---------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/config/http-client-logger.php b/config/http-client-logger.php index 656663a..e30901c 100644 --- a/config/http-client-logger.php +++ b/config/http-client-logger.php @@ -69,7 +69,6 @@ 'replace_json' => [], - /* |-------------------------------------------------------------------------- | Logger class diff --git a/src/PsrMessageToStringConverter.php b/src/PsrMessageToStringConverter.php index 3c026b6..7b3fde5 100644 --- a/src/PsrMessageToStringConverter.php +++ b/src/PsrMessageToStringConverter.php @@ -19,6 +19,7 @@ public function __construct(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); } diff --git a/tests/PsrMessageToStringConverterTest.php b/tests/PsrMessageToStringConverterTest.php index 4d13d7c..c97c450 100644 --- a/tests/PsrMessageToStringConverterTest.php +++ b/tests/PsrMessageToStringConverterTest.php @@ -4,11 +4,8 @@ use Bilfeldt\LaravelHttpClientLogger\MessageAccessor; use Bilfeldt\LaravelHttpClientLogger\PsrMessageToStringConverter; -use GuzzleHttp\Psr7\Message; use GuzzleHttp\Psr7\Request; -use GuzzleHttp\Psr7\Response; use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; class PsrMessageToStringConverterTest extends TestCase { @@ -42,11 +39,11 @@ public function setUp(): void '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' + 'field_1' => 'value1', + 'field_2' => 'value2', + 'password' => '123456', + 'secret' => 'this is not for everyone', + 'legacy_replace' => 'replace array is also still used', ], ], ], @@ -56,7 +53,7 @@ public function setUp(): void public function test_to_string_replaces_sensitive_data() { - $string = $this->converter->toString($this->request, [ 'legacy_replace' => '********']); + $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', @@ -82,4 +79,4 @@ public function test_to_string_replaces_sensitive_data() 'replace array not used' ); } -} \ No newline at end of file +} From 0ad6d9e3955b8435cd5a5be3896d3579761cc10a Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Wed, 2 Oct 2024 12:58:05 +0000 Subject: [PATCH 7/9] add example of removing sensitive data in the README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 From f1eddcfbc981f16b254b29339669aee92d0542ee Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Mon, 28 Oct 2024 19:09:37 +0000 Subject: [PATCH 8/9] message accessor cannot be configured with ad-hoc configuration, throw exception if this is attempt; docs + test improvements --- config/http-client-logger.php | 2 +- src/HttpLogger.php | 6 ++++++ tests/PsrMessageToStringConverterTest.php | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/config/http-client-logger.php b/config/http-client-logger.php index e30901c..6610b8e 100644 --- a/config/http-client-logger.php +++ b/config/http-client-logger.php @@ -53,7 +53,7 @@ | | These settings determine what data should be replaced with a placeholder. | - | - replace contains an array of strings that will be replaced anywhere in the request/response + | - 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 diff --git a/src/HttpLogger.php b/src/HttpLogger.php index 7543f6e..3615a7b 100644 --- a/src/HttpLogger.php +++ b/src/HttpLogger.php @@ -37,6 +37,12 @@ public function log( $this->response = $response; $this->sec = $sec; $this->context = $context; + + // ad-hoc config is not supported for message accessor settings: replace_json, replace_query, replace_headers, replace_values. + if (Arr::hasAny($config, ['replace_json', 'replace_query', 'replace_headers', 'replace_values'])) { + throw new \InvalidArgumentException('Ad-hoc config does not support replace_json, replace_query, replace_headers, replace_values.'); + } + $this->config = array_merge(config('http-client-logger'), $config); if (Arr::get($this->config, 'channel')) { diff --git a/tests/PsrMessageToStringConverterTest.php b/tests/PsrMessageToStringConverterTest.php index c97c450..239dd19 100644 --- a/tests/PsrMessageToStringConverterTest.php +++ b/tests/PsrMessageToStringConverterTest.php @@ -61,6 +61,12 @@ public function test_to_string_replaces_sensitive_data() 'sensitive data not replaced in URI' ); + $this->assertStringNotContainsString( + '123456', + $string, + 'sensitive data not replaced in json' + ); + $this->assertStringContainsString( 'Host: ********.example.com:9000', $string, From 1c7f44d27b4785a9809dbe3845e9bacd07bbe579 Mon Sep 17 00:00:00 2001 From: basteln3rk Date: Mon, 28 Oct 2024 23:05:36 +0000 Subject: [PATCH 9/9] enable ad-hoc configuration for message accessor and swapping out message accessor class; add e2e tests --- src/HttpLogger.php | 15 ++++-- src/PsrMessageToStringConverter.php | 5 ++ tests/HttpLoggerE2eTest.php | 83 +++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/HttpLoggerE2eTest.php diff --git a/src/HttpLogger.php b/src/HttpLogger.php index 3615a7b..e597692 100644 --- a/src/HttpLogger.php +++ b/src/HttpLogger.php @@ -38,13 +38,18 @@ public function log( $this->sec = $sec; $this->context = $context; - // ad-hoc config is not supported for message accessor settings: replace_json, replace_query, replace_headers, replace_values. - if (Arr::hasAny($config, ['replace_json', 'replace_query', 'replace_headers', 'replace_values'])) { - throw new \InvalidArgumentException('Ad-hoc config does not support replace_json, replace_query, replace_headers, replace_values.'); - } - $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/PsrMessageToStringConverter.php b/src/PsrMessageToStringConverter.php index 7b3fde5..7ef5ef2 100644 --- a/src/PsrMessageToStringConverter.php +++ b/src/PsrMessageToStringConverter.php @@ -16,6 +16,11 @@ public function __construct(MessageAccessor $messageAccessor) $this->messageAccessor = $messageAccessor; } + public function setMessageAccessor(MessageAccessor $messageAccessor): void + { + $this->messageAccessor = $messageAccessor; + } + public function toString(MessageInterface $message, array $replace): string { $filteredMessage = $message instanceof Request ? $this->messageAccessor->filterRequest($message) : $this->messageAccessor->filterMessage($message); 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