Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.91% |
130 / 143 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
FilterRunner | |
90.91% |
130 / 143 |
|
55.56% |
5 / 9 |
28.59 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
3 | |||
init | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
run | |
90.20% |
46 / 51 |
|
0.00% |
0 / 1 |
10.09 | |||
runForStash | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
3.08 | |||
checkAllFiltersInternal | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
checkAllFilters | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
checkFilter | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
profileExecution | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
updateEmergencyCache | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
4.12 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter; |
4 | |
5 | use BadMethodCallException; |
6 | use DeferredUpdates; |
7 | use InvalidArgumentException; |
8 | use MediaWiki\Config\ServiceOptions; |
9 | use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger; |
10 | use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory; |
11 | use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter; |
12 | use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; |
13 | use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator; |
14 | use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory; |
15 | use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory; |
16 | use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer; |
17 | use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder; |
18 | use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager; |
19 | use MediaWiki\Extension\AbuseFilter\Watcher\Watcher; |
20 | use Psr\Log\LoggerInterface; |
21 | use Status; |
22 | use Title; |
23 | use 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 | */ |
30 | class 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 |