IncidentsTable::getReportDetails()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 37
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 25
nc 2
nop 2
dl 0
loc 37
ccs 24
cts 24
cp 1
crap 3
rs 9.52
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * An incident a representing a single incident of a submited bug.
5
 *
6
 * phpMyAdmin Error reporting server
7
 * Copyright (c) phpMyAdmin project (https://www.phpmyadmin.net/)
8
 *
9
 * Licensed under The MIT License
10
 * For full copyright and license information, please see the LICENSE.txt
11
 * Redistributions of files must retain the above copyright notice.
12
 *
13
 * @copyright Copyright (c) phpMyAdmin project (https://www.phpmyadmin.net/)
14
 * @license   https://opensource.org/licenses/mit-license.php MIT License
15
 *
16
 * @see      https://www.phpmyadmin.net/
17
 */
18
19
namespace App\Model\Table;
20
21
use Cake\Log\Log;
22
use Cake\Model\Model;
23
use Cake\ORM\Entity;
24
use Cake\ORM\Table;
25
use Cake\ORM\TableRegistry;
26
27
use function array_merge;
28
use function array_push;
29
use function array_slice;
30
use function count;
31
use function date;
32
use function hash_final;
33
use function hash_init;
34
use function hash_update;
35
use function in_array;
36
use function json_encode;
37
use function mb_strimwidth;
38
use function mb_strlen;
39
use function preg_match;
40
use function strtotime;
41
use function time;
42
43
/**
44
 * An incident a representing a single incident of a submited bug.
45
 */
46
class IncidentsTable extends Table
47
{
48
    /**
49
     * @var array
50
     *
51
     * @see http://book.cakephp.org/2.0/en/models/behaviors.html#using-behaviors
52
     * @see Model::$actsAs
53
     */
54
    public $actsAs = ['Summarizable'];
55
56
    /**
57
     * @var array
58
     *
59
     * @see http://book.cakephp.org/2.0/en/models/model-attributes.html#validate
60
     * @see http://book.cakephp.org/2.0/en/models/data-validation.html
61
     * @see Model::$validate
62
     */
63
    public $validate = [
64
        'pma_version' => [
65
            'rule' => 'notEmpty',
66
            'required' => true,
67
        ],
68
        'php_version' => [
69
            'rule' => 'notEmpty',
70
            'required' => true,
71
        ],
72
        'full_report' => [
73
            'rule' => 'notEmpty',
74
            'required' => true,
75
        ],
76
        'stacktrace' => [
77
            'rule' => 'notEmpty',
78
            'required' => true,
79
        ],
80
        'browser' => [
81
            'rule' => 'notEmpty',
82
            'required' => true,
83
        ],
84
        'stackhash' => [
85
            'rule' => 'notEmpty',
86
            'required' => true,
87
        ],
88
        'user_os' => [
89
            'rule' => 'notEmpty',
90
            'required' => true,
91
        ],
92
        'locale' => [
93
            'rule' => 'notEmpty',
94
            'required' => true,
95
        ],
96
        'script_name' => [
97
            'rule' => 'notEmpty',
98
            'required' => true,
99
        ],
100
        'server_software' => [
101
            'rule' => 'notEmpty',
102
            'required' => true,
103
        ],
104
        'configuration_storage' => [
105
            'rule' => 'notEmpty',
106
            'required' => true,
107
        ],
108
    ];
109
110
    /**
111
     * @var array
112
     *
113
     * @see http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#belongsto
114
     * @see Model::$belongsTo
115
     */
116
117
    /**
118
     * The fields which are summarized in the report page with charts and are also
119
     * used in the overall stats and charts for the website.
120
     *
121
     * @var array
122
     */
123
    public $summarizableFields = [
124
        'browser',
125
        'pma_version',
126
        'php_version',
127
        'locale',
128
        'server_software',
129
        'user_os',
130
        'script_name',
131
        'configuration_storage',
132
    ];
133
134
    /** @var array[] */
135
    public $filterTimes = [];
136
137 315
    public function __construct(array $data)
138
    {
139 315
        parent::__construct($data);
140
141 315
        $this->filterTimes = [
142 45
            'all_time' => [
143 270
                'label' => 'All Time',
144
                'limit' => null,
145
                'group' => "DATE_FORMAT(Incidents.created, '%m %Y')",
146
            ],
147
            'day' => [
148 315
                'label' => 'Last Day',
149 315
                'limit' => date('Y-m-d', strtotime('-1 day')),
150 315
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y %H')",
151
            ],
152
            'week' => [
153 315
                'label' => 'Last Week',
154 315
                'limit' => date('Y-m-d', strtotime('-1 week')),
155 315
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y')",
156
            ],
157
            'month' => [
158 315
                'label' => 'Last Month',
159 315
                'limit' => date('Y-m-d', strtotime('-1 month')),
160 315
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y')",
161
            ],
162
            'year' => [
163 315
                'label' => 'Last Year',
164 315
                'limit' => date('Y-m-d', strtotime('-1 year')),
165 315
                'group' => "DATE_FORMAT(Incidents.created, '%b %u %Y')",
166
            ],
167
        ];
168 315
    }
169
170
    /**
171
     * creates an incident/report record given a raw bug report object.
172
     *
173
     * This gets a decoded bug report from the submitted json body. This has not
174
     * yet been santized. It either adds it as an incident to another report or
175
     * creates a new report if nothing matches.
176
     *
177
     * @param array|null $bugReport the bug report being submitted
178
     *
179
     * @return array of:
180
     *          1. array of inserted incident ids. If the report/incident was not
181
     *               correctly saved, false is put in it place.
182
     *          2. array of newly created report ids. If no new report was created,
183
     *               an empty array is returned
184
     */
185 14
    public function createIncidentFromBugReport(?array $bugReport): array
186
    {
187 14
        if ($bugReport === null) {
188
            return [
189 7
                'incidents' => [false],
190
                'reports' => [],
191
            ];
192
        }
193
194 14
        $incident_ids = [];    // array to hold ids of all the inserted incidents
195 14
        $new_report_ids = []; // array to hold ids of all newly created reports
196
197
        // Avoid storing too many errors from single report
198 14
        if (isset($bugReport['errors']) && count($bugReport['errors']) > 40) {
199 7
            $bugReport['errors'] = array_slice($bugReport['errors'], 0, 40);
200
        }
201
202 14
        if (isset($bugReport['exception']['stack']) && count($bugReport['exception']['stack']) > 40) {
203 7
            $bugReport['exception']['stack'] = array_slice($bugReport['exception']['stack'], 0, 40);
204
        }
205
206
        // Also sanitizes the bug report
207 14
        $schematizedIncidents = $this->getSchematizedIncidents($bugReport);
208 14
        $incidentsTable = TableRegistry::getTableLocator()->get('Incidents');
209 14
        $reportsTable = TableRegistry::getTableLocator()->get('Reports');
210 14
        foreach ($schematizedIncidents as $index => $si) {
211
            // find closest report. If not found, create a new report.
212 14
            $closestReport = $this->getClosestReport($bugReport, $index);
213 14
            if ($closestReport) {
214 14
                $si['report_id'] = $closestReport['id'];
215 14
                $si['error_message'] = mb_strimwidth($si['error_message'], 0, 200, '...');
216 14
                $si = $incidentsTable->newEntity($si);
217 14
                $si->created = date('Y-m-d H:i:s', time());
218 14
                $si->modified = date('Y-m-d H:i:s', time());
219
220 14
                $this->logLongIncidentSubmissions($si, $incident_ids);
221 14
                if (in_array(false, $incident_ids)) {
222 14
                    break;
223
                }
224
            } else {
225
                // no close report. Create a new report.
226 14
                $report = $this->getReportDetails($bugReport, $index);
227
228 14
                $this->logLongIncidentSubmissions($si, $incident_ids);
229 14
                if (in_array(false, $incident_ids)) {
230
                    break;
231
                }
232
233 14
                $report = $reportsTable->newEntity($report);
234 14
                $report->created = date('Y-m-d H:i:s', time());
235 14
                $report->modified = date('Y-m-d H:i:s', time());
236 14
                $reportsTable->save($report);
237
238 14
                $si['report_id'] = $report->id;
239 14
                $si['error_message'] = mb_strimwidth($si['error_message'], 0, 200, '...');
240 14
                $new_report_ids[] = $report->id;
241 14
                $si = $incidentsTable->newEntity($si);
242 14
                $si->created = date('Y-m-d H:i:s', time());
243 14
                $si->modified = date('Y-m-d H:i:s', time());
244
            }
245
246 14
            $isSaved = $incidentsTable->save($si);
247 14
            if ($isSaved) {
248 14
                array_push($incident_ids, $si->id);
249 14
                if (! $closestReport) {
250
                    // add notifications entry
251 14
                    $tmpIncident = $incidentsTable->findById($si->id)->all()->first();
252 14
                    if (! TableRegistry::getTableLocator()->get('Notifications')->addNotifications((int) $tmpIncident['report_id'])) {
253
                        Log::write(
254
                            'error',
255
                            'ERRORED: Notification::addNotifications() failed on Report#'
256
                                . $tmpIncident['report_id'],
257 14
                            'alert'
258
                        );
259
                    }
260
                }
261
            } else {
262
                array_push($incident_ids, false);
263
            }
264
        }
265
266
        return [
267 14
            'incidents' => $incident_ids,
268 14
            'reports' => $new_report_ids,
269
        ];
270
    }
271
272
    /**
273
     * retrieves the closest report to a given bug report.
274
     *
275
     * it checks for another report with the same line number, filename and
276
     * pma_version
277
     *
278
     * @param array $bugReport the bug report being checked
279
     *                         Integer $index: for php exception type
280
     * @param int   $index     The report index
281
     *
282
     * @return Entity|null the first similar report or null
283
     */
284 21
    protected function getClosestReport(array $bugReport, int $index = 0): ?Entity
285
    {
286
        if (
287 21
            isset($bugReport['exception_type'])
288 21
            && $bugReport['exception_type'] === 'php'
289
        ) {
290 14
            $location = $bugReport['errors'][$index]['file'];
291 14
            $linenumber = $bugReport['errors'][$index]['lineNum'];
292
        } else {
293 6
            [$location, $linenumber] =
294 21
                    $this->getIdentifyingLocation($bugReport['exception']['stack'] ?? []);
295
        }
296
297 21
        return TableRegistry::getTableLocator()->get('Reports')->findByLocationAndLinenumberAndPmaVersion(
298 21
            $location,
299 3
            $linenumber,
300 21
            self::getStrippedPmaVersion($bugReport['pma_version'])
301 21
        )->all()->first();
302
    }
303
304
    /**
305
     * creates the report data from an incident that has no related report.
306
     *
307
     * @param array $bugReport the bug report the report record is being created for
308
     *                         Integer $index: for php exception type
309
     * @param int   $index     The report index
310
     *
311
     * @return array an array with the report fields can be used with Report->save
312
     */
313 21
    protected function getReportDetails(array $bugReport, int $index = 0): array
314
    {
315
        if (
316 21
            isset($bugReport['exception_type'])
317 21
            && $bugReport['exception_type'] === 'php'
318
        ) {
319 21
            $location = $bugReport['errors'][$index]['file'];
320 21
            $linenumber = $bugReport['errors'][$index]['lineNum'];
321 6
            $reportDetails = [
322 21
                'error_message' => $bugReport['errors'][$index]['msg'],
323 21
                'error_name' => $bugReport['errors'][$index]['type'],
324
            ];
325 21
            $exception_type = 1;
326
        } else {
327 21
            $exception = $bugReport['exception'] ?? [];
328 6
            [$location, $linenumber] =
329 21
                $this->getIdentifyingLocation($exception['stack'] ?? []);
330
331 6
            $reportDetails = [
332 21
                'error_message' => $exception['message'] ?? '',
333 21
                'error_name' => $exception['name'] ?? '',
334
            ];
335 21
            $exception_type = 0;
336
        }
337
338 21
        $reportDetails = array_merge(
339 21
            $reportDetails,
340
            [
341 21
                'status' => 'new',
342 21
                'location' => $location,
343 21
                'linenumber' => $linenumber ?? 0,
344 21
                'pma_version' => self::getStrippedPmaVersion($bugReport['pma_version']),
345 21
                'exception_type' => $exception_type,
346
            ]
347
        );
348
349 21
        return $reportDetails;
350
    }
351
352
    /**
353
     * creates the incident data from the submitted bug report.
354
     *
355
     * @param array $bugReport the bug report the report record is being created for
356
     *
357
     * @return array an array of schematized incident.
358
     *               Can be used with Incident->save
359
     */
360 21
    protected function getSchematizedIncidents(array $bugReport): array
361
    {
362
        //$bugReport = Sanitize::clean($bugReport, array('escape' => false));
363 21
        $schematizedReports = [];
364 6
        $schematizedCommonReport = [
365 21
            'pma_version' => self::getStrippedPmaVersion($bugReport['pma_version']),
366 21
            'php_version' => $this->getSimpleVersion($bugReport['php_version'] ?? '', 2),
367 21
            'browser' => ($bugReport['browser_name'] ?? '') . ' '
368 21
                    . $this->getSimpleVersion($bugReport['browser_version'] ?? '', 1),
369 21
            'user_os' => $bugReport['user_os'] ?? '',
370 21
            'locale' => $bugReport['locale'] ?? '',
371 21
            'configuration_storage' => $bugReport['configuration_storage'] ?? '',
372 21
            'server_software' => $this->getServer($bugReport['server_software'] ?? ''),
373 21
            'full_report' => json_encode($bugReport),
374
        ];
375
376
        if (
377 21
            isset($bugReport['exception_type'])
378 21
            && $bugReport['exception_type'] === 'php'
379
        ) {
380
            // for each "errors"
381 21
            foreach ($bugReport['errors'] as $error) {
382 21
                $tmpReport = array_merge(
383 21
                    $schematizedCommonReport,
384
                    [
385 21
                        'error_name' => $error['type'],
386 21
                        'error_message' => $error['msg'],
387 21
                        'script_name' => $error['file'],
388 21
                        'stacktrace' => json_encode($error['stackTrace'] ?? []),
389 21
                        'stackhash' => $error['stackhash'],
390 21
                        'exception_type' => 1,         // 'php'
391
                    ]
392
                );
393 21
                array_push($schematizedReports, $tmpReport);
394
            }
395
        } else {
396 21
            $exception = $bugReport['exception'] ?? [
397 15
                'name' => '',
398
                'message' => '',
399
                'stack' => [],
400
            ];
401 21
            $tmpReport = array_merge(
402 21
                $schematizedCommonReport,
403
                [
404 21
                    'error_name' => $exception['name'] ?? '',
405 21
                    'error_message' => $exception['message'] ?? '',
406 21
                    'script_name' => $bugReport['script_name'] ?? '',
407 21
                    'stacktrace' => json_encode($exception['stack'] ?? []),
408 21
                    'stackhash' => self::getStackHash($exception['stack'] ?? []),
409 21
                    'exception_type' => 0,     //'js'
410
                ]
411
            );
412
413 21
            if (isset($bugReport['steps'])) {
414 21
                $tmpReport['steps'] = $bugReport['steps'];
415
            }
416
417 21
            array_push($schematizedReports, $tmpReport);
418
        }
419
420 21
        return $schematizedReports;
421
    }
422
423
    /**
424
     * Gets the identifiying location info from a stacktrace.
425
     *
426
     * This is used to skip stacktrace levels that are within the error reporting js
427
     * files that sometimes appear in the stacktrace but are not related to the bug
428
     * report
429
     *
430
     * returns two things in an array:
431
     * - the first element is the filename/scriptname of the error
432
     * - the second element is the linenumber of the error
433
     *
434
     * @param array $stacktrace the stacktrace being examined
435
     *
436
     * @return array an array with the filename/scriptname and linenumber of the
437
     *               error
438
     */
439 35
    protected function getIdentifyingLocation(array $stacktrace): array
440
    {
441 10
        $fallback = [
442 25
            'UNKNOWN',
443
            0,
444
        ];
445 35
        foreach ($stacktrace as $level) {
446 35
            if (isset($level['filename'])) {
447
                // ignore unrelated files that sometimes appear in the error report
448 35
                if ($level['filename'] === 'tracekit/tracekit.js') {
449 7
                    continue;
450
                }
451
452 35
                if ($level['filename'] === 'error_report.js') {
453
                    // in case the error really is in the error_report.js file save it for
454
                    // later
455 7
                    if ($fallback[0] === 'UNKNOWN') {
456 2
                        $fallback = [
457 7
                            $level['filename'],
458 7
                            $level['line'],
459
                        ];
460
                    }
461
462 7
                    continue;
463
                }
464
465
                return [
466 35
                    $level['filename'],
467 35
                    $level['line'],
468
                ];
469
            }
470
471 7
            if (isset($level['scriptname'])) {
472
                return [
473 7
                    $level['scriptname'],
474 7
                    $level['line'],
475
                ];
476
            }
477
478 7
            continue;
479
        }
480
481 7
        return $fallback;
482
    }
483
484
    /**
485
     * Gets a part of a version string according to the specified version Length.
486
     *
487
     * @param string $versionString the version string
488
     * @param string $versionLength the number of version components to return. eg
489
     *                              1 for major version only and 2 for major and
490
     *                              minor version
491
     *
492
     * @return string the major and minor version part
493
     */
494 28
    protected function getSimpleVersion(string $versionString, string $versionLength): string
495
    {
496 28
        $versionLength = (int) $versionLength;
497 28
        if ($versionLength < 1) {
498 7
            $versionLength = 1;
499
        }
500
501
        /* modify the re to accept a variable number of version components. I
502
         * atleast take one component and optionally get more components if need be.
503
         * previous code makes sure that the $versionLength variable is a positive
504
         * int
505
         */
506 28
        $result = preg_match(
507 28
            '/^(\d+\.){' . ($versionLength - 1) . '}\d+/',
508 28
            $versionString,
509 28
            $matches
510
        );
511 28
        if ($result) {
512 28
            return $matches[0];
513
        }
514
515
        return $versionString;
516
    }
517
518
    /**
519
     * Returns the version string stripped of
520
     * 'deb', 'ubuntu' and other suffixes
521
     *
522
     * @param string $versionString phpMyAdmin version
523
     *
524
     * @return string stripped phpMyAdmin version
525
     */
526 196
    public static function getStrippedPmaVersion(string $versionString): string
527
    {
528
        // This regex contains a negative lookahead to avoid fedora version suffix
529
        // Use: https://regex101.com/
530
        // See tests: IncidentsTableTest::versionsStripping
531 196
        $allowedRegexp = '/^((\d+)(\.\d+){0,3}(\-[a-z0-9]+){0,1})(?!\.[a-z]{1,}[0-9]{1,})/';
532 196
        $matches = [];
533
534
        // Check if $versionString matches the regexp
535
        // and store the matched strings
536 196
        if (preg_match($allowedRegexp, $versionString, $matches)) {
537 182
            return $matches[0];
538
        }
539
540
        // If $versionString does not match the regexp at all,
541
        // leave it as it is
542 14
        return $versionString;
543
    }
544
545
    /**
546
     * Gets the server name and version from the server signature.
547
     *
548
     * @param string $signature the server signature
549
     *
550
     * @return string the server name and version or UNKNOWN
551
     */
552 28
    protected function getServer(string $signature): string
553
    {
554
        if (
555 28
            preg_match(
556
                '/(apache\/\d+\.\d+)|(nginx\/\d+\.\d+)|(iis\/\d+\.\d+)'
557 28
                . '|(lighttpd\/\d+\.\d+)/i',
558 28
                $signature,
559 28
                $matches
560
            )
561
        ) {
562 28
            return $matches[0];
563
        }
564
565 7
        return 'UNKNOWN';
566
    }
567
568
    /**
569
     * returns the hash pertaining to a stacktrace.
570
     *
571
     * @param array $stacktrace the stacktrace in question
572
     *
573
     * @return string the hash string of the stacktrace
574
     */
575 28
    public function getStackHash(array $stacktrace): string
576
    {
577 28
        $handle = hash_init('md5');
578 28
        foreach ($stacktrace as $level) {
579 8
            $elements = [
580 20
                'filename',
581
                'scriptname',
582
                'line',
583
                'func',
584
                'column',
585
            ];
586 28
            foreach ($elements as $element) {
587 28
                if (! isset($level[$element])) {
588 28
                    continue;
589
                }
590
591 28
                hash_update($handle, $level[$element]);
592
            }
593
        }
594
595 28
        return hash_final($handle);
596
    }
597
598
    /**
599
     * Checks the length of stacktrace and full_report
600
     * and logs if it is greater than what it can hold
601
     *
602
     * @param object $si           submitted incident
603
     * @param array  $incident_ids incident IDs
604
     * @return void Nothing
605
     */
606 14
    private function logLongIncidentSubmissions($si, array &$incident_ids): void// phpcs:ignore SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingParameterTypeHint
607
    {
608 14
        $stacktraceLength = mb_strlen($si['stacktrace']);
609 14
        $fullReportLength = mb_strlen($si['full_report']);
610
611
        if (
612 14
            $stacktraceLength <= 65535
613 14
            && $fullReportLength <= 65535
614
        ) {
615 14
            return;
616
        }
617
618
        // If length of report is longer than
619
        // what can fit in the table field,
620
        // we log it and don't save it in the database
621
        Log::error(
622
            'Too long data submitted in the incident. The length of stacktrace: '
623
            . $stacktraceLength . ', the length of bug report: '
624
            . $fullReportLength . '. The full incident reported was as follows: '
625
            . json_encode($si)
626
        );
627
628
        // add a 'false' to the return array
629
        array_push($incident_ids, false);
630
    }
631
}
632