Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.87% covered (warning)
86.87%
172 / 198
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewTestBatch
86.87% covered (warning)
86.87%
172 / 198
50.00% covered (danger)
50.00%
2 / 4
36.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 show
97.85% covered (success)
97.85%
91 / 93
0.00% covered (danger)
0.00%
0 / 1
4
 doTest
71.43% covered (warning)
71.43%
60 / 84
0.00% covered (danger)
0.00%
0 / 1
37.43
 loadParameters
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
7use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
8use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
9use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
10use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxField;
11use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
12use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
13use MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable;
14use MediaWiki\Html\Html;
15use MediaWiki\HTMLForm\HTMLForm;
16use MediaWiki\Linker\LinkRenderer;
17use MediaWiki\Logging\LogEventsList;
18use MediaWiki\Logging\LogPage;
19use MediaWiki\Message\Message;
20use MediaWiki\RecentChanges\RecentChange;
21use MediaWiki\RecentChanges\RecentChangeFactory;
22use MediaWiki\Revision\RevisionRecord;
23use MediaWiki\Title\Title;
24use Wikimedia\Rdbms\LBFactory;
25use Wikimedia\Rdbms\SelectQueryBuilder;
26
27class AbuseFilterViewTestBatch extends AbuseFilterView {
28    /**
29     * @var int The limit of changes to test, hard coded for now
30     */
31    private static $mChangeLimit = 100;
32
33    /**
34     * @var LBFactory
35     */
36    private $lbFactory;
37    /**
38     * @var string The text of the rule to test changes against
39     */
40    private $testPattern;
41    /**
42     * @var EditBoxBuilderFactory
43     */
44    private $boxBuilderFactory;
45    /**
46     * @var RuleCheckerFactory
47     */
48    private $ruleCheckerFactory;
49    /**
50     * @var VariableGeneratorFactory
51     */
52    private $varGeneratorFactory;
53    private AbuseLoggerFactory $abuseLoggerFactory;
54    private RecentChangeFactory $recentChangeFactory;
55
56    /**
57     * @param LBFactory $lbFactory
58     * @param AbuseFilterPermissionManager $afPermManager
59     * @param EditBoxBuilderFactory $boxBuilderFactory
60     * @param RuleCheckerFactory $ruleCheckerFactory
61     * @param VariableGeneratorFactory $varGeneratorFactory
62     * @param AbuseLoggerFactory $abuseLoggerFactory
63     * @param RecentChangeFactory $recentChangeFactory
64     * @param IContextSource $context
65     * @param LinkRenderer $linkRenderer
66     * @param string $basePageName
67     * @param array $params
68     */
69    public function __construct(
70        LBFactory $lbFactory,
71        AbuseFilterPermissionManager $afPermManager,
72        EditBoxBuilderFactory $boxBuilderFactory,
73        RuleCheckerFactory $ruleCheckerFactory,
74        VariableGeneratorFactory $varGeneratorFactory,
75        AbuseLoggerFactory $abuseLoggerFactory,
76        RecentChangeFactory $recentChangeFactory,
77        IContextSource $context,
78        LinkRenderer $linkRenderer,
79        string $basePageName,
80        array $params
81    ) {
82        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
83        $this->lbFactory = $lbFactory;
84        $this->boxBuilderFactory = $boxBuilderFactory;
85        $this->ruleCheckerFactory = $ruleCheckerFactory;
86        $this->varGeneratorFactory = $varGeneratorFactory;
87        $this->abuseLoggerFactory = $abuseLoggerFactory;
88        $this->recentChangeFactory = $recentChangeFactory;
89    }
90
91    /**
92     * Shows the page
93     */
94    public function show() {
95        $out = $this->getOutput();
96
97        if ( !$this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
98            // TODO: the message still refers to the old rights
99            $out->addWikiMsg( 'abusefilter-mustviewprivateoredit' );
100            return;
101        }
102
103        $this->loadParameters();
104
105        // Check if a loaded test pattern uses protected variables and if the user has the right
106        // to view protected variables. If they don't and protected variables are present, unset
107        // the test pattern to avoid leaking PII and notify the user.
108        // This is done as early as possible so that a filter with PII the user cannot access is
109        // never loaded.
110        if ( $this->testPattern !== '' ) {
111            $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
112            $usedVars = $ruleChecker->getUsedVars( $this->testPattern );
113            if ( $this->afPermManager->getForbiddenVariables( $this->getAuthority(), $usedVars ) ) {
114                $this->testPattern = '';
115                $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
116                $out->addHtml(
117                    Html::errorBox( $this->msg( 'abusefilter-test-protectedvarerr' )->parse() )
118                );
119            }
120        }
121
122        $out->setPageTitleMsg( $this->msg( 'abusefilter-test' ) );
123        $out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
124        $out->addWikiMsg( 'abusefilter-test-intro', Message::numParam( self::$mChangeLimit ) );
125        $out->enableOOUI();
126
127        $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $this->getAuthority(), $out );
128
129        $rulesFields = [
130            'rules' => [
131                'section' => 'abusefilter-test-rules-section',
132                'class' => EditBoxField::class,
133                'html' => $boxBuilder->buildEditBox(
134                    $this->testPattern,
135                    true,
136                    true,
137                    false
138                ) . $this->buildFilterLoader()
139            ]
140        ];
141
142        $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
143        $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
144        $max = wfTimestampNow();
145
146        $optionsFields = [
147            'TestAction' => [
148                'type' => 'select',
149                'label-message' => 'abusefilter-test-action',
150                'options-messages' => [
151                    'abusefilter-test-search-type-all' => '0',
152                    'abusefilter-test-search-type-edit' => 'edit',
153                    'abusefilter-test-search-type-move' => 'move',
154                    'abusefilter-test-search-type-delete' => 'delete',
155                    'abusefilter-test-search-type-createaccount' => 'createaccount',
156                    'abusefilter-test-search-type-upload' => 'upload'
157                ],
158            ],
159            'TestUser' => [
160                'type' => 'user',
161                'exists' => true,
162                'ipallowed' => true,
163                'required' => false,
164                'label-message' => 'abusefilter-test-user',
165            ],
166            'ExcludeBots' => [
167                'type' => 'check',
168                'label-message' => 'abusefilter-test-nobots',
169            ],
170            'TestPeriodStart' => [
171                'type' => 'datetime',
172                'label-message' => 'abusefilter-test-period-start',
173                'min' => $min,
174                'max' => $max,
175            ],
176            'TestPeriodEnd' => [
177                'type' => 'datetime',
178                'label-message' => 'abusefilter-test-period-end',
179                'min' => $min,
180                'max' => $max,
181            ],
182            'TestPage' => [
183                'type' => 'title',
184                'label-message' => 'abusefilter-test-page',
185                'creatable' => true,
186                'required' => false,
187            ],
188            'ShowNegative' => [
189                'type' => 'check',
190                'label-message' => 'abusefilter-test-shownegative',
191            ],
192        ];
193        array_walk( $optionsFields, static function ( &$el ) {
194            $el['section'] = 'abusefilter-test-options-section';
195        } );
196        $allFields = array_merge( $rulesFields, $optionsFields );
197
198        $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
199        HTMLForm::factory( 'ooui', $allFields, $this->getContext() )
200            ->setTitle( $this->getTitle( 'test' ) )
201            ->setId( 'wpFilterForm' )
202            ->setWrapperLegendMsg( 'abusefilter-test-legend' )
203            ->setSubmitTextMsg( 'abusefilter-test-submit' )
204            ->setSubmitCallback( [ $this, 'doTest' ] )
205            ->showAlways();
206    }
207
208    /**
209     * Loads the revisions and checks the given syntax against them
210     * @param array $formData
211     * @param HTMLForm $form
212     * @return bool
213     */
214    public function doTest( array $formData, HTMLForm $form ): bool {
215        // Quick syntax check.
216        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
217
218        if ( !$ruleChecker->checkSyntax( $this->testPattern )->isValid() ) {
219            $form->addPreHtml(
220                Html::errorBox( $this->msg( 'abusefilter-test-syntaxerr' )->parse() )
221            );
222            return true;
223        }
224
225        $dbr = $this->lbFactory->getReplicaDatabase();
226        $rcQuery = RecentChange::getQueryInfo();
227        $conds = [];
228
229        // Normalise username
230        $userTitle = Title::newFromText( $formData['TestUser'], NS_USER );
231        $testUser = $userTitle ? $userTitle->getText() : '';
232        if ( $testUser !== '' ) {
233            $conds[$rcQuery['fields']['rc_user_text']] = $testUser;
234        }
235
236        $startTS = strtotime( $formData['TestPeriodStart'] );
237        if ( $startTS ) {
238            $conds[] = $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $startTS ) );
239        }
240        $endTS = strtotime( $formData['TestPeriodEnd'] );
241        if ( $endTS ) {
242            $conds[] = $dbr->expr( 'rc_timestamp', '<=', $dbr->timestamp( $endTS ) );
243        }
244        if ( $formData['TestPage'] !== '' ) {
245            // The form validates the input for us, so this shouldn't throw.
246            $title = Title::newFromTextThrow( $formData['TestPage'] );
247            $conds['rc_namespace'] = $title->getNamespace();
248            $conds['rc_title'] = $title->getDBkey();
249        }
250
251        if ( $formData['ExcludeBots'] ) {
252            $conds['rc_bot'] = 0;
253        }
254
255        $action = $formData['TestAction'] !== '0' ? $formData['TestAction'] : false;
256        $conds[] = $this->buildTestConditions( $dbr, $action );
257        $conds = array_merge( $conds, $this->buildVisibilityConditions( $dbr, $this->getAuthority() ) );
258
259        $res = $dbr->newSelectQueryBuilder()
260            ->tables( $rcQuery['tables'] )
261            ->fields( $rcQuery['fields'] )
262            ->conds( $conds )
263            ->caller( __METHOD__ )
264            ->limit( self::$mChangeLimit )
265            ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC )
266            ->joinConds( $rcQuery['joins'] )
267            ->fetchResultSet();
268
269        // Get our ChangesList
270        $changesList = new AbuseFilterChangesList( $this->getContext(), $this->testPattern );
271        // Note, we're initializing some rows that will later be discarded. Hopefully this won't have any overhead.
272        $changesList->initChangesListRows( $res );
273        $output = $changesList->beginRecentChangesList();
274
275        $counter = 1;
276
277        $contextUser = $this->getUser();
278        $ruleChecker->toggleConditionLimit( false );
279        foreach ( $res as $row ) {
280            $rc = $this->recentChangeFactory->newRecentChangeFromRow( $row );
281            if ( !$formData['ShowNegative'] ) {
282                $source = $rc->getAttribute( 'rc_source' );
283                $deletedValue = (int)$rc->getAttribute( 'rc_deleted' );
284                if (
285                    (
286                        $source === RecentChange::SRC_LOG &&
287                        !LogEventsList::userCanBitfield(
288                            $deletedValue,
289                            LogPage::SUPPRESSED_ACTION | LogPage::SUPPRESSED_USER,
290                            $contextUser
291                        )
292                    ) || (
293                        $source !== RecentChange::SRC_LOG &&
294                        !RevisionRecord::userCanBitfield( $deletedValue, RevisionRecord::SUPPRESSED_ALL, $contextUser )
295                    )
296                ) {
297                    // If the RC is deleted, the user can't see it, and we're only showing matches,
298                    // always skip this row. If ShowNegative is true, we can still show the row
299                    // because we won't tell whether it matches the given filter.
300                    continue;
301                }
302            }
303
304            $varGenerator = $this->varGeneratorFactory->newRCGenerator( $rc, $contextUser );
305            $vars = $varGenerator->getVars();
306
307            if ( !$vars ) {
308                continue;
309            }
310
311            $ruleChecker->setVariables( $vars );
312            $result = $ruleChecker->checkConditions( $this->testPattern )->getResult();
313
314            // If the test filter pattern contains protected variables and this entry had a value set for the
315            // protected variables that were in the pattern, then log that protected variables were accessed.
316            // This is to avoid a user being able to know the value of the variable if they repeatedly try values to
317            // find the actual value through trial-and-error.
318            $usedVars = $ruleChecker->getUsedVars( $this->testPattern );
319            $protectedVariableValuesShown = [];
320            foreach ( $this->afPermManager->getUsedProtectedVariables( $usedVars ) as $protectedVariable ) {
321                if ( $vars->varIsSet( $protectedVariable ) ) {
322                    $protectedVariableValue = $vars->getVarThrow( $protectedVariable );
323                    if (
324                        !( $protectedVariableValue instanceof LazyLoadedVariable ) &&
325                        $protectedVariableValue !== null
326                    ) {
327                        $protectedVariableValuesShown[] = $protectedVariable;
328                    }
329                }
330            }
331
332            if ( count( $protectedVariableValuesShown ) ) {
333                // Either 'user_name' or 'accountname' should be set which are not lazily loaded, so get one of
334                // them to use as the target
335                if ( $vars->varIsSet( 'user_name' ) ) {
336                    $target = $vars->getComputedVariable( 'user_name' )->toNative();
337                } else {
338                    $target = $vars->getComputedVariable( 'accountname' )->toNative();
339                }
340                $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
341                $logger->logViewProtectedVariableValue( $this->getUser(), $target, $protectedVariableValuesShown );
342            }
343
344            if ( $result || $formData['ShowNegative'] ) {
345                $changesList->setRCResult( $rc, $result );
346                $rc->counter = $counter++;
347                $output .= $changesList->recentChangesLine( $rc, false );
348            }
349        }
350
351        $output .= $changesList->endRecentChangesList();
352
353        $form->addPostHtml( $output );
354
355        return true;
356    }
357
358    /**
359     * Loads parameters from request
360     */
361    public function loadParameters() {
362        $request = $this->getRequest();
363
364        $this->testPattern = $request->getText( 'wpFilterRules' );
365
366        if ( $this->testPattern === ''
367            && count( $this->mParams ) > 1
368            && is_numeric( $this->mParams[1] )
369        ) {
370            $dbr = $this->lbFactory->getReplicaDatabase();
371            $pattern = $dbr->newSelectQueryBuilder()
372                ->select( 'af_pattern' )
373                ->from( 'abuse_filter' )
374                ->where( [ 'af_id' => intval( $this->mParams[1] ) ] )
375                ->caller( __METHOD__ )
376                ->fetchField();
377            if ( $pattern !== false ) {
378                $this->testPattern = $pattern;
379            }
380        }
381    }
382}