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 |
26.51 | |
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 |
8.06 | |||
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 InvalidArgumentException; |
6 | use MediaWiki\Config\ServiceOptions; |
7 | use MediaWiki\Deferred\DeferredUpdates; |
8 | use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger; |
9 | use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory; |
10 | use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter; |
11 | use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; |
12 | use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator; |
13 | use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory; |
14 | use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory; |
15 | use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer; |
16 | use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder; |
17 | use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager; |
18 | use MediaWiki\Extension\AbuseFilter\Watcher\Watcher; |
19 | use MediaWiki\Status\Status; |
20 | use MediaWiki\Title\Title; |
21 | use MediaWiki\User\User; |
22 | use 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 | */ |
29 | class 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 | } |