Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.03% covered (success)
99.03%
102 / 103
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
RequestHandler
99.03% covered (success)
99.03%
102 / 103
93.75% covered (success)
93.75%
15 / 16
35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setRequest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 request
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRedactSensitiveData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isRedactingSensitiveData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRedactedKeys
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addRedactedKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRedactedKeys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
4
 redactKeys
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 redactAll
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 isRedactedKey
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 prepareMessage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 log
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Debug\Handler;
16
17use Pop\Http\Server\Request;
18use Pop\Http\Uri;
19use Pop\Session\Session;
20use Psr\Log\LoggerInterface;
21
22/**
23 * Debug request handler class
24 *
25 * @category   Pop
26 * @package    Pop\Debug
27 * @author     Nick Sagona, III <dev@noladev.com>
28 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    4.0.0
31 */
32class RequestHandler extends AbstractHandler
33{
34
35    /**
36     * Request
37     * @var ?Request
38     */
39    protected ?Request $request = null;
40
41    /**
42     * Default keys (case-insensitive, separator-insensitive substring match) whose values get redacted
43     * @var array
44     */
45    protected const array DEFAULT_REDACTED_KEYS = [
46        'pass', 'pwd', 'secret', 'token', 'apikey', 'accesstoken', 'refreshtoken',
47        'clientsecret', 'privatekey', 'authorization', 'auth', 'cookie', 'csrf',
48        'xsrf', 'sessionid', 'creditcard', 'cardnumber', 'cvv', 'cvc', 'ssn', 'pin',
49    ];
50
51    /**
52     * Value substituted in for anything matched by $redactedKeys or held in $_COOKIE/$_SESSION
53     * @var string
54     */
55    protected const string REDACTED_VALUE = '[REDACTED]';
56
57    /**
58     * Whether to redact sensitive request data (headers, server/env vars, post/put/patch/parsed
59     * data matching $redactedKeys, plus the entirety of $_COOKIE and $_SESSION) before it is
60     * returned from prepare() and, in turn, logged or written to storage. Defaults to true so
61     * secrets aren't captured in plaintext by default.
62     * @var bool
63     */
64    protected bool $redactSensitiveData = true;
65
66    /**
67     * Keys whose values are redacted when $redactSensitiveData is true
68     * @var array
69     */
70    protected array $redactedKeys = self::DEFAULT_REDACTED_KEYS;
71
72    /**
73     * Cached, normalized (lowercased, non-alphanumeric stripped) version of $redactedKeys,
74     * rebuilt lazily on next use whenever $redactedKeys changes
75     * @var ?array
76     */
77    protected ?array $normalizedRedactedKeys = null;
78
79    /**
80     * Constructor
81     *
82     * Instantiate a request handler object
83     *
84     * @param ?Request         $request
85     * @param ?string          $name
86     * @param ?LoggerInterface $logger
87     * @param array            $loggingParams
88     */
89    public function __construct(?Request $request = null, ?string $name = null, ?LoggerInterface $logger = null, array $loggingParams = [])
90    {
91        parent::__construct($name, $logger, $loggingParams);
92        if ($request === null) {
93            $request = new Request(new Uri());
94        }
95        $this->setRequest($request);
96    }
97
98    /**
99     * Set request
100     *
101     * @param  Request $request
102     * @return RequestHandler
103     */
104    public function setRequest(Request $request): RequestHandler
105    {
106        $this->request = $request;
107        return $this;
108    }
109
110    /**
111     * Get request
112     *
113     * @return Request
114     */
115    public function getRequest(): Request
116    {
117        return $this->request;
118    }
119
120    /**
121     * Get request (alias)
122     *
123     * @return Request
124     */
125    public function request(): Request
126    {
127        return $this->request;
128    }
129
130    /**
131     * Has request
132     *
133     * @return bool
134     */
135    public function hasRequest(): bool
136    {
137        return ($this->request !== null);
138    }
139
140    /**
141     * Set whether to redact sensitive request data before it's returned from prepare()
142     *
143     * @param  bool $redact
144     * @return RequestHandler
145     */
146    public function setRedactSensitiveData(bool $redact = true): RequestHandler
147    {
148        $this->redactSensitiveData = $redact;
149        return $this;
150    }
151
152    /**
153     * Determine if sensitive request data is being redacted
154     *
155     * @return bool
156     */
157    public function isRedactingSensitiveData(): bool
158    {
159        return $this->redactSensitiveData;
160    }
161
162    /**
163     * Set the keys (case-insensitive, separator-insensitive substring match) whose values get redacted
164     *
165     * @param  array $keys
166     * @return RequestHandler
167     */
168    public function setRedactedKeys(array $keys): RequestHandler
169    {
170        $this->redactedKeys           = $keys;
171        $this->normalizedRedactedKeys = null;
172        return $this;
173    }
174
175    /**
176     * Add a key whose value should be redacted
177     *
178     * @param  string $key
179     * @return RequestHandler
180     */
181    public function addRedactedKey(string $key): RequestHandler
182    {
183        $this->redactedKeys[]         = $key;
184        $this->normalizedRedactedKeys = null;
185        return $this;
186    }
187
188    /**
189     * Get the keys whose values get redacted
190     *
191     * @return array
192     */
193    public function getRedactedKeys(): array
194    {
195        return $this->redactedKeys;
196    }
197
198    /**
199     * Prepare handler data for storage
200     *
201     * @return array
202     */
203    public function prepare(): array
204    {
205        Session::getInstance();
206
207        if (!$this->hasEnd()) {
208            $this->setEnd();
209        }
210
211        $headers = $this->request->getHeaders();
212        $server  = $this->request->getServer();
213        $env     = $this->request->getEnv();
214        $get     = $this->request->getQuery();
215        $post    = $this->request->getPost();
216        $put     = $this->request->getPut();
217        $patch   = $this->request->getPatch();
218        $delete  = $this->request->getDelete();
219        $cookie  = $_COOKIE;
220        $session = (isset($_SESSION)) ? $_SESSION : [];
221        $parsed  = $this->request->getParsedData();
222
223        if ($this->redactSensitiveData) {
224            $headers = $this->redactKeys($headers);
225            $server  = $this->redactKeys($server);
226            $env     = $this->redactKeys($env);
227            $get     = $this->redactKeys($get);
228            $post    = $this->redactKeys($post);
229            $put     = $this->redactKeys($put);
230            $patch   = $this->redactKeys($patch);
231            $delete  = $this->redactKeys($delete);
232            $cookie  = $this->redactAll($cookie);
233            $session = $this->redactAll($session);
234            $parsed  = $this->redactKeys($parsed);
235        }
236
237        return [
238            'uri'     => $this->request->getUri()->getUri(),
239            'method'  => $this->request->getMethod(),
240            'headers' => $headers,
241            'server'  => $server,
242            'env'     => $env,
243            'get'     => $get,
244            'post'    => $post,
245            'put'     => $put,
246            'patch'   => $patch,
247            'delete'  => $delete,
248            'files'   => $this->request->getFiles(),
249            'cookie'  => $cookie,
250            'session' => $session,
251            'raw'     => $this->request->getRawData(),
252            'parsed'  => $parsed,
253        ];
254    }
255
256    /**
257     * Recursively redact array values whose key matches one of $redactedKeys
258     *
259     * @param  mixed $data
260     * @return mixed
261     */
262    protected function redactKeys(mixed $data): mixed
263    {
264        if (!is_array($data)) {
265            return $data;
266        }
267
268        foreach ($data as $key => $value) {
269            if ($this->isRedactedKey((string)$key)) {
270                $data[$key] = self::REDACTED_VALUE;
271            } else if (is_array($value)) {
272                $data[$key] = $this->redactKeys($value);
273            }
274        }
275
276        return $data;
277    }
278
279    /**
280     * Redact every value in a flat array, regardless of key (used for $_COOKIE/$_SESSION,
281     * whose contents are treated as sensitive-by-nature)
282     *
283     * @param  mixed $data
284     * @return mixed
285     */
286    protected function redactAll(mixed $data): mixed
287    {
288        if (!is_array($data)) {
289            return $data;
290        }
291
292        foreach ($data as $key => $value) {
293            $data[$key] = self::REDACTED_VALUE;
294        }
295
296        return $data;
297    }
298
299    /**
300     * Determine if a key matches one of $redactedKeys (case- and separator-insensitive)
301     *
302     * @param  string $key
303     * @return bool
304     */
305    protected function isRedactedKey(string $key): bool
306    {
307        if ($this->normalizedRedactedKeys === null) {
308            $this->normalizedRedactedKeys = [];
309            foreach ($this->redactedKeys as $redactedKey) {
310                $normalizedRedactedKey = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', (string)$redactedKey));
311                if ($normalizedRedactedKey !== '') {
312                    $this->normalizedRedactedKeys[] = $normalizedRedactedKey;
313                }
314            }
315        }
316
317        $normalizedKey = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $key));
318
319        foreach ($this->normalizedRedactedKeys as $normalizedRedactedKey) {
320            if (str_contains($normalizedKey, $normalizedRedactedKey)) {
321                return true;
322            }
323        }
324
325        return false;
326    }
327
328    /**
329     * Prepare handler message
330     *
331     * @param  ?array $context
332     * @return string
333     */
334    public function prepareMessage(?array $context = null): string
335    {
336        return (!empty($this->request)) ?
337            "The HTTP request '" .  $this->request->getUri()->getUri() . "' was received." :
338            "An HTTP request was received.";
339    }
340
341    /**
342     * Trigger handler logging
343     *
344     * @throws Exception
345     * @return void
346     */
347    public function log(): void
348    {
349        $logLevel = $this->resolveLogLevel();
350        if ($logLevel === null) {
351            return;
352        }
353
354        $timeLimit = $this->loggingParams['limit'] ?? null;
355        $context   = $this->prepare();
356
357        if ($timeLimit !== null) {
358            $elapsedTime = $this->getElapsed();
359            if ($elapsedTime >= $timeLimit) {
360                $this->logger->log($logLevel, 'The request \'' . $this->request->getUri()->getUri() .
361                    '\' has exceeded the time limit of ' . $timeLimit . ' second(s) by ' .
362                    $elapsedTime - $timeLimit . ' second(s). The request was a total of ' . $elapsedTime . ' second(s).',
363                    $context
364                );
365            }
366        } else {
367            $this->logger->log($logLevel, $this->prepareMessage(), $context);
368        }
369    }
370
371}