Issues (8)

Security Analysis    no request data  

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Header Injection
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

src/ChromeLogger.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace Kodus\Logging;
4
5
use DateTimeInterface;
6
use Error;
7
use Exception;
8
use function file_put_contents;
9
use InvalidArgumentException;
10
use JsonSerializable;
11
use Psr\Http\Message\ResponseInterface;
12
use Psr\Log\AbstractLogger;
13
use Psr\Log\LoggerInterface;
14
use Psr\Log\LogLevel;
15
use ReflectionClass;
16
use ReflectionProperty;
17
use RuntimeException;
18
use Throwable;
19
20
/**
21
 * PSR-3 and PSR-7 compliant alternative to the original ChromeLogger by Craig Campbell.
22
 *
23
 * @link https://craig.is/writing/chrome-logger
24
 */
25
class ChromeLogger extends AbstractLogger implements LoggerInterface
26
{
27
    const VERSION = "4.1.0";
28
29
    const COLUMN_LOG       = "log";
30
    const COLUMN_BACKTRACE = "backtrace";
31
    const COLUMN_TYPE      = "type";
32
33
    const CLASS_NAME  = "type";
34
    const TABLES      = "tables";
35
    const HEADER_NAME = "X-ChromeLogger-Data";
36
    const LOCATION_HEADER_NAME = "X-ServerLog-Location";
37
38
    const LOG   = "log";
39
    const WARN  = "warn";
40
    const ERROR = "error";
41
    const INFO  = "info";
42
43
    const GROUP           = "group";
44
    const GROUP_END       = "groupEnd";
45
    const GROUP_COLLAPSED = "groupCollapsed";
46
    const TABLE           = "table";
47
48
    const DATETIME_FORMAT = "Y-m-d\\TH:i:s\\Z"; // ISO-8601 UTC date/time format
49
50
    const LIMIT_WARNING = "Beginning of log entries omitted - total header size over Chrome's internal limit!";
51
52
    /**
53
     * @var int header size limit (in bytes, defaults to 240KB)
54
     */
55
    protected $limit = 245760;
56
57
    /**
58
     * @var LogEntry[]
59
     */
60
    protected $entries = [];
61
62
    /**
63
     * @var string|null
64
     */
65
    private $local_path;
66
67
    /**
68
     * @var string|null
69
     */
70
    private $public_path;
71
72
    /**
73
     * Logs with an arbitrary level.
74
     *
75
     * @param mixed  $level
76
     * @param string $message
77
     * @param array  $context
78
     *
79
     * @return void
80
     */
81
    public function log($level, $message, array $context = [])
82
    {
83
        $this->entries[] = new LogEntry($level, $message, $context);
84
    }
85
86
    /**
87
     * Allows you to override the internal 240 KB header size limit.
88
     *
89
     * (Chrome has a 250 KB limit for the total size of all headers.)
90
     *
91
     * @see https://cs.chromium.org/chromium/src/net/http/http_stream_parser.h?q=ERR_RESPONSE_HEADERS_TOO_BIG&sq=package:chromium&dr=C&l=159
92
     *
93
     * @param int $limit header size limit (in bytes)
94
     */
95
    public function setLimit($limit)
96
    {
97
        $this->limit = $limit;
98
    }
99
100
    /**
101
     * @return int header size limit (in bytes)
102
     */
103
    public function getLimit()
104
    {
105
        return $this->limit;
106
    }
107
108
    /**
109
     * Enables persistence to local log-files served from your web-root.
110
     *
111
     * Bypasses the header-size limitation (imposed by Chrome, NGINX, etc.) by avoiding the
112
     * large `X-ChromeLogger-Data` header and instead storing the log in a flat file.
113
     *
114
     * Requires the [ServerLog](https://github.com/mindplay-dk/server-log) Chrome extension,
115
     * which replaces the ChromeLogger extension - this does NOT work with the regular
116
     * ChromeLogger extension.
117
     *
118
     * @link https://github.com/mindplay-dk/server-log
119
     *
120
     * @param string $local_path  absolute local path to a dedicated log-folder in your public web-root,
121
     *                            e.g. "/var/www/mysite.com/webroot/log"
122
     * @param string $public_path absolute public path, e.g. "/log"
123
     */
124
    public function usePersistence(string $local_path, string $public_path)
125
    {
126
        if (! is_dir($local_path)) {
127
            throw new InvalidArgumentException("local path does not exist: {$local_path}");
128
        }
129
130
        $this->local_path = $local_path;
131
        $this->public_path = $public_path;
132
    }
133
134
    /**
135
     * Adds headers for recorded log-entries in the ChromeLogger format, and clear the internal log-buffer.
136
     *
137
     * (You should call this at the end of the request/response cycle in your PSR-7 project, e.g.
138
     * immediately before emitting the Response.)
139
     *
140
     * @param ResponseInterface $response
141
     *
142
     * @return ResponseInterface
143
     */
144
    public function writeToResponse(ResponseInterface $response)
145
    {
146
        if (count($this->entries)) {
147
            if ($this->local_path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->local_path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
148
                $response = $response->withAddedHeader(self::LOCATION_HEADER_NAME, $this->createLogFile());
149
            } else {
150
                $response = $response->withHeader(self::HEADER_NAME, $this->getHeaderValue());
151
            }
152
        }
153
154
        $this->entries = [];
155
156
        return $response;
157
    }
158
159
    /**
160
     * Emit the header for recorded log-entries directly using `header()`, and clear the internal buffer.
161
     *
162
     * (You can use this in a non-PSR-7 project, immediately before you start emitting the response body.)
163
     *
164
     * @throws RuntimeException if you've already started emitting the response body
165
     *
166
     * @return void
167
     */
168
    public function emitHeader()
169
    {
170
        if (count($this->entries)) {
171
            if (headers_sent()) {
172
                throw new RuntimeException("unable to emit ChromeLogger header: headers have already been sent");
173
            }
174
175
            if ($this->local_path) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->local_path of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
176
                header(self::LOCATION_HEADER_NAME . ": " . $this->createLogFile());
177
            } else {
178
                header(self::HEADER_NAME . ": " . $this->getHeaderValue());
179
            }
180
181
            $this->entries = [];
182
        }
183
    }
