Passed
Push — master ( 2acbdd...490d36 )
by William
07:42
created

IncidentsTable::_getClosestReport()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 18
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 13
nc 2
nop 2
dl 0
loc 18
ccs 12
cts 12
cp 1
crap 3
rs 9.8333
c 1
b 0
f 0
1
<?php
2
/* vim: set expandtab sw=4 ts=4 sts=4: */
3
4
/**
5
 * An incident a representing a single incident of a submited bug.
6
 *
7
 * phpMyAdmin Error reporting server
8
 * Copyright (c) phpMyAdmin project (https://www.phpmyadmin.net/)
9
 *
10
 * Licensed under The MIT License
11
 * For full copyright and license information, please see the LICENSE.txt
12
 * Redistributions of files must retain the above copyright notice.
13
 *
14
 * @copyright Copyright (c) phpMyAdmin project (https://www.phpmyadmin.net/)
15
 * @license   https://opensource.org/licenses/mit-license.php MIT License
16
 *
17
 * @see      https://www.phpmyadmin.net/
18
 */
19
20
namespace App\Model\Table;
21
22
use Cake\Log\Log;
23
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...
24
use Cake\ORM\Table;
25
use Cake\ORM\TableRegistry;
26
27
/**
28
 * An incident a representing a single incident of a submited bug.
29
 */
30
class IncidentsTable extends Table
31
{
32
    /**
33
     * @var array
34
     *
35
     * @see http://book.cakephp.org/2.0/en/models/behaviors.html#using-behaviors
36
     * @see Model::$actsAs
37
     */
38
    public $actsAs = ['Summarizable'];
39
40
    /**
41
     * @var array
42
     *
43
     * @see http://book.cakephp.org/2.0/en/models/model-attributes.html#validate
44
     * @see http://book.cakephp.org/2.0/en/models/data-validation.html
45
     * @see Model::$validate
46
     */
47
    public $validate = [
48
        'pma_version' => [
49
            'rule' => 'notEmpty',
50
            'required' => true,
51
        ],
52
        'php_version' => [
53
            'rule' => 'notEmpty',
54
            'required' => true,
55
        ],
56
        'full_report' => [
57
            'rule' => 'notEmpty',
58
            'required' => true,
59
        ],
60
        'stacktrace' => [
61
            'rule' => 'notEmpty',
62
            'required' => true,
63
        ],
64
        'browser' => [
65
            'rule' => 'notEmpty',
66
            'required' => true,
67
        ],
68
        'stackhash' => [
69
            'rule' => 'notEmpty',
70
            'required' => true,
71
        ],
72
        'user_os' => [
73
            'rule' => 'notEmpty',
74
            'required' => true,
75
        ],
76
        'locale' => [
77
            'rule' => 'notEmpty',
78
            'required' => true,
79
        ],
80
        'script_name' => [
81
            'rule' => 'notEmpty',
82
            'required' => true,
83
        ],
84
        'server_software' => [
85
            'rule' => 'notEmpty',
86
            'required' => true,
87
        ],
88
        'configuration_storage' => [
89
            'rule' => 'notEmpty',
90
            'required' => true,
91
        ],
92
    ];
93
94
    /**
95
     * @var array
96
     *
97
     * @see http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#belongsto
98
     * @see Model::$belongsTo
99
     */
100
101
    /**
102
     * The fields which are summarized in the report page with charts and are also
103
     * used in the overall stats and charts for the website.
104
     *
105
     * @var array
106
     */
107
    public $summarizableFields = [
108
        'browser',
109
        'pma_version',
110
        'php_version',
111
        'locale',
112
        'server_software',
113
        'user_os',
114
        'script_name',
115
        'configuration_storage',
116
    ];
117
118 28
    public function __construct($id = false)
119
    {
120 28
        parent::__construct($id);
121
122 28
        $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...
123 28
            'all_time' => [
124
                'label' => 'All Time',
125
                'limit' => null,
126
                'group' => "DATE_FORMAT(Incidents.created, '%m %Y')",
127
            ],
128
            'day' => [
129 28
                'label' => 'Last Day',
130 28
                'limit' => date('Y-m-d', strtotime('-1 day')),
131 28
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y %H')",
132
            ],
133
            'week' => [
134 28
                'label' => 'Last Week',
135 28
                'limit' => date('Y-m-d', strtotime('-1 week')),
136 28
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y')",
137
            ],
138
            'month' => [
139 28
                'label' => 'Last Month',
140 28
                'limit' => date('Y-m-d', strtotime('-1 month')),
141 28
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y')",
142
            ],
143
            'year' => [
144 28
                'label' => 'Last Year',
145 28
                'limit' => date('Y-m-d', strtotime('-1 year')),
146 28
                'group' => "DATE_FORMAT(Incidents.created, '%b %u %Y')",
147
            ],
148
        ];
149 28
    }
