Issues (196)

Security Analysis    6 potential vulnerabilities

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection (4)
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection (1)
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

src/Model/Pages.php (4 issues)

Labels
Severity
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use App\Repository\PagesRepository;
8
use DateTime;
9
10
/**
11
 * A Pages provides statistics about the pages created by a given User.
12
 */
13
class Pages extends Model
14
{
15
    private const RESULTS_LIMIT_SINGLE_NAMESPACE = 1000;
16
    private const RESULTS_LIMIT_ALL_NAMESPACES = 50;
17
18
    public const REDIR_NONE = 'noredirects';
19
    public const REDIR_ONLY = 'onlyredirects';
20
    public const REDIR_ALL = 'all';
21
    public const DEL_NONE = 'live';
22
    public const DEL_ONLY = 'deleted';
23
    public const DEL_ALL = 'all';
24
25
    /** @var string One of the self::REDIR_ constants of this class. */
26
    protected string $redirects;
27
28
    /** @var string One of the self::DEL_ constants of this class. */
29
    protected string $deleted;
30
31
    /** @var array The list of pages including various statistics, keyed by namespace. */
32
    protected array $pages;
33
34
    /** @var array Number of redirects/pages that were created/deleted, broken down by namespace. */
35
    protected array $countsByNamespace;
36
37
    /**
38
     * Pages constructor.
39
     * @param PagesRepository $repository
40
     * @param Project $project
41
     * @param User $user
42
     * @param string|int $namespace Namespace ID or 'all'.
43
     * @param string $redirects One of the Pages::REDIR_ constants.
44
     * @param string $deleted One of the Pages::DEL_ constants.
45
     * @param int|false $start Start date as Unix timestamp.
46
     * @param int|false $end End date as Unix timestamp.
47
     * @param int|false $offset Unix timestamp. Used for pagination.
48
     */
49
    public function __construct(
50
        PagesRepository $repository,
51
        Project $project,
52
        User $user,
53
        $namespace = 0,
54
        string $redirects = self::REDIR_NONE,
55
        string $deleted = self::DEL_ALL,
56
        $start = false,
57
        $end = false,
58
        $offset = false
59
    ) {
60
        $this->repository = $repository;
61
        $this->project = $project;
62
        $this->user = $user;
63
        $this->namespace = 'all' === $namespace ? 'all' : (int)$namespace;
64
        $this->start = $start;
65
        $this->end = $end;
66
        $this->redirects = $redirects ?: self::REDIR_NONE;
67
        $this->deleted = $deleted ?: self::DEL_ALL;
68
        $this->offset = $offset;
69
    }
70
71
    /**
72
     * The redirects option associated with this Pages instance.
73
     * @return string
74
     */
75
    public function getRedirects(): string
76
    {
77
        return $this->redirects;
78
    }
79
80
    /**
81
     * The deleted pages option associated with this Page instance.
82
     * @return string
83
     */
84
    public function getDeleted(): string
85
    {
86
        return $this->deleted;
87
    }
88
89
    /**
90
     * Fetch and prepare the pages created by the user.
91
     * @param bool $all Whether to get *all* results. This should only be used for
92
     *     export options. HTTP and JSON should paginate.
93
     * @return array
94
     * @codeCoverageIgnore
95
     */
96
    public function prepareData(bool $all = false): array
97
    {
98
        $this->pages = [];
99
100
        foreach ($this->getNamespaces() as $ns) {
101
            $data = $this->fetchPagesCreated($ns, $all);
102
            $this->pages[$ns] = count($data) > 0
103
                ? $this->formatPages($data)[$ns]
104
                : [];
105
        }
106
107
        return $this->pages;
108
    }
109
110
    /**
111
     * The public function to get the list of all pages created by the user,
112
     * up to self::resultsPerPage(), across all namespaces.
113
     * @param bool $all Whether to get *all* results. This should only be used for
114
     *     export options. HTTP and JSON should paginate.
115
     * @return array
116
     */
117
    public function getResults(bool $all = false): array
118
    {
119
        if (!isset($this->pages)) {
120
            $this->prepareData($all);
121
        }
122
        return $this->pages;
123
    }
124
125
    /**
126
     * Return a ISO 8601 timestamp of the last result. This is used for pagination purposes.
127
     * @return string|null
128
     */
129
    public function getLastTimestamp(): ?string
130
    {
131
        if ($this->isMultiNamespace()) {
132
            // No pagination in multi-namespace view.
133
            return null;
134
        }
135
136
        $numResults = count($this->getResults()[$this->getNamespace()]);
137
        $timestamp = new DateTime($this->getResults()[$this->getNamespace()][$numResults - 1]['timestamp']);
138
        return $timestamp->format('Y-m-d\TH:i:s\Z');
139
    }
140
141
    /**
142
     * Get the total number of pages the user has created.
143
     * @return int
144
     */
145
    public function getNumPages(): int
146
    {
147
        $total = 0;
148
        foreach (array_values($this->getCounts()) as $values) {
149
            $total += $values['count'];
150
        }
151
        return $total;
152
    }
153
154
    /**
155
     * Get the total number of pages we're showing data for.
156
     * @return int
157
     */
158
    public function getNumResults(): int
159
    {
160
        $total = 0;
161
        foreach (array_values($this->getResults()) as $pages) {
162
            $total += count($pages);
163
        }
164
        return $total;
165
    }
166
167
    /**
168
     * Get the total number of pages that are currently deleted.
169
     * @return int
170
     */
171
    public function getNumDeleted(): int
172
    {
173
        $total = 0;
174
        foreach (array_values($this->getCounts()) as $values) {
175
            $total += $values['deleted'];
176
        }
177
        return $total;
178
    }
179
180
    /**
181
     * Get the total number of pages that are currently redirects.
182
     * @return int
183
     */
184
    public function getNumRedirects(): int
185
    {
186
        $total = 0;
187
        foreach (array_values($this->getCounts()) as $values) {
188
            $total += $values['redirects'];
189
        }
190
        return $total;
191
    }
192
193
    /**
194
     * Get the namespaces in which this user has created pages.
195
     * @return int[] The IDs.
196
     */
197
    public function getNamespaces(): array
198
    {
199
        return array_keys($this->getCounts());
200
    }
201
202
    /**
203
     * Number of namespaces being reported.
204
     * @return int
205
     */
206
    public function getNumNamespaces(): int
207
    {
208
        return count(array_keys($this->getCounts()));
209
    }
210
211
    /**
212
     * Are there more than one namespace in the results?
213
     * @return bool
214
     */
215
    public function isMultiNamespace(): bool
216
    {
217
        return $this->getNumNamespaces() > 1 || ('all' === $this->getNamespace() && 1 === $this->getNumNamespaces());
218
    }
219
220
    /**
221
     * Get the sum of all page sizes, across all specified namespaces.
222
     * @return int
223
     */
224
    public function getTotalPageSize(): int
225
    {
226
        return array_sum(array_column($this->getCounts(), 'total_length'));
227
    }
228
229
    /**
230
     * Get average size across all pages.
231
     * @return float
232
     */
233
    public function averagePageSize(): float
234
    {
235
        return $this->getTotalPageSize() / $this->getNumPages();
236
    }
237
238
    /**
239
     * Number of redirects/pages that were created/deleted, broken down by namespace.
240
     * @return array Namespace IDs as the keys, with values 'count', 'deleted' and 'redirects'.
241
     */
242
    public function getCounts(): array
243
    {
244
        if (isset($this->countsByNamespace)) {
245
            return $this->countsByNamespace;
246
        }
247
248
        $counts = [];
249
250
        foreach ($this->countPagesCreated() as $row) {
251
            $ns = (int)$row['namespace'];
252
            $count = (int)$row['count'];
253
            $totalLength = (int)$row['total_length'];
254
            $counts[$ns] = [
255
                'count' => $count,
256
                'total_length' => $totalLength,
257
                'avg_length' => round($count > 0 ? $totalLength / $count : 0, 1),
258
            ];
259
            if (self::DEL_NONE !== $this->deleted) {
260
                $counts[$ns]['deleted'] = (int)$row['deleted'];
261
            }
262
            if (self::REDIR_NONE !== $this->redirects) {
263
                $counts[$ns]['redirects'] = (int)$row['redirects'];
264
            }
265
        }
266
267
        $this->countsByNamespace = $counts;
268
        return $this->countsByNamespace;
269
    }
270
271
    /**
272
     * Get the number of pages the user created by assessment.
273
     * @return array Keys are the assessment class, values are the counts.
274
     */
275
    public function getAssessmentCounts(): array
276
    {
277
        if ($this->getNumPages() > $this->resultsPerPage()) {
278
            $counts = $this->repository->getAssessmentCounts(
0 ignored issues
show
The method getAssessmentCounts() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PagesRepository. ( Ignorable by Annotation )

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

278
            /** @scrutinizer ignore-call */ 
279
            $counts = $this->repository->getAssessmentCounts(
Loading history...
279
                $this->project,
280
                $this->user,
281
                $this->namespace,
282
                $this->redirects
283
            );
284
        } else {
285
            $counts = [];
286
            foreach ($this->pages as $nsPages) {
287
                foreach ($nsPages as $page) {
288
                    if (!isset($counts[$page['assessment']['class'] ?? 'Unknown'])) {
289
                        $counts[$page['assessment']['class'] ?? 'Unknown'] = 1;
290
                    } else {
291
                        $counts[$page['assessment']['class'] ?? 'Unknown']++;
292
                    }
293
                }
294
            }
295
        }
296
297
        arsort($counts);
298
299
        return $counts;
300
    }
301
302
    /**
303
     * Number of results to show, depending on the namespace.
304
     * @param bool $all Whether to get *all* results. This should only be used for
305
     *     export options. HTTP and JSON should paginate.
306
     * @return int|false
307
     */
308
    public function resultsPerPage(bool $all = false)
309
    {
310
        if (true === $all) {
311
            return false;
312
        }
313
        if ('all' === $this->namespace) {
314
            return self::RESULTS_LIMIT_ALL_NAMESPACES;
315
        }
316
        return self::RESULTS_LIMIT_SINGLE_NAMESPACE;
317
    }
318
319
    /**
320
     * What columns to show in namespace totals table.
321
     * @return string[]
322
     */
323
    public function getSummaryColumns(): array
324
    {
325
        $order = ['namespace', 'pages', 'redirects', 'deleted', 'live', 'total-page-size', 'average-page-size'];
326
327
        $summaryColumns = ['namespace'];
328
        if (in_array($this->getDeleted(), [self::DEL_ALL, self::DEL_ONLY])) {
329
            $summaryColumns[] = 'deleted';
330
        }
331
        if (self::DEL_ALL === $this->getDeleted()) {
332
            $summaryColumns[] = 'live';
333
        }
334
        if (in_array($this->getRedirects(), [self::REDIR_ALL, self::REDIR_ONLY])) {
335
            $summaryColumns[] = 'redirects';
336
        }
337
        if (self::DEL_ONLY !== $this->getDeleted() && self::REDIR_ONLY !== $this->getRedirects()) {
338
            $summaryColumns[] = 'pages';
339
        }
340
341
        $summaryColumns[] = 'total-page-size';
342
        $summaryColumns[] = 'average-page-size';
343
344
        // Re-sort based on $order
345
        return array_values(array_filter($order, static function ($column) use ($summaryColumns) {
346
            return in_array($column, $summaryColumns);
347
        }));
348
    }
349
350
    /**
351
     * Get the deletion summary to be shown when hovering over the "Deleted" text in the UI.
352
     * @param int $namespace
353
     * @param string $pageTitle
354
     * @param string $offset
355
     * @return string|null null if no deletion summary is available.
356
     */
357
    public function getDeletionSummary(int $namespace, string $pageTitle, string $offset): ?string
358
    {
359
        $ret = $this->repository->getDeletionSummary($this->project, $namespace, $pageTitle, $offset);
0 ignored issues
show
The method getDeletionSummary() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PagesRepository. ( Ignorable by Annotation )

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

359
        /** @scrutinizer ignore-call */ 
360
        $ret = $this->repository->getDeletionSummary($this->project, $namespace, $pageTitle, $offset);
Loading history...
360
        if (!$ret) {
361
            return null;
362
        }
363
        $timestampStr = (new DateTime($ret['log_timestamp']))->format('Y-m-d H:i');
364
        $summary = Edit::wikifyString($ret['comment_text'], $this->project, $this->page, true);
365
        $userpageUrl = $this->project->getUrlForPage("User:{$ret['actor_name']}");
366
        return "$timestampStr (<a target='_blank' href=\"$userpageUrl\">{$ret['actor_name']}</a>): <i>$summary</i>";
367
    }
368
369
    /**
370
     * Run the query to get pages created by the user with options.
371
     * This is ran independently for each namespace if $this->namespace is 'all'.
372
     * @param int $namespace Namespace ID.
373
     * @param bool $all Whether to get *all* results. This should only be used for
374
     *     export options. HTTP and JSON should paginate.
375
     * @return array
376
     */
377
    private function fetchPagesCreated(int $namespace, bool $all = false): array
378
    {
379
        return $this->repository->getPagesCreated(
0 ignored issues
show
The method getPagesCreated() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PagesRepository. ( Ignorable by Annotation )

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

379
        return $this->repository->/** @scrutinizer ignore-call */ getPagesCreated(
Loading history...
380
            $this->project,
381
            $this->user,
382
            $namespace,
383
            $this->redirects,
384
            $this->deleted,
385
            $this->start,
386
            $this->end,
387
            $this->resultsPerPage($all),
388
            $this->offset
389
        );
390
    }
391
392
    /**
393
     * Run the query to get the number of pages created by the user with given options.
394
     * @return array
395
     */
396
    private function countPagesCreated(): array
397
    {
398
        return $this->repository->countPagesCreated(
0 ignored issues
show
The method countPagesCreated() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PagesRepository. ( Ignorable by Annotation )

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

398
        return $this->repository->/** @scrutinizer ignore-call */ countPagesCreated(
Loading history...
399
            $this->project,
400
            $this->user,
401
            $this->namespace,
402
            $this->redirects,
403
            $this->deleted,
404
            $this->start,
405
            $this->end
406
        );
407
    }
408
409
    /**
410
     * Format the data, adding page titles, assessment badges,
411
     * and sorting by namespace and then timestamp.
412
     * @param array $pages As returned by self::fetchPagesCreated()
413
     * @return array
414
     */
415
    private function formatPages(array $pages): array
416
    {
417
        $results = [];
418
419
        foreach ($pages as $row) {
420
            $fullPageTitle = $row['namespace'] > 0
421
                ? $this->project->getNamespaces()[$row['namespace']].':'.$row['page_title']
422
                : $row['page_title'];
423
            $pageData = [
424
                'deleted' => 'arc' === $row['type'],
425
                'namespace' => $row['namespace'],
426
                'page_title' => $row['page_title'],
427
                'full_page_title' => $fullPageTitle,
428
                'redirect' => (bool)$row['redirect'] || (bool)$row['was_redirect'],
429
                'timestamp' => $row['timestamp'],
430
                'rev_id' => $row['rev_id'],
431
                'rev_length' => $row['rev_length'],
432
                'length' => $row['length'],
433
            ];
434
435
            if ($row['recreated']) {
436
                $pageData['recreated'] = (bool)$row['recreated'];
437
            } else {
438
                // This is always NULL for live pages, in which case 'recreated' doesn't apply.
439
                unset($pageData['recreated']);
440
            }
441
442
            if ($this->project->hasPageAssessments()) {
443
                $attrs = $this->project
444
                    ->getPageAssessments()
445
                    ->getClassAttrs($row['pa_class'] ?: 'Unknown');
446
                $pageData['assessment'] = [
447
                    'class' => $row['pa_class'] ?: 'Unknown',
448
                    'badge' => $this->project
449
                        ->getPageAssessments()
450
                        ->getBadgeURL($row['pa_class'] ?: 'Unknown'),
451
                    'color' => $attrs['color'],
452
                    'category' => $attrs['category'],
453
                ];
454
            }
455
456
            $results[$row['namespace']][] = $pageData;
457
        }
458
459
        return $results;
460
    }
461
}
462