Passed
Push — master ( 8072b8...acfa49 )
by William
04:14
created

IncidentsTable::getServer()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

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