Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
130 / 143
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterRunner
90.91% covered (success)
90.91%
130 / 143
55.56% covered (warning)
55.56%
5 / 9
28.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 init
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 run
90.20% covered (success)
90.20%
46 / 51
0.00% covered (danger)
0.00%
0 / 1
10.09
 runForStash
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
3.08
 checkAllFiltersInternal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 checkAllFilters
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 checkFilter
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 profileExecution
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 updateEmergencyCache
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use BadMethodCallException;
6use DeferredUpdates;
7use InvalidArgumentException;
8use MediaWiki\Config\ServiceOptions;
9use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
10use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
11use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
12use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
13use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
14use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
15use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
16use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
17use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
18use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
19use MediaWiki\Extension\AbuseFilter\Watcher\Watcher;
20use Psr\Log\LoggerInterface;
21use Status;
22use Title;
23use User;
24
25/**
26 * This class contains the logic for executing abuse filters and their actions. The entry points are
27 * run() and runForStash(). Note that run() can only be executed once on a given instance.
28 * @internal Not stable yet
29 */
30class FilterRunner {
31    public const CONSTRUCTOR_OPTIONS = [
32        'AbuseFilterValidGroups',
33        'AbuseFilterCentralDB',
34        'AbuseFilterIsCentral',
35        'AbuseFilterConditionLimit',
36    ];
37
38    /** @var AbuseFilterHookRunner */
39    private $hookRunner;
40    /** @var FilterProfiler */
41    private $filterProfiler;
42    /** @var ChangeTagger */
43    private $changeTagger;
44    /** @var FilterLookup */
45    private $filterLookup;
46    /** @var RuleCheckerFactory */
47    private $ruleCheckerFactory;
48    /** @var ConsequencesExecutorFactory */
49    private $consExecutorFactory;
50    /** @var AbuseLoggerFactory */
51    private $abuseLoggerFactory;
52    /** @var EmergencyCache */
53    private $emergencyCache;
54    /** @var Watcher[] */
55    private $watchers;
56    /** @var EditStashCache */
57    private $stashCache;
58    /** @var LoggerInterface */
59    private $logger;
60    /** @var VariablesManager */
61    private $varManager;
62    /** @var VariableGeneratorFactory */
63    private $varGeneratorFactory;
64    /** @var ServiceOptions */
65    private $options;
66
67    /**
68     * @var FilterEvaluator
69     */
70    private $ruleChecker;
71
72    /**
73     * @var User The user who performed the action being filtered
74     */
75    protected $user;
76    /**
77     * @var Title The title where the action being filtered was performed
78     */
79    protected $title;
80    /**
81     * @var VariableHolder The variables for the current action
82     */
83    protected $vars;
84    /**
85     * @var string The group of filters to check (as defined in $wgAbuseFilterValidGroups)
86     */
87    protected $group;
88    /**
89     * @var string The action we're filtering
90     */
91    protected $action;
92
93    /**
94     * @param AbuseFilterHookRunner $hookRunner
95     * @param FilterProfiler $filterProfiler
96     * @param ChangeTagger $changeTagger
97     * @param FilterLookup $filterLookup
98     * @param RuleCheckerFactory $ruleCheckerFactory
99     * @param ConsequencesExecutorFactory $consExecutorFactory
100     * @param AbuseLoggerFactory $abuseLoggerFactory
101     * @param VariablesManager $varManager
102     * @param VariableGeneratorFactory $varGeneratorFactory
103     * @param EmergencyCache $emergencyCache
104     * @param Watcher[] $watchers
105     * @param EditStashCache $stashCache
106     * @param LoggerInterface $logger
107     * @param ServiceOptions $options
108     * @param User $user
109     * @param Title $title
110     * @param VariableHolder $vars
111     * @param string $group
112     * @throws InvalidArgumentException If $group is invalid or the 'action' variable is unset
113     */
114    public function __construct(
115        AbuseFilterHookRunner $hookRunner,
116        FilterProfiler $filterProfiler,
117        ChangeTagger $changeTagger,
118        FilterLookup $filterLookup,
119        RuleCheckerFactory $ruleCheckerFactory,
120        ConsequencesExecutorFactory $consExecutorFactory,
121        AbuseLoggerFactory $abuseLoggerFactory,
122        VariablesManager $varManager,
123        VariableGeneratorFactory $varGeneratorFactory,
124        EmergencyCache $emergencyCache,
125        array $watchers,
126        EditStashCache $stashCache,
127        LoggerInterface $logger,
128        ServiceOptions $options,
129        User $user,
130        Title $title,
131        VariableHolder $vars,
132        string $group
133    ) {
134        $this->hookRunner = $hookRunner;
135        $this->filterProfiler = $filterProfiler;
136        $this->changeTagger = $changeTagger;
137        $this->filterLookup = $filterLookup;
138        $this->ruleCheckerFactory = $ruleCheckerFactory;
139        $this->consExecutorFactory = $consExecutorFactory;
140        $this->abuseLoggerFactory = $abuseLoggerFactory;
141        $this->varManager = $varManager;
142        $this->varGeneratorFactory = $varGeneratorFactory;
143        $this->emergencyCache = $emergencyCache;
144        $this->watchers = $watchers;
145        $this->stashCache = $stashCache;
146        $this->logger = $logger;
147
148        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
149        if ( !in_array( $group, $options->get( 'AbuseFilterValidGroups' ), true ) ) {
150            throw new InvalidArgumentException( "Group $group is not a valid group" );
151        }
152        $this->options = $options;
153        if ( !$vars->varIsSet( 'action' ) ) {
154            throw new InvalidArgumentException( "The 'action' variable is not set." );
155        }
156        $this->user = $user;
157        $this->title = $title;
158        $this->vars = $vars;
159        $this->group = $group;
160        $this->action = $vars->getComputedVariable( 'action' )->toString();
161    }
162
163    /**
164     * Inits variables and parser right before running
165     */
166    private function init() {
167        // Add vars from extensions
168        $this->hookRunner->onAbuseFilter_filterAction(
169            $this->vars,
170            $this->title
171        );
172        $this->hookRunner->onAbuseFilterAlterVariables(
173            $this->vars,
174            $this->title,
175            $this->user
176        );
177        $generator = $this->varGeneratorFactory->newGenerator( $this->vars );
178        $this->vars = $generator->addGenericVars()->getVariableHolder();
179        $this->ruleChecker = $this->ruleCheckerFactory->newRuleChecker( $this->vars );
180    }
181
182    /**
183     * The main entry point of this class. This method runs all filters and takes their consequences.
184     *
185     * @param bool $allowStash Whether we are allowed to check the cache to see if there's a cached
186     *  result of a previous execution for the same edit.
187     * @throws BadMethodCallException If run() was already called on this instance
188     * @return Status Good if no action has been taken, a fatal otherwise.
189     */
190    public function run( $allowStash = true ): Status {
191        $this->init();
192
193        $skipReasons = [];
194        $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
195            $this->vars, $this->title, $this->user, $skipReasons
196        );
197        if ( !$shouldFilter ) {
198            $this->logger->info(
199                'Skipping action {action}. Reasons provided: {reasons}',
200                [ 'action' => $this->action, 'reasons' => implode( ', ', $skipReasons ) ]
201            );
202            return Status::newGood();
203        }
204
205        $useStash = $allowStash && $this->action === 'edit';
206
207        $runnerData = null;
208        if ( $useStash ) {
209            $cacheData = $this->stashCache->seek( $this->vars );
210            if ( $cacheData !== false ) {
211                // Use cached vars (T176291) and profiling data (T191430)
212                $this->vars = VariableHolder::newFromArray( $cacheData['vars'] );
213                $runnerData = RunnerData::fromArray( $cacheData['data'] );
214            }
215        }
216
217        if ( $runnerData === null ) {
218            $runnerData = $this->checkAllFiltersInternal();
219        }
220
221        // hack until DI for DeferredUpdates is possible (T265749)
222        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
223            $this->profileExecution( $runnerData );
224            $this->updateEmergencyCache( $runnerData->getMatchesMap() );
225        } else {
226            // @codeCoverageIgnoreStart
227            DeferredUpdates::addCallableUpdate( function () use ( $runnerData ) {
228                $this->profileExecution( $runnerData );
229                $this->updateEmergencyCache( $runnerData->getMatchesMap() );
230            } );
231            // @codeCoverageIgnoreEnd
232        }
233
234        // TODO: inject the action specifier to avoid this
235        $accountname = $this->varManager->getVar(
236            $this->vars,
237            'accountname',
238            VariablesManager::GET_BC
239        )->toNative();
240        $spec = new ActionSpecifier(
241            $this->action,
242            $this->title,
243            $this->user,
244            $this->user->getRequest()->getIP(),
245            $accountname
246        );
247
248        // Tag the action if the condition limit was hit
249        if ( $runnerData->getTotalConditions() > $this->options->get( 'AbuseFilterConditionLimit' ) ) {
250            $this->changeTagger->addConditionsLimitTag( $spec );
251        }
252
253        $matchedFilters = $runnerData->getMatchedFilters();
254
255        if ( count( $matchedFilters ) === 0 ) {
256            return Status::newGood();
257        }
258
259        $executor = $this->consExecutorFactory->newExecutor( $spec, $this->vars );
260        $status = $executor->executeFilterActions( $matchedFilters );
261        $actionsTaken = $status->getValue();
262
263        // Note, it's important that we create an AbuseLogger now, after all lazy-loaded variables
264        // requested by active filters have been computed
265        $abuseLogger = $this->abuseLoggerFactory->newLogger( $this->title, $this->user, $this->vars );
266        [
267            'local' => $loggedLocalFilters,
268            'global' => $loggedGlobalFilters
269        ] = $abuseLogger->addLogEntries( $actionsTaken );
270
271        foreach ( $this->watchers as $watcher ) {
272            $watcher->run( $loggedLocalFilters, $loggedGlobalFilters, $this->group );
273        }
274
275        return $status;
276    }
277
278    /**
279     * Similar to run(), but runs in "stash" mode, which means filters are executed, no actions are
280     *  taken, and the result is saved in cache to be later reused. This can only be used for edits,
281     *  and not doing so will throw.
282     *
283     * @throws InvalidArgumentException
284     * @return Status Always a good status, since we're only saving data.
285     */
286    public function runForStash(): Status {
287        if ( $this->action !== 'edit' ) {
288            throw new InvalidArgumentException(
289                __METHOD__ . " can only be called for edits, called for action {$this->action}."
290            );
291        }
292
293        $this->init();
294
295        $skipReasons = [];
296        $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
297            $this->vars, $this->title, $this->user, $skipReasons
298        );
299        if ( !$shouldFilter ) {
300            // Don't log it yet
301            return Status::newGood();
302        }
303
304        // XXX: We need a copy here because the cache key is computed
305        // from the variables, but some variables can be loaded lazily
306        // which would store the data with a key distinct from that
307        // computed by seek() in ::run().
308        // TODO: Find better way to generate the cache key.
309        $origVars = clone $this->vars;
310
311        $runnerData = $this->checkAllFiltersInternal();
312        // Save the filter stash result and do nothing further
313        $cacheData = [
314            'vars' => $this->varManager->dumpAllVars( $this->vars ),
315            'data' => $runnerData->toArray(),
316        ];
317
318        $this->stashCache->store( $origVars, $cacheData );
319
320        return Status::newGood();
321    }
322
323    /**
324     * Run all filters and return information about matches and profiling
325     *
326     * @return RunnerData
327     */
328    protected function checkAllFiltersInternal(): RunnerData {
329        // Ensure there's no extra time leftover
330        LazyVariableComputer::$profilingExtraTime = 0;
331
332        $data = new RunnerData();
333
334        foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, false ) as $filter ) {
335            [ $status, $timeTaken ] = $this->checkFilter( $filter );
336            $data->record( $filter->getID(), false, $status, $timeTaken );
337        }
338
339        if ( $this->options->get( 'AbuseFilterCentralDB' ) && !$this->options->get( 'AbuseFilterIsCentral' ) ) {
340            foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, true ) as $filter ) {
341                [ $status, $timeTaken ] = $this->checkFilter( $filter, true );
342                $data->record( $filter->getID(), true, $status, $timeTaken );
343            }
344        }
345
346        return $data;
347    }
348
349    /**
350     * Returns an associative array of filters which were tripped
351     *
352     * @internal BC method
353     * @return bool[] Map of (filter ID => bool)
354     */
355    public function checkAllFilters(): array {
356        $this->init();
357        return $this->checkAllFiltersInternal()->getMatchesMap();
358    }
359
360    /**
361     * Check the conditions of a single filter, and profile it
362     *
363     * @param ExistingFilter $filter
364     * @param bool $global
365     * @return array [ status, time taken ]
366     * @phan-return array{0:\MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerStatus,1:float}
367     */
368    protected function checkFilter( ExistingFilter $filter, bool $global = false ): array {
369        $filterName = GlobalNameUtils::buildGlobalName( $filter->getID(), $global );
370
371        $startTime = microtime( true );
372        $origExtraTime = LazyVariableComputer::$profilingExtraTime;
373
374        $status = $this->ruleChecker->checkConditions( $filter->getRules(), $filterName );
375
376        $actualExtra = LazyVariableComputer::$profilingExtraTime - $origExtraTime;
377        $timeTaken = 1000 * ( microtime( true ) - $startTime - $actualExtra );
378
379        return [ $status, $timeTaken ];
380    }
381
382    /**
383     * @param RunnerData $data
384     */
385    protected function profileExecution( RunnerData $data ) {
386        $allFilters = $data->getAllFilters();
387        $matchedFilters = $data->getMatchedFilters();
388        $this->filterProfiler->recordRuntimeProfilingResult(
389            count( $allFilters ),
390            $data->getTotalConditions(),
391            $data->getTotalRuntime()
392