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