150
151
    /**
152
     * creates an incident/report record given a raw bug report object.
153
     *
154
     * This gets a decoded bug report from the submitted json body. This has not
155
     * yet been santized. It either adds it as an incident to another report or
156
     * creates a new report if nothing matches.
157
     *
158
     * @param array $bugReport the bug report being submitted
159
     *
160
     * @return array of:
161
     *          1. array of inserted incident ids. If the report/incident was not
162
     *               correctly saved, false is put in it place.
163
     *          2. array of newly created report ids. If no new report was created,
164
     *               an empty array is returned
165
     */
166 2
    public function createIncidentFromBugReport($bugReport)
167
    {
168 2
        if ($bugReport == null) {
169
            return [
170 1
                'incidents' => [false],
171
                'reports' => []
172
            ];
173
        }
174 2
        $incident_ids = [];    // array to hold ids of all the inserted incidents
175 2
        $new_report_ids = []; // array to hold ids of all newly created reports
176
177
        // Avoid storing too many errors from single report
178 2
        if (isset($bugReport['errors']) && count($bugReport['errors']) > 40) {
179 1
            $bugReport['errors'] = array_slice($bugReport['errors'], 0, 40);
180
        }
181 2
        if (isset($bugReport['exception']['stack']) && count($bugReport['exception']['stack']) > 40) {
182 1
            $bugReport['exception']['stack'] = array_slice($bugReport['exception']['stack'], 0, 40);
183
        }
184
185
        // Also sanitizes the bug report
186 2
        $schematizedIncidents = $this->_getSchematizedIncidents($bugReport);
187 2
        $incidentsTable = TableRegistry::getTableLocator()->get('Incidents');
188 2
        $reportsTable = TableRegistry::getTableLocator()->get('Reports');
189 2
        foreach ($schematizedIncidents as $index => $si) {
190
            // find closest report. If not found, create a new report.
191 2
            $closestReport = $this->_getClosestReport($bugReport, $index);
192 2
            if ($closestReport) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $closestReport of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
193 2
                $si['report_id'] = $closestReport['id'];
194 2
                $si = $incidentsTable->newEntity($si);
195 2
                $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...
196 2
                $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...
197
198 2
                $this->_logLongIncidentSubmissions($si, $incident_ids);
0 ignored issues
show
Bug introduced by
$si of type Cake\Datasource\EntityInterface is incompatible with the type array expected by parameter $si of App\Model\Table\Incident...ngIncidentSubmissions(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

198
                $this->_logLongIncidentSubmissions(/** @scrutinizer ignore-type */ $si, $incident_ids);
Loading history...
199 2
                if (in_array(false, $incident_ids)) {
200 2
                    break;
201
                }
202
            } else {
203
                // no close report. Create a new report.
204 2
                $report = $this->_getReportDetails($bugReport, $index);
205
206 2
                $this->_logLongIncidentSubmissions($si, $incident_ids);
207 2
                if (in_array(false, $incident_ids)) {
208
                    break;
209
                }
210
211 2
                $report = $reportsTable->newEntity($report);
212 2
                $report->created = date('Y-m-d H:i:s', time());
213 2
                $report->modified = date('Y-m-d H:i:s', time());
214 2
                $reportsTable->save($report);
215
216 2
                $si['report_id'] = $report->id;
217 2
                $new_report_ids[] = $report->id;
218 2
                $si = $incidentsTable->newEntity($si);
219 2
                $si->created = date('Y-m-d H:i:s', time());
220 2
                $si->modified = date('Y-m-d H:i:s', time());
221
            }
222
223 2
            $isSaved = $incidentsTable->save($si);
224 2
            if ($isSaved) {
225 2
                array_push($incident_ids, $si->id);
226 2
                if (! $closestReport) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $closestReport of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
227
                    // add notifications entry
228 2
                    $tmpIncident = $incidentsTable->findById($si->id)->all()->first();
229 2
                    if (! TableRegistry::getTableLocator()->get('Notifications')->addNotifications(intval($tmpIncident['report_id']))) {
230
                        Log::write(
231
                            'error',
232
                            'ERRORED: Notification::addNotifications() failed on Report#'
233
                                . $tmpIncident['report_id'],
234 2
                            'alert'
235
                        );
236
                    }
237
                }
238
            } else {
239 2
                array_push($incident_ids, false);
240
            }
241
        }