184
185
    /**
186
     * @return string raw value for the X-ChromeLogger-Data header
187
     */
188
    protected function getHeaderValue()
189
    {
190
        $data = $this->createData($this->entries);
191
192
        $value = $this->encodeData($data);
193
194
        if (strlen($value) > $this->limit) {
195
            $data["rows"][] = $this->createEntryData(new LogEntry(LogLevel::WARNING, self::LIMIT_WARNING))[0];
196
197
            // NOTE: the strategy here is to calculate an estimated overhead, based on the number
198
            //       of rows - because the size of each row may vary, this isn't necessarily accurate,
199
            //       so we may need repeat this more than once.
200
201
            while (strlen($value) > $this->limit) {
202
                $num_rows = count($data["rows"]); // current number of rows
203
204
                $row_size = strlen($value) / $num_rows; // average row-size
205
206
                $max_rows = (int) floor(($this->limit * 0.95) / $row_size); // 5% under the likely max. number of rows
207
208
                $excess = max(1, $num_rows - $max_rows); // remove at least 1 row
209
210
                $data["rows"] = array_slice($data["rows"], $excess); // remove excess rows
211
212
                $value = $this->encodeData($data); // encode again with fewer rows
213
            }
214
        }
215
216
        return $value;
217
    }
218
219
    /**
220
     * @return string public path to log-file
221
     */
222
    protected function createLogFile(): string
223
    {
224
        $this->collectGarbage();
225
226
        $content = json_encode(
227
            $this->createData($this->entries),
228
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
229
        );
230
231
        $filename = $this->createUniqueFilename();
232
233
        $local_path = "{$this->local_path}/{$filename}";
234
235
        if (@file_put_contents($local_path, $content) === false) {
236
            throw new RuntimeException("unable to write log-file: {$local_path}");
237
        }
238
239
        return "{$this->public_path}/{$filename}";
240
    }
241
242
    /**
243
     * @return string pseudo-random log filename
244
     */
245
    protected function createUniqueFilename(): string
246
    {
247
        return uniqid("log-", true) . ".json";
248
    }
249
250
    /**
251
     * Garbage-collects log-files older than one minute.
252
     */
253
    protected function collectGarbage()
254
    {
255
        foreach (glob("{$this->local_path}/*.json") as $path) {
256
            $age = $this->getTime() - filemtime($path);
257
258
            if ($age > 60) {
259
                @unlink($path);
260
            }
261
        }
262
    }
263
264
    /**
265
     * @return int
266
     */
267
    protected function getTime(): int
