Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.56% covered (warning)
87.56%
190 / 217
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryAbuseLog
87.56% covered (warning)
87.56%
190 / 217
66.67% covered (warning)
66.67%
2 / 3
76.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
86.15% covered (warning)
86.15%
168 / 195
0.00% covered (danger)
0.00%
0 / 1
66.93
 addUserFilter
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 getAllowedParams
n/a
0 / 0
n/a
0 / 0
2
 getExamplesMessages
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 */
18
19namespace MediaWiki\Extension\AbuseFilter\Api;
20
21use InvalidArgumentException;
22use MediaWiki\Api\ApiBase;
23use MediaWiki\Api\ApiQuery;
24use MediaWiki\Api\ApiQueryBase;
25use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
26use MediaWiki\Extension\AbuseFilter\AbuseLogConditionFactory;
27use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
28use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
29use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
30use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
31use MediaWiki\Extension\AbuseFilter\FilterLookup;
32use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
33use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
34use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
35use MediaWiki\Extension\AbuseFilter\TemporaryAccountIPsViewerSpecification;
36use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
37use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
38use MediaWiki\Permissions\Authority;
39use MediaWiki\Title\Title;
40use MediaWiki\User\UserFactory;
41use MediaWiki\User\UserIdentityValue;
42use MediaWiki\Utils\MWTimestamp;
43use Wikimedia\IPUtils;
44use Wikimedia\ParamValidator\ParamValidator;
45use Wikimedia\ParamValidator\TypeDef\IntegerDef;
46use Wikimedia\Rdbms\ReadOnlyMode;
47
48/**
49 * Query module to list abuse log entries.
50 *
51 * @copyright 2009 Alex Z. <mrzmanwiki AT gmail DOT com>
52 * Based mostly on code by Bryan Tong Minh and Roan Kattouw
53 *
54 * @ingroup API
55 * @ingroup Extensions
56 */
57class QueryAbuseLog extends ApiQueryBase {
58
59    public function __construct(
60        ApiQuery $query,
61        string $moduleName,
62        private readonly FilterLookup $afFilterLookup,
63        private readonly AbuseFilterPermissionManager $afPermManager,
64        private readonly VariablesBlobStore $afVariablesBlobStore,
65        private readonly VariablesManager $afVariablesManager,
66        private readonly UserFactory $userFactory,
67        private readonly AbuseLoggerFactory $abuseLoggerFactory,
68        private readonly RuleCheckerFactory $ruleCheckerFactory,
69        private readonly AbuseLogConditionFactory $abuseLogConditionFactory,
70        private readonly TemporaryAccountIPsViewerSpecification $tempAccountIPsViewerSpecification,
71        private readonly ReadOnlyMode $readOnlyMode,
72    ) {
73        parent::__construct( $query, $moduleName, 'afl' );
74    }
75
76    /**
77     * @inheritDoc
78     */
79    public function execute() {
80        $lookup = $this->afFilterLookup;
81
82        // Same check as in SpecialAbuseLog
83        $this->checkUserRightsAny( 'abusefilter-log' );
84
85        $performer = $this->getAuthority();
86        $params = $this->extractRequestParams();
87
88        $prop = array_fill_keys( $params['prop'], true );
89        $fld_ids = isset( $prop['ids'] );
90        $fld_filter = isset( $prop['filter'] );
91        $fld_user = isset( $prop['user'] );
92        $fld_title = isset( $prop['title'] );
93        $fld_action = isset( $prop['action'] );
94        $fld_details = isset( $prop['details'] );
95        $fld_result = isset( $prop['result'] );
96        $fld_timestamp = isset( $prop['timestamp'] );
97        $fld_hidden = isset( $prop['hidden'] );
98        $fld_revid = isset( $prop['revid'] );
99        $isCentral = $this->getConfig()->get( 'AbuseFilterIsCentral' );
100        $fld_wiki = $isCentral && isset( $prop['wiki'] );
101
102        if ( $fld_details ) {
103            $this->checkUserRightsAny( 'abusefilter-log-detail' );
104        }
105
106        $canViewPrivate = $this->afPermManager->canViewPrivateFiltersLogs( $performer );
107        $canViewSuppressed = $this->afPermManager->canViewSuppressed( $performer );
108
109        // Map of [ [ id, global ], ... ]
110        $searchFilters = [];
111        // Match permissions for viewing events on private filters to SpecialAbuseLog (bug 42814)
112        // @todo Avoid code duplication with SpecialAbuseLog::showList, make it so that, if hidden
113        // filters are specified, we only filter them out instead of failing.
114        if ( $params['filter'] ) {
115            if ( !is_array( $params['filter'] ) ) {
116                $params['filter'] = [ $params['filter'] ];
117            }
118            $foundInvalid = false;
119            foreach ( $params['filter'] as $filter ) {
120                try {
121                    $searchFilters[] = GlobalNameUtils::splitGlobalName( $filter );
122                } catch ( InvalidArgumentException ) {
123                    $foundInvalid = true;
124                    continue;
125                }
126            }
127
128            foreach ( $searchFilters as [ $filterID, $global ] ) {
129                try {
130                    $filter = $lookup->getFilter( $filterID, $global );
131                    $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
132                    $usedVariables = $ruleChecker->getUsedVars( $filter->getRules() );
133                } catch ( CentralDBNotAvailableException ) {
134                    // Conservatively assume that it's suppressed, hidden and protected,
135                    // like in AbuseLogPager::doFormatRow.
136                    // Also assume that the filter contains all protected variables for the same reasons.
137                    $filter = MutableFilter::newDefault();
138                    $filter->setHidden( true );
139                    $filter->setProtected( true );
140                    $filter->setSuppressed( true );
141                    $usedVariables = $this->afPermManager->getProtectedVariables();
142                } catch ( FilterNotFoundException ) {
143                    // If no filter is found, assume it has no restrictions (is public and uses no protected
144                    // variables) because it should be an non-existing filter ID.
145                    $filter = MutableFilter::newDefault();
146                    $usedVariables = [];
147                    $foundInvalid = true;
148                }
149
150                if ( !$canViewSuppressed && $filter->isSuppressed() ) {
151                    $this->dieWithError(
152                        [ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-suppressed' ) ]
153                    );
154                }
155
156                if ( !$canViewPrivate && $filter->isHidden() ) {
157                    $this->dieWithError(
158                        [ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-private' ) ]
159                    );
160                }
161
162                if ( $filter->isProtected() ) {
163                    $protectedVariableAccessStatus = $this->afPermManager
164                        ->canViewProtectedVariables( $performer, $usedVariables );
165                    if ( !$protectedVariableAccessStatus->isGood() ) {
166                        if ( $protectedVariableAccessStatus->getBlock() ) {
167                            $this->dieWithError( 'apierror-blocked', 'blocked' );
168                        }
169                        if ( $protectedVariableAccessStatus->getPermission() ) {
170                            $this->dieWithError(
171                                [
172                                    'apierror-permissiondenied',
173                                    $this->msg( "action-{$protectedVariableAccessStatus->getPermission()}" )->plain()
174                                ],
175                                'permissiondenied'
176                            );
177                        }
178
179                        $this->dieStatus( $protectedVariableAccessStatus );
180                    }
181                }
182            }
183
184            if ( $foundInvalid ) {
185                // @todo Tell what the invalid IDs are
186                $this->addWarning( 'abusefilter-log-invalid-filter' );
187            }
188        }
189
190        $result = $this->getResult();
191
192        $this->addTables( 'abuse_filter_log' );
193        $this->addFields( 'afl_timestamp' );
194        $this->addFields( 'afl_rev_id' );
195        $this->addFields( 'afl_deleted' );
196        $this->addFields( 'afl_filter_id' );
197        $this->addFields( 'afl_global' );
198        $this->addFields( 'afl_ip_hex' );
199        $this->addFieldsIf( 'afl_id', $fld_ids );
200        $this->addFieldsIf( 'afl_user_text', $fld_user );
201        $this->addFieldsIf( [ 'afl_namespace', 'afl_title' ], $fld_title );
202        $this->addFieldsIf( 'afl_action', $fld_action );
203        $this->addFieldsIf( 'afl_var_dump', $fld_details );
204        $this->addFieldsIf( 'afl_actions', $fld_result );
205        $this->addFieldsIf( 'afl_wiki', $fld_wiki );
206
207        $this->addOption( 'LIMIT', $params['limit'] + 1 );
208
209        $this->addWhereIf( [ 'afl_id' => $params['logid'] ], isset( $params['logid'] ) );
210
211        $this->addWhereRange( 'afl_timestamp', $params['dir'], $params['start'], $params['end'] );
212
213        if ( isset( $params['user'] ) ) {
214            $this->addUserFilter( $performer, $params['user'] );
215        }
216
217        $this->addWhereIf( [ 'afl_deleted' => 0 ], !$this->afPermManager->canSeeHiddenLogEntries( $performer ) );
218
219        if ( $searchFilters ) {
220            // @todo Avoid code duplication with SpecialAbuseLog::showList
221            $filterConds = [ 'local' => [], 'global' => [] ];
222            foreach ( $searchFilters as $filter ) {
223                $isGlobal = $filter[1];
224                $key = $isGlobal ? 'global' : 'local';
225                $filterConds[$key][] = $filter[0];
226            }
227            $dbr = $this->getDB();
228            $conds = [];
229            if ( $filterConds['local'] ) {
230                $conds[] = $dbr->andExpr( [
231                    'afl_global' => 0,
232                    'afl_filter_id' => $filterConds['local'],
233                ] );
234            }
235            if ( $filterConds['global'] ) {
236                $conds[] = $dbr->andExpr( [
237                    'afl_global' => 1,
238                    'afl_filter_id' => $filterConds['global'],
239                ] );
240            }
241            $this->addWhere( $dbr->orExpr( $conds ) );
242        }
243
244        if ( isset( $params['wiki'] ) ) {
245            // 'wiki' won't be set if $wgAbuseFilterIsCentral = false
246            $this->addWhereIf( [ 'afl_wiki' => $params['wiki'] ], $isCentral );
247        }
248
249        $title = $params['title'];
250        if ( $title !== null ) {
251            $titleObj = Title::newFromText( $title );
252            if ( $titleObj === null ) {
253                $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
254            }
255            $this->addWhereFld( 'afl_namespace', $titleObj->getNamespace() );
256            $this->addWhereFld( 'afl_title', $titleObj->getDBkey() );
257        }
258        $res = $this->select( __METHOD__ );
259
260        $count = 0;
261        foreach ( $res as $row ) {
262            if ( ++$count > $params['limit'] ) {
263                // We've had enough
264                $ts = new MWTimestamp( $row->afl_timestamp );
265                $this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) );
266                break;
267            }
268            $visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermManager );
269            if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
270                continue;
271            }
272
273            $filterID = $row->afl_filter_id;
274            $global = $row->afl_global;
275            $fullName = GlobalNameUtils::buildGlobalName( $filterID, $global );
276            $filterObj = $lookup->getFilter( $filterID, $global );
277            $canSeeDetails = $this->afPermManager->canSeeLogDetailsForFilter( $performer, $filterObj );
278
279            $entry = [];
280            if ( $fld_ids ) {
281                $entry['id'] = intval( $row->afl_id );
282                $entry['filter_id'] = $canSeeDetails ? $fullName : '';
283            }
284            if ( $fld_filter ) {
285                $entry['filter'] = $filterObj->getName();
286            }
287            if ( $fld_user ) {
288                $entry['user'] = $row->afl_user_text;
289            }
290            if ( $fld_wiki ) {
291                $entry['wiki'] = $row->afl_wiki;
292            }
293            if ( $fld_title ) {
294                $title = Title::makeTitle( $row->afl_namespace, $row->afl_title );
295                ApiQueryBase::addTitleInfo( $entry, $title );
296            }
297            if ( $fld_action ) {
298                $entry['action'] = $row->afl_action;
299            }
300            if ( $fld_result ) {
301                $entry['result'] = $row->afl_actions;
302            }
303            if ( $fld_revid && $row->afl_rev_id !== null ) {
304                $entry['revid'] = $canSeeDetails ? (int)$row->afl_rev_id : '';
305            }
306            if ( $fld_timestamp ) {
307                $ts = new MWTimestamp( $row->afl_timestamp );
308                $entry['timestamp'] = $ts->getTimestamp( TS_ISO_8601 );
309            }
310            if ( $fld_details ) {
311                $entry['details'] = [];
312                if ( $canSeeDetails ) {
313                    $vars = $this->afVariablesBlobStore->loadVarDump( $row );
314                    $varManager = $this->afVariablesManager;
315                    $entry['details'] = $varManager->exportAllVars( $vars );
316
317                    $usedProtectedVars = $this->afPermManager
318                        ->getUsedProtectedVariables( array_keys( $entry['details'] ) );
319                    if ( $usedProtectedVars ) {
320                        // Unset the variable if the user can't see protected variables.
321                        // Additionally, a protected variable is considered used if the key exists
322                        // but since it can have a null value, check isset before logging access
323                        $protectedVariableValuesShown = [];
324                        foreach ( $usedProtectedVars as $protectedVariable ) {
325                            if ( isset( $entry['details'][$protectedVariable] ) ) {
326                                if ( $this->afPermManager->canViewProtectedVariables(
327                                    $performer, [ $protectedVariable ]
328                                )->isGood() ) {
329                                    $protectedVariableValuesShown[] = $protectedVariable;
330                                } else {
331                                    $entry['details'][$protectedVariable] = '';
332                                }
333                            }
334                        }
335
336                        if ( $filterObj->isProtected() ) {
337                            // We need to log any access of protected variable values. If the site is
338                            // in read only or user_name or account_name don't exist, then just blank
339                            // the protected variable values because we cannot create a log
340                            if (
341                                !$this->readOnlyMode->isReadOnly() &&
342                                (
343                                    isset( $entry['details']['user_name'] ) ||
344                                    isset( $entry['details']['account_name'] )
345                                )
346                            ) {
347                                $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
348                                $logger->logViewProtectedVariableValue(
349                                    $performer->getUser(),
350                                    $entry['details']['user_name'] ?? $entry['details']['account_name'],
351                                    $protectedVariableValuesShown
352                                );
353                            } else {
354                                foreach ( $usedProtectedVars as $protectedVariable ) {
355                                    if ( isset( $entry['details'][$protectedVariable] ) ) {
356                                        $entry['details'][$protectedVariable] = '';
357                                    }
358                                }
359                            }
360
361                        }
362                    }
363                }
364            }
365
366            if ( $fld_hidden ) {
367                $entry['hidden'] = (bool)$row->afl_deleted;
368            }
369
370            if ( $entry ) {
371                $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry );
372                if ( !$fit ) {
373                    $ts = new MWTimestamp( $row->afl_timestamp );
374                    $this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) );
375                    break;
376                }
377            }
378        }
379        $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' );
380    }
381
382    /**
383     * Updates the query params and the internal query builder to include the
384     * parameters and clauses required for filtering out entries not associated
385     * with the provided username, IP or IP range.
386     *
387     * @param Authority $performer The authority listing the AF logs.
388     * @param string $userName Username or IP address to filter for.
389     * @return void
390     */
391    private function addUserFilter( Authority $performer, string $userName ): void {
392        if ( IPUtils::isIPAddress( $userName ) ) {
393            $cleanIP = IPUtils::sanitizeIP( $userName );
394            $canAccessTempAccountIPs =
395                $this->tempAccountIPsViewerSpecification->isSatisfiedBy(
396                    $performer
397                );
398
399            if ( !$canAccessTempAccountIPs ) {
400                // Gets entries associated with anonymous users identified by
401                // their IPs (i.e. filter by afl_user_text; legacy behaviour).
402                $expression = $this->abuseLogConditionFactory
403                    ->getUserFilterByUserIdentity(
404                        new UserIdentityValue( 0, $cleanIP )
405                    );
406            } else {
407                // Gets entries associated with anonymous users identified by
408                // their IPs (i.e. filter by afl_user_text; legacy behavior) as
409                // well as entries associated with temp accounts under the
410                // provided IP (i.e. filter by afl_ip_hex).
411                $expression = $this->abuseLogConditionFactory
412                    ->getUserFilterByIPAddress( $cleanIP );
413            }
414
415            if ( $expression ) {
416                $this->getQueryBuilder()->conds( $expression );
417            }
418        } else {
419            $user = $this->userFactory->newFromName( $userName );
420
421            if ( $user ) {
422                $expression = $this->abuseLogConditionFactory
423                    ->getUserFilterByUserIdentity( $user );
424
425                if ( $expression ) {
426                    $this->addWhere( $expression );
427                }
428            }
429        }
430    }
431
432    /**
433     * @codeCoverageIgnore Merely declarative
434     * @inheritDoc
435     */
436    public function getAllowedParams() {
437        $params = [
438            'logid' => [
439                ParamValidator::PARAM_TYPE => 'integer'
440            ],
441            'start' => [
442                ParamValidator::PARAM_TYPE => 'timestamp'
443            ],
444            'end' => [
445                ParamValidator::PARAM_TYPE => 'timestamp'
446            ],
447            'dir' => [
448                ParamValidator::PARAM_TYPE => [
449                    'newer',
450                    'older'
451                ],
452                ParamValidator::PARAM_DEFAULT => 'older',
453                ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
454            ],
455            'user' => null,
456            'title' => null,
457            'filter' => [
458                ParamValidator::PARAM_TYPE => 'string',
459                ParamValidator::PARAM_ISMULTI => true,
460                ApiBase::PARAM_HELP_MSG => [
461                    'apihelp-query+abuselog-param-filter',
462                    GlobalNameUtils::GLOBAL_FILTER_PREFIX
463                ]
464            ],
465            'limit' => [
466                ParamValidator::PARAM_DEFAULT => 10,
467                ParamValidator::PARAM_TYPE => 'limit',
468                IntegerDef::PARAM_MIN => 1,
469                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
470                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
471            ],
472            'prop' => [
473                ParamValidator::PARAM_DEFAULT => 'ids|user|title|action|result|timestamp|hidden|revid',
474                ParamValidator::PARAM_TYPE => [
475                    'ids',
476                    'filter',
477                    'user',
478                    'title',
479                    'action',
480                    'details',
481                    'result',
482                    'timestamp',
483                    'hidden',
484                    'revid',
485                ],
486                ParamValidator::PARAM_ISMULTI => true
487            ]
488        ];
489        if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
490            $params['wiki'] = [
491                ParamValidator::PARAM_TYPE => 'string',
492            ];
493            $params['prop'][ParamValidator::PARAM_DEFAULT] .= '|wiki';
494            $params['prop'][ParamValidator::PARAM_TYPE][] = 'wiki';
495            $params['filter'][ApiBase::PARAM_HELP_MSG] = 'apihelp-query+abuselog-param-filter-central';
496        }
497        return $params;
498    }
499
500    /**
501     * @codeCoverageIgnore Merely declarative
502     * @inheritDoc
503     */
504    protected function getExamplesMessages() {
505        return [
506            'action=query&list=abuselog'
507                => 'apihelp-query+abuselog-example-1',
508            'action=query&list=abuselog&afltitle=API'
509                => 'apihelp-query+abuselog-example-2',
510        ];
511    }
512}