Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.42% covered (warning)
68.42%
156 / 228
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewExamine
68.42% covered (warning)
68.42%
156 / 228
28.57% covered (danger)
28.57%
2 / 7
90.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 show
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
7.14
 showSearch
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
2
 showResults
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 showExaminerForRC
88.57% covered (warning)
88.57%
31 / 35
0.00% covered (danger)
0.00%
0 / 1
9.12
 showExaminerForLogEntry
89.87% covered (warning)
89.87%
71 / 79
0.00% covered (danger)
0.00%
0 / 1
15.23
 showExaminer
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use LogicException;
6use MediaWiki\Context\IContextSource;
7use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
8use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
9use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
10use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
11use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
12use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
13use MediaWiki\Extension\AbuseFilter\FilterLookup;
14use MediaWiki\Extension\AbuseFilter\Pager\AbuseFilterExaminePager;
15use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
16use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
17use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
18use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
19use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
20use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
21use MediaWiki\Html\Html;
22use MediaWiki\HTMLForm\HTMLForm;
23use MediaWiki\Linker\LinkRenderer;
24use MediaWiki\RecentChanges\ChangesList;
25use MediaWiki\RecentChanges\RecentChange;
26use MediaWiki\RecentChanges\RecentChangeStore;
27use MediaWiki\Revision\RevisionRecord;
28use MediaWiki\Title\Title;
29use OOUI;
30use Wikimedia\Rdbms\LBFactory;
31
32class AbuseFilterViewExamine extends AbuseFilterView {
33    /**
34     * @var string The rules of the filter we're examining
35     */
36    private $testFilter;
37
38    public function __construct(
39        private readonly LBFactory $lbFactory,
40        AbuseFilterPermissionManager $afPermManager,
41        private readonly FilterLookup $filterLookup,
42        private readonly EditBoxBuilderFactory $boxBuilderFactory,
43        private readonly VariablesBlobStore $varBlobStore,
44        private readonly VariablesFormatter $variablesFormatter,
45        private readonly VariablesManager $varManager,
46        private readonly VariableGeneratorFactory $varGeneratorFactory,
47        private readonly AbuseLoggerFactory $abuseLoggerFactory,
48        private readonly RecentChangeStore $recentChangeStore,
49        IContextSource $context,
50        LinkRenderer $linkRenderer,
51        string $basePageName,
52        array $params
53    ) {
54        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
55        $this->variablesFormatter->setMessageLocalizer( $context );
56    }
57
58    /**
59     * Shows the page
60     */
61    public function show() {
62        $out = $this->getOutput();
63        $out->setPageTitleMsg( $this->msg( 'abusefilter-examine' ) );
64        $out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
65        if ( $this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
66            $out->addWikiMsg( 'abusefilter-examine-intro' );
67        } else {
68            $out->addWikiMsg( 'abusefilter-examine-intro-examine-only' );
69        }
70
71        $this->testFilter = $this->getRequest()->getText( 'testfilter' );
72
73        // Check if we've got a subpage
74        if ( count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) {
75            $this->showExaminerForRC( $this->mParams[1] );
76        } elseif ( count( $this->mParams ) > 2
77            && $this->mParams[1] === 'log'
78            && is_numeric( $this->mParams[2] )
79        ) {
80            $this->showExaminerForLogEntry( $this->mParams[2] );
81        } else {
82            $this->showSearch();
83        }
84    }
85
86    /**
87     * Shows the search form
88     */
89    public function showSearch() {
90        $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
91        $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
92        $max = wfTimestampNow();
93        $formDescriptor = [
94            'SearchUser' => [
95                'label-message' => 'abusefilter-test-user',
96                'type' => 'user',
97                'ipallowed' => true,
98            ],
99            'SearchPeriodStart' => [
100                'label-message' => 'abusefilter-test-period-start',
101                'type' => 'datetime',
102                'min' => $min,
103                'max' => $max,
104            ],
105            'SearchPeriodEnd' => [
106                'label-message' => 'abusefilter-test-period-end',
107                'type' => 'datetime',
108                'min' => $min,
109                'max' => $max,
110            ],
111        ];
112        HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
113            ->addHiddenField( 'testfilter', $this->testFilter )
114            ->setWrapperLegendMsg( 'abusefilter-examine-legend' )
115            ->setSubmitTextMsg( 'abusefilter-examine-submit' )
116            ->setSubmitCallback( [ $this, 'showResults' ] )
117            ->showAlways();
118    }
119
120    /**
121     * Show search results, called as submit callback by HTMLForm
122     * @param array $formData
123     * @param HTMLForm $form
124     * @return bool
125     */
126    public function showResults( array $formData, HTMLForm $form ): bool {
127        $changesList = new AbuseFilterChangesList( $this->getContext(), $this->testFilter );
128
129        $dbr = $this->lbFactory->getReplicaDatabase();
130        $conds = $this->buildVisibilityConditions( $dbr, $this->getAuthority() );
131        $conds[] = $this->buildTestConditions( $dbr );
132
133        // Normalise username
134        $userTitle = Title::newFromText( $formData['SearchUser'], NS_USER );
135        $userName = $userTitle ? $userTitle->getText() : '';
136
137        if ( $userName !== '' ) {
138            $rcQuery = RecentChange::getQueryInfo();
139            $conds[$rcQuery['fields']['rc_user_text']] = $userName;
140        }
141
142        $startTS = strtotime( $formData['SearchPeriodStart'] );
143        if ( $startTS ) {
144            $conds[] = $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $startTS ) );
145        }
146        $endTS = strtotime( $formData['SearchPeriodEnd'] );
147        if ( $endTS ) {
148            $conds[] = $dbr->expr( 'rc_timestamp', '<=', $dbr->timestamp( $endTS ) );
149        }
150        $pager = new AbuseFilterExaminePager(
151            $changesList,
152            $this->linkRenderer,
153            $this->recentChangeStore,
154            $dbr,
155            $this->getTitle( 'examine' ),
156            $conds
157        );
158
159        $output = $changesList->beginRecentChangesList()
160            . $pager->getNavigationBar()
161            . $pager->getBody()
162            . $pager->getNavigationBar()
163            . $changesList->endRecentChangesList();
164
165        $form->addPostHtml( $output );
166        return true;
167    }
168
169    /**
170     * @param int $rcid
171     */
172    public function showExaminerForRC( $rcid ) {
173        // Get data
174        $rc = $this->recentChangeStore->getRecentChangeById( $rcid );
175        $out = $this->getOutput();
176        if ( !$rc ) {
177            $out->addWikiMsg( 'abusefilter-examine-notfound' );
178            return;
179        }
180
181        if ( !ChangesList::userCan( $rc, RevisionRecord::SUPPRESSED_ALL ) ) {
182            $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' );
183            return;
184        }
185
186        $varGenerator = $this->varGeneratorFactory->newRCGenerator( $rc, $this->getUser() );
187        $vars = $varGenerator->getVars();
188        if ( !$vars ) {
189            $out->addWikiMsg( 'abusefilter-examine-incompatible' );
190            return;
191        }
192
193        // We compute all lazily loaded variables here, because we want to display all variables in ::showExaminer
194        $varsArray = $this->varManager->dumpAllVars( $vars, true );
195
196        // Filter out any protected variables that the user cannot see. Keep any protected variables that the user
197        // can see. This will unconditionally generate log entries when viewing the examiner that contains protected
198        // variables, as there is no way to specify the variables to select in the RecentChanges examiner.
199        $protectedVariableValuesShown = [];
200        foreach ( $this->afPermManager->getProtectedVariables() as $protectedVariable ) {
201            if ( array_key_exists( $protectedVariable, $varsArray ) ) {
202                // Try each variable at a time, as the user may be able to see some but not all of the
203                // protected variables.
204                $canViewProtectedVariable = $this->afPermManager
205                    ->canViewProtectedVariables( $this->getAuthority(), [ $protectedVariable ] )->isGood();
206                if ( !$canViewProtectedVariable ) {
207                    // Remove protected variables the user cannot see because they didn't specifically ask for them.
208                    unset( $varsArray[$protectedVariable] );
209                } else {
210                    // Only log if there was a value set. This is to be consistent with
211                    // self::showExaminerForLogEntry
212                    if ( $varsArray[$protectedVariable] !== null ) {
213                        $protectedVariableValuesShown[] = $protectedVariable;
214                    }
215                }
216            }
217        }
218        $vars = VariableHolder::newFromArray( $varsArray );
219
220        if ( count( $protectedVariableValuesShown ) ) {
221            $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
222            $logger->logViewProtectedVariableValue(
223                $this->getUser(),
224                $varsArray['user_name'] ?? $varsArray['accountname'],
225                $protectedVariableValuesShown
226            );
227        }
228
229        $out->addJsConfigVars( [
230            'abuseFilterExamine' => [ 'type' => 'rc', 'id' => $rcid ]
231        ] );
232
233        $this->showExaminer( $vars );
234    }
235
236    /**
237     * @param int $logid
238     */
239    public function showExaminerForLogEntry( $logid ) {
240        // Get data
241        $dbr = $this->lbFactory->getReplicaDatabase();
242        $performer = $this->getAuthority();
243        $out = $this->getOutput();
244
245        $row = $dbr->newSelectQueryBuilder()
246            ->select( [
247                'afl_deleted',
248                'afl_ip_hex',
249                'afl_var_dump',
250                'afl_rev_id',
251                'afl_filter_id',
252                'afl_global'
253            ] )
254            ->from( 'abuse_filter_log' )
255            ->where( [ 'afl_id' => $logid ] )
256            ->caller( __METHOD__ )
257            ->fetchRow();
258
259        if ( !$row ) {
260            $out->addWikiMsg( 'abusefilter-examine-notfound' );
261            return;
262        }
263
264        try {
265            $filter = $this->filterLookup->getFilter( $row->afl_filter_id, $row->afl_global );
266        } catch ( CentralDBNotAvailableException $_ ) {
267            // Conservatively assume that it's hidden and protected, like in AbuseLogPager::doFormatRow
268            $filter = MutableFilter::newDefault();
269            $filter->setProtected( true );
270            $filter->setHidden( true );
271        }
272        if ( !$this->afPermManager->canSeeLogDetailsForFilter( $performer, $filter ) ) {
273            $out->addWikiMsg( 'abusefilter-log-cannot-see-details' );
274            return;
275        }
276
277        $visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermManager );
278        if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
279            if ( $visibility === SpecialAbuseLog::VISIBILITY_HIDDEN ) {
280                $msg = 'abusefilter-log-details-hidden';
281            } elseif ( $visibility === SpecialAbuseLog::VISIBILITY_HIDDEN_IMPLICIT ) {
282                $msg = 'abusefilter-log-details-hidden-implicit';
283            } else {
284                throw new LogicException( "Unexpected visibility $visibility" );
285            }
286            $out->addWikiMsg( $msg );
287            return;
288        }
289
290        $vars = $this->varBlobStore->loadVarDump( $row );
291
292        // Check that the user can see the protected variables that are being examined if the filter is protected.
293        $userAuthority = $this->getAuthority();
294        if ( $filter->isProtected() ) {
295            $permStatus = $this->afPermManager->canViewProtectedVariables(
296                $userAuthority, array_keys( $vars->getVars() )
297            );
298            if ( !$permStatus->isGood() ) {
299                if ( $permStatus->getPermission() ) {
300                    $out->addWikiMsg(
301                        $this->msg(
302                            'abusefilter-examine-error-protected-due-to-permission',
303                            $this->msg( "action-{$permStatus->getPermission()}" )->plain()
304                        )
305                    );
306                    return;
307                }
308
309                // Add any messages in the status after a generic error message.
310                $additional = '';
311                foreach ( $permStatus->getMessages() as $message ) {
312                    $additional .= $this->msg( $message )->parseAsBlock();
313                }
314
315                $out->addWikiMsg(
316                    $this->msg( 'abusefilter-examine-error-protected' )->rawParams( $additional )
317                );
318                return;
319            }
320        }
321
322        // AbuseFilter logs created before T390086 may have protected variables present in the variable dump
323        // when the filter itself isn't protected. This is because a different filter matched against the
324        // a protected variable which caused the value to be added to the var dump for the public filter
325        // match.
326        // We shouldn't block access to the details of an otherwise public filter hit so
327        // instead only check for access to the protected variables and redact them if the user
328        // shouldn't see them.
329        $protectedVariableValuesShown = [];
330        $varsArray = $this->varManager->dumpAllVars( $vars, $this->afPermManager->getProtectedVariables() );
331        foreach ( $this->afPermManager->getProtectedVariables() as $protectedVariable ) {
332            if ( isset( $varsArray[$protectedVariable] ) ) {
333                // Try each variable at a time, as the user may be able to see some but not all of the
334                // protected variables. We only want to redact what is necessary to redact.
335                $canViewProtectedVariable = $this->afPermManager
336                    ->canViewProtectedVariables( $userAuthority, [ $protectedVariable ] )->isGood();
337                if ( !$canViewProtectedVariable ) {
338                    $varsArray[$protectedVariable] = '';
339                } else {
340                    $protectedVariableValuesShown[] = $protectedVariable;
341                }
342            }
343        }
344        $vars = VariableHolder::newFromArray( $varsArray );
345
346        if ( $filter->isProtected() ) {
347            $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
348            $logger->logViewProtectedVariableValue(
349                $userAuthority->getUser(),
350                $varsArray['user_name'] ?? $varsArray['accountname'],
351                $protectedVariableValuesShown
352            );
353        }
354
355        $out->addJsConfigVars( [
356            'abuseFilterExamine' => [ 'type' => 'log', 'id' => $logid ]
357        ] );
358        $this->showExaminer( $vars );
359    }
360
361    public function showExaminer( VariableHolder $vars ) {
362        $output = $this->getOutput();
363        $output->enableOOUI();
364
365        $html = '';
366
367        $output->addModules( 'ext.abuseFilter.examine' );
368        $output->addJsConfigVars( [
369            'wgAbuseFilterVariables' => $this->varManager->dumpAllVars( $vars, true ),
370        ] );
371
372        // Add test bit
373        if ( $this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
374            $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder(
375                $this,
376                $this->getAuthority(),
377                $output
378            );
379
380            $tester = Html::rawElement( 'h2', [], $this->msg( 'abusefilter-examine-test' )->parse() );
381            $tester .= $boxBuilder->buildEditBox( $this->testFilter, false, false, false );
382            $tester .= $this->buildFilterLoader();
383            $html .= Html::rawElement( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester );
384            $html .= Html::rawElement( 'p',
385                [],
386                new OOUI\ButtonInputWidget(
387                    [
388                        'label' => $this->msg( 'abusefilter-examine-test-button' )->text(),
389                        'id' => 'mw-abusefilter-examine-test',
390                        'flags' => [ 'primary', 'progressive' ]
391                    ]
392                ) .
393                Html::element( 'div',
394                    [
395                        'id' => 'mw-abusefilter-syntaxresult',
396                        'style' => 'display: none;'
397                    ]
398                )
399            );
400        }
401
402        // Variable dump
403        $html .= Html::rawElement(
404            'h2',
405            [],
406            $this->msg( 'abusefilter-examine-vars' )->parse()
407        );
408        $html .= $this->variablesFormatter->buildVarDumpTable( $vars );
409
410        $output->addHTML( $html );
411    }
412
413}