242
243
        return [
244 2
            'incidents' => $incident_ids,
245 2
            'reports' => $new_report_ids
246
        ];
247
    }
248
249
    /**
250
     * retrieves the closest report to a given bug report.
251
     *
252
     * it checks for another report with the same line number, filename and
253
     * pma_version
254
     *
255
     * @param array $bugReport the bug report being checked
256
     *                         Integer $index: for php exception type
257
     * @param int   $index     The report index
258
     *
259
     * @return array the first similar report or null
260
     */
261 3
    protected function _getClosestReport($bugReport, $index = 0)
262
    {
263 3
        if (isset($bugReport['exception_type'])
264 3
            && $bugReport['exception_type'] == 'php'
265
        ) {
266 2
            $location = $bugReport['errors'][$index]['file'];
267 2
            $linenumber = $bugReport['errors'][$index]['lineNum'];
268
        } else {
269
            list($location, $linenumber) =
270 3
                    $this->_getIdentifyingLocation($bugReport['exception']['stack']);
271
        }
272 3
        $report = TableRegistry::getTableLocator()->get('Reports')->findByLocationAndLinenumberAndPmaVersion(
273 3
            $location,
274 3
            $linenumber,
275 3
            $this->getStrippedPmaVersion($bugReport['pma_version'])
276 3
        )->all()->first();
277
278 3
        return $report;
279
    }
280
281
    /**
282
     * creates the report data from an incident that has no related report.
283
     *
284
     * @param array $bugReport the bug report the report record is being created for
285
     *                         Integer $index: for php exception type
286
     * @param int   $index     The report index
287
     *
288
     * @return array an array with the report fields can be used with Report->save
289
     */
290 3
    protected function _getReportDetails($bugReport, $index = 0)
291
    {
292 3
        if (isset($bugReport['exception_type'])
293 3
            && $bugReport['exception_type'] == 'php'
294
        ) {
295 3
            $location = $bugReport['errors'][$index]['file'];
296 3
            $linenumber = $bugReport['errors'][$index]['lineNum'];
297
            $reportDetails = [
298 3
                'error_message' => $bugReport['errors'][$index]['msg'],
299 3
                'error_name' => $bugReport['errors'][$index]['type'],
300
            ];
301 3
            $exception_type = 1;
302
        } else {
303
            list($location, $linenumber) =
304 3
                $this->_getIdentifyingLocation($bugReport['exception']['stack']);
305
306
            $reportDetails = [
307 3
                'error_message' => $bugReport['exception']['message'],
308 3
                'error_name' => $bugReport['exception']['name'],
309
            ];
310 3
            $exception_type = 0;
311
        }
312
313 3
        $reportDetails = array_merge(
314 3
            $reportDetails,
315
            [
316 3
                'status' => 'new',
317 3
                'location' => $location,
318 3
                'linenumber' => is_null($linenumber) ? 0 : $linenumber,
319 3
                'pma_version' => $this->getStrippedPmaVersion($bugReport['pma_version']),
320 3
                'exception_type' => $exception_type,
321
            ]
322
        );
323
324 3
        return $reportDetails;
325
    }
326
327
    /**
328
     * creates the incident data from the submitted bug report.
329
     *
330
     * @param array $bugReport the bug report the report record is being created for
331
     *
332
     * @return array an array of schematized incident.
333
     *               Can be used with Incident->save
334
     */
335 3
    protected function _getSchematizedIncidents($bugReport)
