Completed
Push — master ( 07e3a8...9561cd )
by William
16:58 queued 14:23
created

IncidentsTable::getIdentifyingLocation()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 41
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 21
nc 7
nop 1
dl 0
loc 41
ccs 18
cts 18
cp 1
crap 7
rs 8.6506
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;
0 ignored issues
show
Bug introduced by
The type Cake\Model\Model was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

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