268
    {
269
        return time();
270
    }
271
272
    /**
273
     * Encodes the ChromeLogger-compatible data-structure in JSON/base64-format
274
     *
275
     * @param array $data header data
276
     *
277
     * @return string
278
     */
279
    protected function encodeData(array $data)
280
    {
281
        $json = json_encode(
282
            $data,
283
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
284
        );
285
286
        $value = base64_encode($json);
287
288
        return $value;
289
    }
290
291
    /**
292
     * Internally builds the ChromeLogger-compatible data-structure from internal log-entries.
293
     *
294
     * @param LogEntry[] $entries
295
     *
296
     * @return array
297
     */
298
    protected function createData(array $entries)
299
    {
300
        $rows = [];
301
302
        foreach ($entries as $entry) {
303
            foreach ($this->createEntryData($entry) as $row) {
304
                $rows[] = $row;
305
            }
306
        }
307
308
        return [
309
            "version" => self::VERSION,
310
            "columns" => [self::COLUMN_LOG, self::COLUMN_TYPE, self::COLUMN_BACKTRACE],
311
            "rows"    => $rows,
312
        ];
313
    }
314
315
    /**
316
     * Encode an individual LogEntry in ChromeLogger-compatible format
317
     *
318
     * @param LogEntry $entry
319
     *
320
     * @return array log-entries in ChromeLogger row-format
321
     */
322
    protected function createEntryData(LogEntry $entry)
323
    {
324
        // NOTE: "log" level type is deliberately omitted from the following map, since
325
        //       it's the default entry-type in ChromeLogger, and can be omitted.
326
327
        static $LEVELS = [
328
            LogLevel::DEBUG     => self::LOG,
329
            LogLevel::INFO      => self::INFO,
330
            LogLevel::NOTICE    => self::INFO,
331
            LogLevel::WARNING   => self::WARN,
332
            LogLevel::ERROR     => self::ERROR,
333
            LogLevel::CRITICAL  => self::ERROR,
334
            LogLevel::ALERT     => self::ERROR,
335
            LogLevel::EMERGENCY => self::ERROR,
336
        ];
337
338
        $rows = []; // all rows returned by this function
339
340
        $row = []; // the first row returned by this function
341
342
        $message = $entry->message;
343
        $context = $entry->context;
344
345
        if (isset($context["exception"])) {
346
            // NOTE: per PSR-3, this reserved key could be anything, but if it is an Exception, we
347
            //       can use that Exception to obtain a stack-trace for output in ChromeLogger.
348
349
            $exception = $context["exception"];
350
351
            if ($exception instanceof Exception || $exception instanceof Error) {
352
                $stack_trace = explode("\n", $exception->__toString());
353
                $title = array_shift($stack_trace);
354
355
                $rows[] = [[$title], self::GROUP_COLLAPSED];
356
                $rows[] = [[implode("\n", $stack_trace)], self::INFO];
357
                $rows[] = [[], self::GROUP_END];
358
            }
359
360
            unset($context["exception"]);
361
        }
362
363
        $data = [str_replace("%", "%%", $message)];
364
365
        foreach ($context as $key => $value) {
366
            if (is_array($context[$key]) && preg_match("/^table:\\s*(.+)/ui", $key, $matches) === 1) {
367
                $title = $matches[1];
368
369
                $rows[] = [[$title], self::GROUP_COLLAPSED];
370
                $rows[] = [[$context[$key]], self::TABLE];
371
                $rows[] = [[], self::GROUP_END];
372
373
                unset($context[$key]);
374
            } else {
375
                if (!is_int($key)) {
376
                    $data[] = "{$key}:";
377
                }
378
379
                $data[] = $this->sanitize($value);
380
            }
381
        }
382
383
        $row[] = $data;
384
385
        $row[] = isset($LEVELS[$entry->level])
386
            ? $LEVELS[$entry->level]
387
            : self::LOG;
388
389
        // Optimization: ChromeLogger defaults to "log" if no entry-type is specified.
390
391
        if ($row[1] === self::LOG) {
392
            if (count($row) === 2) {
393
                unset($row[1]);
394
            } else {
395
                $row[1] = "";
396
            }
397
        }
398
399
        array_unshift($rows, $row); // append the first row
400
401
        return $rows;
402
    }
