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