336
    {
337
        //$bugReport = Sanitize::clean($bugReport, array('escape' => false));
338 3
        $schematizedReports = [];
339
        $schematizedCommonReport = [
340 3
            'pma_version' => $this->getStrippedPmaVersion($bugReport['pma_version']),
341 3
            'php_version' => $this->_getSimpleVersion($bugReport['php_version'], 2),
342 3
            'browser' => $bugReport['browser_name'] . ' '
343 3
                    . $this->_getSimpleVersion($bugReport['browser_version'], 1),
344 3
            'user_os' => $bugReport['user_os'],
345 3
            'locale' => $bugReport['locale'],
346 3
            'configuration_storage' => $bugReport['configuration_storage'],
347 3
            'server_software' => $this->_getServer($bugReport['server_software']),
348 3
            'full_report' => json_encode($bugReport),
349
        ];
350
351 3
        if (isset($bugReport['exception_type'])
352 3
            && $bugReport['exception_type'] == 'php'
353
        ) {
354
            // for each "errors"
355 3
            foreach ($bugReport['errors'] as $error) {
356 3
                $tmpReport = array_merge(
357 3
                    $schematizedCommonReport,
358
                    [
359 3
                        'error_name' => $error['type'],
360 3
                        'error_message' => $error['msg'],
361 3
                        'script_name' => $error['file'],
362 3
                        'stacktrace' => json_encode($error['stackTrace']),
363 3
                        'stackhash' => $error['stackhash'],
364 3
                        'exception_type' => 1,         // 'php'
365
                    ]
366
                );
367 3
                array_push($schematizedReports, $tmpReport);
368
            }
369
        } else {
370 3
            $tmpReport = array_merge(
371 3
                $schematizedCommonReport,
372
                [
373 3
                    'error_name' => $bugReport['exception']['name'],
374 3
                    'error_message' => $bugReport['exception']['message'],
375 3
                    'script_name' => $bugReport['script_name'],
376 3
                    'stacktrace' => json_encode($bugReport['exception']['stack']),
377 3
                    'stackhash' => $this->getStackHash($bugReport['exception']['stack']),
378 3
                    'exception_type' => 0,     //'js'
379
                ]
380
            );
381
382 3
            if (isset($bugReport['steps'])) {
383 3
                $tmpReport['steps'] = $bugReport['steps'];
384
            }
385 3
            array_push($schematizedReports, $tmpReport);
386
        }
387
388 3
        return $schematizedReports;
389
    }
390
391
    /**
392
     * Gets the identifiying location info from a stacktrace.
393
     *
394
     * This is used to skip stacktrace levels that are within the error reporting js
395
     * files that sometimes appear in the stacktrace but are not related to the bug
396
     * report
397
     *
398
     * returns two things in an array:
399
     * - the first element is the filename/scriptname of the error
400
     * - the second element is the linenumber of the error
401
     *
402
     * @param array $stacktrace the stacktrace being examined
403
     *
404
     * @return array an array with the filename/scriptname and linenumber of the
405
     *               error
406
     */
407 5
    protected function _getIdentifyingLocation($stacktrace)
408
    {
409
        $fallback = [
410 5
            'UNKNOWN',
411
            0,
412
        ];
413 5
        foreach ($stacktrace as $level) {
414 5
            if (isset($level['filename'])) {
415
                // ignore unrelated files that sometimes appear in the error report
416 5
                if ($level['filename'] === 'tracekit/tracekit.js') {
417 1
                    continue;
418 5
                } elseif ($level['filename'] === 'error_report.js') {
419
                    // in case the error really is in the error_report.js file save it for
420
                    // later
421 1
                    if ($fallback[0] == 'UNKNOWN') {
422
                        $fallback = [
423 1
                            $level['filename'],
424 1
                            $level['line'],
425
                        ];
426
                    }
427 1
                    continue;
428
                }
429
430
                return [
431 5
                    $level['filename'],
432 5
                    $level['line'],
433
                ];
434 1
            } elseif (isset($level['scriptname'])) {
435
                return [
436 1
                    $level['scriptname'],
437 1
                    $level['line'],
438
                ];
439
            }
440 1
            continue;
441
        }
442
443 1
        return $fallback;
444
    }
445
446
    /**
447
     * Gets a part of a version string according to the specified version Length.
448
     *
449
     * @param string $versionString the version string
450
     * @param string $versionLength the number of version components to return. eg
451
     *                              1 for major version only and 2 for major and
452
     *                              minor version
453
     *
454
     * @return string the major and minor version part
455
     */
456 4
    protected function _getSimpleVersion($versionString, $versionLength)