403
404
    /**
405
     * Internally marshall and sanitize context values, producing a JSON-compatible data-structure.
406
     *
407
     * @param mixed  $data      any PHP object, array or value
408
     * @param true[] $processed map where SPL object-hash => TRUE (eliminates duplicate objects from data-structures)
409
     *
410
     * @return mixed marshalled and sanitized context
411
     */
412
    protected function sanitize($data, &$processed = [])
413
    {
414
        if (is_array($data)) {
415
            /**
416
             * @var array $data
417
             */
418
419
            foreach ($data as $name => $value) {
420
                $data[$name] = $this->sanitize($value, $processed);
421
            }
422
423
            return $data;
424
        }
425
426
        if (is_object($data)) {
427
            /**
428
             * @var object $data
429
             */
430
431
            $class_name = get_class($data);
432
433
            $hash = spl_object_hash($data);
434
435
            if (isset($processed[$hash])) {
436
                // NOTE: duplicate objects (circular references) are omitted to prevent recursion.
437
438
                return [self::CLASS_NAME => $class_name];
439
            }
440
441
            $processed[$hash] = true;
442
443
            if ($data instanceof JsonSerializable) {
0 ignored issues
show
The class JsonSerializable does not exist. Is this class maybe located in a folder that is not analyzed, or in a newer version of your dependencies than listed in your composer.lock/composer.json?
Loading history...
444
                // NOTE: this doesn't serialize to JSON, it only marshalls to a JSON-compatible data-structure
445
446
                $data = $this->sanitize($data->jsonSerialize(), $processed);
447
            } elseif ($data instanceof DateTimeInterface) {
448
                $data = $this->extractDateTimeProperties($data);
449
            } elseif ($data instanceof Exception || $data instanceof Error) {
450
                $data = $this->extractExceptionProperties($data);
451
            } else {
452
                $data = $this->sanitize($this->extractObjectProperties($data), $processed);
453
            }
454
455
            return array_merge([self::CLASS_NAME => $class_name], $data);
456
        }
457
458
        if (is_scalar($data)) {
459
            return $data; // bool, int, float
460
        }
461
462
        if (is_resource($data)) {
463
            $resource = explode("#", (string) $data);
464
465
            return [
466
                self::CLASS_NAME => "resource<" . get_resource_type($data) . ">",
467
                "id" => array_pop($resource)
468
            ];
469
        }
470
471
        return null; // omit any other unsupported types (e.g. resource handles)
472
    }
473
474
    /**
475
     * @param DateTimeInterface $datetime
476
     *
477
     * @return array
478
     */
479
    protected function extractDateTimeProperties(DateTimeInterface $datetime)
480
    {
481
        $utc = date_create_from_format("U", $datetime->format("U"), timezone_open("UTC"));
482
483
        return [
484
            "datetime" => $utc->format(self::DATETIME_FORMAT),
485
            "timezone" => $datetime->getTimezone()->getName(),
486
        ];
487
    }
488
489
    /**
490
     * @param object $object
491
     *
492
     * @return array
493
     */
494
    protected function extractObjectProperties($object)
495
    {
496
        $properties = [];
497
498
        $reflection = new ReflectionClass(get_class($object));
499
500
        // obtain public, protected and private properties of the class itself:
501
502 View Code Duplication
        foreach ($reflection->getProperties() as $property) {
503
            if ($property->isStatic()) {
504
                continue; // omit static properties
505
            }
506
507
            $property->setAccessible(true);
508
509
            $properties["\${$property->name}"] = $property->getValue($object);
510
        }
511
512
        // obtain any inherited private properties from parent classes:
513
514
        while ($reflection = $reflection->getParentClass()) {
515 View Code Duplication
            foreach ($reflection->getProperties(ReflectionProperty::IS_PRIVATE) as $property) {
516
                $property->setAccessible(true);
517
518
                $properties["{$reflection->name}::\${$property->name}"] = $property->getValue($object);
519
            }
520
        }
521
522
        return $properties;
523
    }
524
525
    /**
526
     * @param Throwable $exception
527
     *
528
     * @return array
529
     */
530
    protected function extractExceptionProperties($exception)
531
    {
532
        $previous = $exception->getPrevious();
533
534
        return [
535
            "\$message"  => $exception->getMessage(),
536
            "\$file"     => $exception->getFile(),
537
            "\$code"     => $exception->getCode(),
538
            "\$line"     => $exception->getLine(),
539
            "\$previous" => $previous ? $this->extractExceptionProperties($previous) : null,
540
        ];
541
    }
542
}
543