Completed
Push — master ( 89830f...07e3a8 )
by William
20:06
created

IncidentsTable::logLongIncidentSubmissions()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

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