From 67115751f4424c06d18afbe7b13dd762dca3386e Mon Sep 17 00:00:00 2001
From: Edie Lemoine <edie@myparcel.nl>
Date: Fri, 22 Nov 2024 13:29:50 +0100
Subject: [PATCH] feat(logging): obfuscate authorization header in logs

---
 src/Api/Service/AbstractApiService.php        | 35 ++++++++++---
 .../Api/Service/AbstractApiServiceTest.php    | 51 +++++++++++++++++++
 2 files changed, 78 insertions(+), 8 deletions(-)

diff --git a/src/Api/Service/AbstractApiService.php b/src/Api/Service/AbstractApiService.php
index bd064899c..c54a3cfd0 100644
--- a/src/Api/Service/AbstractApiService.php
+++ b/src/Api/Service/AbstractApiService.php
@@ -54,14 +54,7 @@ public function doRequest(
             'body'    => $request->getBody(),
         ];
 
-        $logContext = [
-            'request' => [
-                'uri'     => $uri,
-                'method'  => $method,
-                'headers' => $options['headers'],
-                'body'    => $options['body'] ? json_decode($options['body'], true) : null,
-            ],
-        ];
+        $logContext = $this->createLogContext($uri, $method, $options);
 
         try {
             $response = $this->clientAdapter->doRequest($method, $uri, $options);
@@ -128,4 +121,30 @@ protected function buildUri(RequestInterface $request): string
 
         return $url;
     }
+
+    /**
+     * @param  string $uri
+     * @param  string $method
+     * @param  array  $options
+     *
+     * @return array[]
+     */
+    private function createLogContext(string $uri, string $method, array $options): array
+    {
+        $headers = array_combine(array_map('strtolower', array_keys($options['headers'])), $options['headers']);
+
+        // Obfuscate the authorization header if present
+        if (isset($headers['authorization'])) {
+            $headers['authorization'] = '***';
+        }
+
+        return [
+            'request' => [
+                'uri'     => $uri,
+                'method'  => $method,
+                'headers' => $headers,
+                'body'    => $options['body'] ? json_decode($options['body'], true) : null,
+            ],
+        ];
+    }
 }
diff --git a/tests/Unit/Api/Service/AbstractApiServiceTest.php b/tests/Unit/Api/Service/AbstractApiServiceTest.php
index 443843faa..bfb9725fb 100644
--- a/tests/Unit/Api/Service/AbstractApiServiceTest.php
+++ b/tests/Unit/Api/Service/AbstractApiServiceTest.php
@@ -6,9 +6,12 @@
 namespace MyParcelNL\Pdk\Api\Service;
 
 use MyParcelNL\Pdk\Account\Repository\ShopRepository;
+use MyParcelNL\Pdk\Api\Contract\ApiServiceInterface;
 use MyParcelNL\Pdk\Api\Exception\ApiException;
+use MyParcelNL\Pdk\Api\Request\Request;
 use MyParcelNL\Pdk\Base\Support\Collection;
 use MyParcelNL\Pdk\Facade\Pdk;
+use MyParcelNL\Pdk\Logger\Contract\PdkLoggerInterface;
 use MyParcelNL\Pdk\Shipment\Model\Shipment;
 use MyParcelNL\Pdk\Shipment\Repository\ShipmentRepository;
 use MyParcelNL\Pdk\Tests\Api\Response\ExampleErrorNotFoundResponse;
@@ -16,7 +19,11 @@
 use MyParcelNL\Pdk\Tests\Api\Response\ExampleErrorUnprocessableEntityResponse;
 use MyParcelNL\Pdk\Tests\Api\Response\ExampleGetShipmentsResponse;
 use MyParcelNL\Pdk\Tests\Bootstrap\MockApi;
+use MyParcelNL\Pdk\Tests\Bootstrap\TestBootstrapper;
+use MyParcelNL\Pdk\Tests\Mocks\MockApiResponse;
 use MyParcelNL\Pdk\Tests\Uses\UsesMockPdkInstance;
+use MyParcelNL\Sdk\src\Support\Arr;
+use Psr\Log\LogLevel;
 use function MyParcelNL\Pdk\Tests\usesShared;
 
 usesShared(new UsesMockPdkInstance());
@@ -49,3 +56,47 @@
         ->and($shipments->first())
         ->toBeInstanceOf(Shipment::class);
 });
+
+it('creates log context with obfuscated authorization header', function () {
+    TestBootstrapper::hasApiKey();
+
+    MockApi::enqueue(new ExampleGetShipmentsResponse());
+
+    /** @var \MyParcelNL\Pdk\Tests\Bootstrap\MockLogger $logger */
+    $logger = Pdk::get(PdkLoggerInterface::class);
+
+    /** @var \MyParcelNL\Pdk\Api\Contract\ApiServiceInterface $apiService */
+    $apiService = Pdk::get(ApiServiceInterface::class);
+
+    $request = new Request([
+        'headers'    => [
+            'Authorization' => 'bearer this-is-some-fake-value',
+            'Content-Type'  => 'application/json',
+        ],
+        'uri'        => 'test',
+        'method'     => 'POST',
+        'parameters' => [],
+        'body'       => json_encode(['test' => 'test']),
+    ]);
+
+    $apiService->doRequest($request, MockApiResponse::class);
+
+    $lastLog = Arr::last($logger->getLogs());
+
+    expect($lastLog['level'])
+        ->toBe(LogLevel::DEBUG)
+        ->and($lastLog['message'])
+        ->toBe('[PDK]: Successfully sent request')
+        ->and(array_keys($lastLog['context']))
+        ->toEqual(['request', 'response'])
+        ->and(array_keys($lastLog['context']['request']))
+        ->toEqual(['uri', 'method', 'headers', 'body'])
+        ->and(array_keys($lastLog['context']['response']))
+        ->toEqual(['code', 'body'])
+        // Expect header keys to be normalized and authorization header to be hidden
+        ->and($lastLog['context']['request']['headers'])
+        ->toBe([
+            'authorization' => '***',
+            'content-type'  => 'application/json',
+        ]);
+});