Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mask sensitive data in logs with the MessageAccessor #35

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions config/http-client-logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/HttpLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
9 changes: 9 additions & 0 deletions src/LaravelHttpClientLoggerServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', []),
);
});
}
}
26 changes: 13 additions & 13 deletions src/MessageAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -104,7 +104,7 @@ public function getJson(MessageInterface $message): ?array
json_decode($message->getBody()->__toString(), true),
$this->jsonFilters,
$this->values,
$this->replace
$this->placeholder
);
}

Expand All @@ -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);
}
}

Expand Down
18 changes: 16 additions & 2 deletions src/PsrMessageToStringConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/HttpLoggerE2eTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace Bilfeldt\LaravelHttpClientLogger\Tests;

use Bilfeldt\LaravelHttpClientLogger\HttpLogger;
use Bilfeldt\LaravelHttpClientLogger\MessageAccessor;
use Bilfeldt\LaravelHttpClientLogger\PsrMessageToStringConverter;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Psr\Http\Message\MessageInterface;
use TiMacDonald\Log\LogEntry;
use TiMacDonald\Log\LogFake;

class HttpLoggerE2eTest extends TestCase
{
protected HttpLogger $logger;

public function setUp(): void
{
parent::setUp();

Http::preventStrayRequests();
Http::fake([
'https://api.example.com/login?username=SECRET_USER&pass=SECRET_PASSWORD' => 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";
}
}
3 changes: 2 additions & 1 deletion tests/HttpLoggerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
}

Expand Down
88 changes: 88 additions & 0 deletions tests/PsrMessageToStringConverterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace Bilfeldt\LaravelHttpClientLogger\Tests;

use Bilfeldt\LaravelHttpClientLogger\MessageAccessor;
use Bilfeldt\LaravelHttpClientLogger\PsrMessageToStringConverter;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\RequestInterface;

class PsrMessageToStringConverterTest extends TestCase
{
public PsrMessageToStringConverter $converter;

public RequestInterface $request;

public function setUp(): void
{
parent::setUp();

$messageAccessor = new MessageAccessor(
['data.baz.*.password'],
['search', 'filter.field2'],
['Authorization'],
['secret'],
);

$this->converter = new PsrMessageToStringConverter($messageAccessor);

$this->request = new Request(
'POST',
'https://user:[email protected]: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'
);
}
}