From 333e39ad7021b6dcc7174095c3b86f7f91b93386 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Tue, 3 Mar 2026 12:41:06 +0100 Subject: [PATCH] Add config-driven body field redaction for request and response Add redact.request_body and redact.response_body config keys (REQUEST_LOG_REDACT_REQUEST_BODY / REQUEST_LOG_REDACT_RESPONSE_BODY env variables) so body fields can be masked without relying on the X-SENSITIVE-REQUEST-BODY-JSON header. --- publishable/config/request-log.php | 4 +- src/RequestLog/Middleware/LogRequest.php | 2 +- src/RequestLog/Utilities/SecurityUtility.php | 32 +++++-- tests/Unit/LogRequestTest.php | 89 ++++++++++++++++++++ 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/publishable/config/request-log.php b/publishable/config/request-log.php index 4cef966..d91ab95 100644 --- a/publishable/config/request-log.php +++ b/publishable/config/request-log.php @@ -38,7 +38,9 @@ 'cegosso', 'refresh_token', 'access_token', - ] + ], + 'request_body' => array_filter(explode(',', env('REQUEST_LOG_REDACT_REQUEST_BODY', ''))), + 'response_body' => array_filter(explode(',', env('REQUEST_LOG_REDACT_RESPONSE_BODY', ''))), ], /* diff --git a/src/RequestLog/Middleware/LogRequest.php b/src/RequestLog/Middleware/LogRequest.php index 6b8039f..6195b3f 100644 --- a/src/RequestLog/Middleware/LogRequest.php +++ b/src/RequestLog/Middleware/LogRequest.php @@ -127,7 +127,7 @@ private function logRequest(Request $request, Response $response): void status: $response->getStatusCode(), responseHeaders: $responseHeaders, responseCookies: SecurityUtility::getResponseCookiesWithMaskingApplied($response->headers->getCookies(), $request), - responseBody: $this->truncate($response->getContent() ?: '{}', $truncateBodyLength), + responseBody: $this->truncate(SecurityUtility::getResponseBodyWithMaskingApplied($response->getContent() ?: '{}', $request->isJson()), $truncateBodyLength), responseException: $response->exception ?? null, executionTimeNs: $executionTimeNs ))->log(); diff --git a/src/RequestLog/Utilities/SecurityUtility.php b/src/RequestLog/Utilities/SecurityUtility.php index 603489e..2fcc398 100644 --- a/src/RequestLog/Utilities/SecurityUtility.php +++ b/src/RequestLog/Utilities/SecurityUtility.php @@ -75,16 +75,38 @@ public static function getHeadersWithMaskingApplied(Request $request): array */ public static function getBodyWithMaskingApplied(Request $request): ?string { - if ( ! $request->hasHeader('X-SENSITIVE-REQUEST-BODY-JSON') || ! $request->isJson()) { - // If the request is not JSON, getContent(), which is what we log as request body, is always empty + $sensitiveBodyHeaderIn = $request->header('X-SENSITIVE-REQUEST-BODY-JSON'); + $sensitiveBodyFields = $sensitiveBodyHeaderIn ? json_decode($sensitiveBodyHeaderIn) : []; + $redactedBodyFields = Config::get('request-log.redact.request_body', []); + + $fieldsToMask = collect($sensitiveBodyFields)->concat($redactedBodyFields); + + if ($fieldsToMask->isEmpty() || ! $request->isJson()) { return $request->getContent(); } - $sensitiveBodyFields = json_decode($request->header('X-SENSITIVE-REQUEST-BODY-JSON')); - $data = json_decode($request->getContent(), true); - foreach ($sensitiveBodyFields as $field) { + foreach ($fieldsToMask as $field) { + if (Arr::has($data, $field)) { + Arr::set($data, $field, '[ MASKED ]'); + } + } + + return json_encode($data); + } + + public static function getResponseBodyWithMaskingApplied(string $responseContent, bool $isJson): string + { + $redactedBodyFields = collect(Config::get('request-log.redact.response_body', [])); + + if ($redactedBodyFields->isEmpty() || ! $isJson) { + return $responseContent; + } + + $data = json_decode($responseContent, true); + + foreach ($redactedBodyFields as $field) { if (Arr::has($data, $field)) { Arr::set($data, $field, '[ MASKED ]'); } diff --git a/tests/Unit/LogRequestTest.php b/tests/Unit/LogRequestTest.php index a1b1f5b..f3c23a1 100644 --- a/tests/Unit/LogRequestTest.php +++ b/tests/Unit/LogRequestTest.php @@ -151,6 +151,95 @@ public function test_it_masks_request_body() $this->postJson('/test', $data, $headers); } + public function test_it_masks_request_body_from_config() + { + // Arrange + Config::set('request-log.redact.request_body', ['password', 'person.sensitive_data']); + + $loggerMock = Log::partialMock(); + Log::setApplication($this->app); + + $loggerMock->shouldReceive('debug')->once()->andReturnUsing(function ($message, $context) { + $loggedBody = json_decode($context['http']['request']['body']['content'], true); + $this->assertEquals('[ MASKED ]', $loggedBody['password']); + $this->assertEquals('[ MASKED ]', $loggedBody['person']['sensitive_data']); + $this->assertEquals('not secret', $loggedBody['person']['insensitive_data']); + }); + + $data = [ + 'password' => '12345678', + 'person' => [ + 'sensitive_data' => 'secret', + 'insensitive_data' => 'not secret', + ], + ]; + + // Act + $this->postJson('/test', $data); + } + + public function test_it_merges_config_and_header_body_masking() + { + // Arrange + Config::set('request-log.redact.request_body', ['password']); + + $loggerMock = Log::partialMock(); + Log::setApplication($this->app); + + $loggerMock->shouldReceive('debug')->once()->andReturnUsing(function ($message, $context) { + $loggedBody = json_decode($context['http']['request']['body']['content'], true); + $this->assertEquals('[ MASKED ]', $loggedBody['password']); + $this->assertEquals('[ MASKED ]', $loggedBody['person']['sensitive_data']); + $this->assertEquals('not secret', $loggedBody['person']['insensitive_data']); + }); + + $data = [ + 'password' => '12345678', + 'person' => [ + 'sensitive_data' => 'secret', + 'insensitive_data' => 'not secret', + ], + ]; + + $headers = [ + 'X-SENSITIVE-REQUEST-BODY-JSON' => json_encode(['person.sensitive_data']), + ]; + + // Act + $this->postJson('/test', $data, $headers); + } + + public function test_it_masks_response_body_from_config() + { + // Arrange + Config::set('request-log.redact.response_body', ['token', 'user.ssn']); + + $loggerMock = Log::partialMock(); + Log::setApplication($this->app); + + $loggerMock->shouldReceive('debug')->once()->andReturnUsing(function ($message, $context) { + $loggedBody = json_decode($context['http']['response']['body']['content'], true); + $this->assertEquals('[ MASKED ]', $loggedBody['token']); + $this->assertEquals('[ MASKED ]', $loggedBody['user']['ssn']); + $this->assertEquals('John', $loggedBody['user']['name']); + }); + + $middleware = new LogRequest(); + + $request = new Request(server: ['HTTP_CONTENT_TYPE' => 'application/json']); + + $response = new Response(); + $response->setContent(json_encode([ + 'token' => 'secret-token', + 'user' => [ + 'name' => 'John', + 'ssn' => '123-45-6789', + ], + ])); + + $middleware->terminate($request, $response); + } + public function test_it_tests() { // Arrange