457
    {
458 4
        $versionLength = (int) $versionLength;
459 4
        if ($versionLength < 1) {
460 1
            $versionLength = 1;
461
        }
462
        /* modify the re to accept a variable number of version components. I
463
         * atleast take one component and optionally get more components if need be.
464
         * previous code makes sure that the $versionLength variable is a positive
465
         * int
466
         */
467 4
        $result = preg_match(
468 4
            "/^(\d+\.){" . ($versionLength - 1) . "}\d+/",
469 4
            $versionString,
470 4
            $matches
471
        );
472 4
        if ($result) {
473 4
            $simpleVersion = $matches[0];
474
475 4
            return $simpleVersion;
476
        }
477
478
        return $versionString;
479
    }
480
481
    /**
482
     * Returns the version string stripped of
483
     * 'deb', 'ubuntu' and other suffixes
484
     *
485
     * @param string $versionString phpMyAdmin version
486
     *
487
     * @return string stripped phpMyAdmin version
488
     */
489 12
    public function getStrippedPmaVersion($versionString)
490
    {
491 12
        $allowedRegexp = '/^(\d+)(\.\d+){0,3}(\-.*)?/';
492 12
        $matches = [];
493
494
        // Check if $versionString matches the regexp
495
        // and store the matched strings
496 12
        if (preg_match($allowedRegexp, $versionString, $matches)) {
497 12
            return $matches[0];
498
        }
499
500
        // If $versionString does not match the regexp at all,
501
        // leave it as it is
502
        return $versionString;
503
    }
504
505
    /**
506
     * Gets the server name and version from the server signature.
507
     *
508
     * @param string $signature the server signature
509
     *
510
     * @return string the server name and version or UNKNOWN
511
     */
512 4
    protected function _getServer($signature)
513
    {
514 4
        if (preg_match(
515
            "/(apache\/\d+\.\d+)|(nginx\/\d+\.\d+)|(iis\/\d+\.\d+)"
516 4
                . "|(lighttpd\/\d+\.\d+)/i",
517 4
            $signature,
518 4
            $matches
519
        )) {
520 4
            return $matches[0];
521
        }
522
523 1
        return 'UNKNOWN';
524
    }
525
526
    /**
527
     * returns the hash pertaining to a stacktrace.
528
     *
529
     * @param array $stacktrace the stacktrace in question
530
     *
531
     * @return string the hash string of the stacktrace
532
     */
533 4
    public function getStackHash($stacktrace)
534
    {
535 4
        $handle = hash_init('md5');
536 4
        foreach ($stacktrace as $level) {
537
            $elements = [
538 4
                'filename',
539
                'scriptname',
540
                'line',
541
                'func',
542
                'column',
543
            ];
544 4
            foreach ($elements as $element) {
545 4
                if (! isset($level[$element])) {
546 4
                    continue;
547
                }
548 4
                hash_update($handle, $level[$element]);
549
            }
550
        }
551
552 4
        return hash_final($handle);
553
    }
554
555
    /**
556
     * Checks the length of stacktrace and full_report
557
     * and logs if it is greater than what it can hold
558
     *
559
     * @param array $si           submitted incident
560
     * @param array $incident_ids incident IDs
561
     *
562
     * @return array $incident_ids
563
     */
564 2
    private function _logLongIncidentSubmissions($si, &$incident_ids)
565
    {
566
567 2
        $stacktraceLength = mb_strlen($si['stacktrace']);
568 2
        $fullReportLength = mb_strlen($si['full_report']);
569 2
        $errorMessageLength = mb_strlen($si['error_message']);
570
571 2
        if ($stacktraceLength > 65535
572 2
            || $fullReportLength > 65535
573 2
            || $errorMessageLength > 200 // length of field in 'incidents' table
574
        ) {
575
            // If length of report is longer than
576
            // what can fit in the table field,
577
            // we log it and don't save it in the database
578 1
            Log::error(
579
                'Too long data submitted in the incident. The length of stacktrace: '
580 1
                . $stacktraceLength . ', the length of bug report: '
581 1
                . $fullReportLength . ', the length of error message: '
582 1
                . $errorMessageLength . '. The full incident reported was as follows: '
583 1
                . json_encode($si)
584
            );
585
586
            // add a 'false' to the return array
587 1
            array_push($incident_ids, false);
588
        }
589 2
    }
590
}
591