Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.44% |
123 / 136 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
FilterRunner | |
90.44% |
123 / 136 |
|
55.56% |
5 / 9 |
26.59 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
3 | |||
init | |
100.00% |
6 / 6 |
|
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\Variables\LazyVariableComputer; |
15 | use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder; |
16 | use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager; |
17 | use MediaWiki\Extension\AbuseFilter\Watcher\Watcher; |
18 | use MediaWiki\Status\Status; |
19 | use MediaWiki\Title\Title; |
20 | use MediaWiki\User\User; |
21 | use 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 | */ |
28 | class 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 | } |