Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
54 / 56
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayloadSanitizer
96.36% covered (success)
96.36%
53 / 55
77.78% covered (warning)
77.78%
7 / 9
31
0.00% covered (danger)
0.00%
0 / 1
 sanitizeHeaders
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 sanitizePayload
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 sanitizeScalarPayload
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 sanitizeArrayToJson
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sanitizeArray
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 isSensitiveKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 maskHeaderValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 truncate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sanitizeUri
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api\Domain\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11/**
12 * OWASP-compliant sensitive payload & header sanitizer for API logging.
13 *
14 * Masks credentials, bearer tokens, cookies, PII, and financial keys from logs
15 * adhering to OWASP ASVS V7.1 & NIST SP 800-92 standards.
16 *
17 * @package App\Core\Api\Domain\Service
18 */
19final class PayloadSanitizer
20{
21    /** @var list<string> Sensitive header names to sanitize (lowercase). */
22    private const array SENSITIVE_HEADERS = [
23        'authorization',
24        'proxy-authorization',
25        'cookie',
26        'set-cookie',
27        'x-api-key',
28        'x-auth-token',
29        'x-csrf-token',
30        'php-auth-pw',
31    ];
32
33    /** @var list<string> Sensitive parameter / field key patterns (lowercase). */
34    private const array SENSITIVE_KEYS = [
35        'password',
36        'passwd',
37        'current_password',
38        'new_password',
39        'confirm_password',
40        'token',
41        'access_token',
42        'refresh_token',
43        'api_token',
44        'api_key',
45        'apikey',
46        'secret',
47        'client_secret',
48        'private_key',
49        'pin',
50        'cvv',
51        'credit_card',
52        'card_number',
53        'pan',
54        'pesel',
55        'ssn',
56    ];
57
58    /** @var int Maximum characters stored for a payload string before truncation (64 KB). */
59    public const int MAX_PAYLOAD_BYTES = 65536;
60
61    /**
62     * Sanitizes an associative array of HTTP headers for logging.
63     *
64     * @param array<string, list<string>|string> $headers Raw HTTP headers.
65     * @return array<string, string> Sanitized header map.
66     */
67    public function sanitizeHeaders(array $headers): array
68    {
69        $sanitized = [];
70        foreach ($headers as $name => $values) {
71            $lowerName = strtolower($name);
72            $joinedValue = is_array($values) ? implode(', ', $values) : (string) $values;
73
74            if (in_array($lowerName, self::SENSITIVE_HEADERS, true)) {
75                $sanitized[$name] = $this->maskHeaderValue($lowerName, $joinedValue);
76            } else {
77                $sanitized[$name] = $joinedValue;
78            }
79        }
80
81        return $sanitized;
82    }
83
84    /**
85     * Sanitizes a raw string or decoded array payload (JSON/form-data).
86     *
87     * @param mixed $payload Raw payload string or array.
88     * @return string Sanitized JSON or truncated string safe for logging.
89     */
90    public function sanitizePayload(mixed $payload): string
91    {
92        if ($payload === null || $payload === '') {
93            return '';
94        }
95
96        if (is_array($payload)) {
97            return $this->sanitizeArrayToJson($payload);
98        }
99
100        return $this->sanitizeScalarPayload($payload);
101    }
102
103    /**
104     * Sanitizes scalar or string JSON payload.
105     */
106    private function sanitizeScalarPayload(mixed $payload): string
107    {
108        if (is_string($payload)) {
109            $trimmed = trim($payload);
110            $decoded = json_decode($trimmed, true);
111            if (is_array($decoded)) {
112                return $this->sanitizeArrayToJson($decoded);
113            }
114
115            return $this->truncate($trimmed);
116        }
117
118        return $this->truncate((string) json_encode($payload));
119    }
120
121    /**
122     * Masks sensitive array keys and encodes to JSON with truncation.
123     *
124     * @param array<string|int, mixed> $payload
125     */
126    private function sanitizeArrayToJson(array $payload): string
127    {
128        $sanitizedArray = $this->sanitizeArray($payload);
129        $json = (string) json_encode($sanitizedArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
130
131        return $this->truncate($json);
132    }
133
134    /**
135     * Recursively masks sensitive keys in an array.
136     *
137     * @param array<string|int, mixed> $data Input array.
138     * @return array<string|int, mixed> Redacted array.
139     */
140    private function sanitizeArray(array $data): array
141    {
142        $result = [];
143        foreach ($data as $key => $value) {
144            $isSensitive = is_string($key) && $this->isSensitiveKey($key);
145
146            if ($isSensitive) {
147                $result[$key] = '[REDACTED]';
148            } elseif (is_array($value)) {
149                $result[$key] = $this->sanitizeArray($value);
150            } else {
151                $result[$key] = $value;
152            }
153        }
154
155        return $result;
156    }
157
158    /**
159     * Checks whether a field name matches sensitive key patterns.
160     *
161     * @param string $key Field key name.
162     * @return bool True if sensitive.
163     */
164    private function isSensitiveKey(string $key): bool
165    {
166        $lower = strtolower($key);
167        foreach (self::SENSITIVE_KEYS as $sensitive) {
168            if ($lower === $sensitive || str_contains($lower, $sensitive)) {
169                return true;
170            }
171        }
172
173        return false;
174    }
175
176    /**
177     * Masks specific sensitive header values (e.g. partial Bearer tokens).
178     *
179     * @param string $lowerName Lowercase header name.
180     * @param string $value     Header value.
181     * @return string Masked header value.
182     */
183    private function maskHeaderValue(string $lowerName, string $value): string
184    {
185        if ($lowerName === 'authorization' && str_starts_with(strtolower($value), 'bearer ')) {
186            $token = substr($value, 7);
187            $len = strlen($token);
188            if ($len > 8) {
189                return 'Bearer ' . substr($token, 0, 4) . '...' . substr($token, -4);
190            }
191            return 'Bearer [REDACTED]';
192        }
193
194        return '[REDACTED]';
195    }
196
197    /**
198     * Truncates string to maximum allowed log storage size.
199     *
200     * @param string $value Input string.
201     * @return string Truncated string if over limit.
202     */
203    private function truncate(string $value): string
204    {
205        if (strlen($value) > self::MAX_PAYLOAD_BYTES) {
206            return substr($value, 0, self::MAX_PAYLOAD_BYTES) . "\n... [TRUNCATED " . strlen($value) . " bytes]";
207        }
208
209        return $value;
210    }
211
212    /**
213     * Sanitizes query parameters in a URI string by masking sensitive keys.
214     *
215     * @param string $uri Full or relative URI.
216     * @return string Sanitized URI string.
217     */
218    public function sanitizeUri(string $uri): string
219    {
220        $parts = explode('?', $uri, 2);
221        if (count($parts) < 2 || $parts[1] === '') {
222            return $uri;
223        }
224
225        parse_str($parts[1], $queryParams);
226        if (empty($queryParams)) {
227            return $uri;
228        }
229
230        $sanitizedParams = $this->sanitizeArray($queryParams);
231        return $parts[0] . '?' . http_build_query($sanitizedParams);
232    }
233}