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