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