Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.34% covered (warning)
88.34%
144 / 163
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseLogger
88.34% covered (warning)
88.34%
144 / 163
75.00% covered (warning)
75.00%
6 / 8
43.66
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 addLogEntries
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
6
 buildLogTemplate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 newLocalLogEntryFromData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 insertLocalLogEntries
78.12% covered (warning)
78.12%
25 / 32
0.00% covered (danger)
0.00%
0 / 1
12.27
 insertCentralLogEntries
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 storeVarDump
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
 publishEntry
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use InvalidArgumentException;
6use MediaWiki\CheckUser\Hooks;
7use MediaWiki\Config\ServiceOptions;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
10use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
11use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
12use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
13use MediaWiki\Logging\ManualLogEntry;
14use MediaWiki\Registration\ExtensionRegistry;
15use MediaWiki\Title\Title;
16use MediaWiki\User\User;
17use MediaWiki\User\UserIdentityValue;
18use Profiler;
19use Wikimedia\Rdbms\IDatabase;
20use Wikimedia\Rdbms\LBFactory;
21use Wikimedia\ScopedCallback;
22
23class AbuseLogger {
24    private Title $title;
25    private User $user;
26    private VariableHolder $vars;
27    private string $action;
28
29    /** @var string[][] A list of variable dumps generated by {@link self::storeVarDump} for de-duplication. */
30    private array $varDumps = [];
31
32    private CentralDBManager $centralDBManager;
33    private FilterLookup $filterLookup;
34    private VariablesBlobStore $varBlobStore;
35    private VariablesManager $varManager;
36    private EditRevUpdater $editRevUpdater;
37    private LBFactory $lbFactory;
38    private ServiceOptions $options;
39    private RuleCheckerFactory $ruleCheckerFactory;
40    private AbuseFilterPermissionManager $afPermissionManager;
41
42    private string $wikiID;
43    private string $requestIP;
44
45    /**
46     * @param CentralDBManager $centralDBManager
47     * @param FilterLookup $filterLookup
48     * @param VariablesBlobStore $varBlobStore
49     * @param VariablesManager $varManager
50     * @param EditRevUpdater $editRevUpdater
51     * @param LBFactory $lbFactory
52     * @param RuleCheckerFactory $ruleCheckerFactory
53     * @param AbuseFilterPermissionManager $afPermissionManager
54     * @param ServiceOptions $options
55     * @param string $wikiID
56     * @param string $requestIP
57     * @param Title $title
58     * @param User $user
59     * @param VariableHolder $vars
60     * @internal Use {@link AbuseLoggerFactory::newLogger} instead
61     */
62    public function __construct(
63        CentralDBManager $centralDBManager,
64        FilterLookup $filterLookup,
65        VariablesBlobStore $varBlobStore,
66        VariablesManager $varManager,
67        EditRevUpdater $editRevUpdater,
68        LBFactory $lbFactory,
69        RuleCheckerFactory $ruleCheckerFactory,
70        AbuseFilterPermissionManager $afPermissionManager,
71        ServiceOptions $options,
72        string $wikiID,
73        string $requestIP,
74        Title $title,
75        User $user,
76        VariableHolder $vars
77    ) {
78        if ( !$vars->varIsSet( 'action' ) ) {
79            throw new InvalidArgumentException( "The 'action' variable is not set." );
80        }
81        $this->centralDBManager = $centralDBManager;
82        $this->filterLookup = $filterLookup;
83        $this->varBlobStore = $varBlobStore;
84        $this->varManager = $varManager;
85        $this->editRevUpdater = $editRevUpdater;
86        $this->lbFactory = $lbFactory;
87        $this->afPermissionManager = $afPermissionManager;
88        $this->ruleCheckerFactory = $ruleCheckerFactory;
89        $this->options = $options;
90        $this->wikiID = $wikiID;
91        $this->requestIP = $requestIP;
92        $this->title = $title;
93        $this->user = $user;
94        $this->vars = $vars;
95        $this->action = $vars->getComputedVariable( 'action' )->toString();
96    }
97
98    /**
99     * Create and publish log entries for taken actions
100     *
101     * @param array[] $actionsTaken
102     * @return array Shape is [ 'local' => int[], 'global' => int[] ], IDs of logged filters
103     * @phan-return array{local:int[],global:int[]}
104     */
105    public function addLogEntries( array $actionsTaken ): array {
106        $dbw = $this->lbFactory->getPrimaryDatabase();
107        $logTemplate = $this->buildLogTemplate();
108        $centralLogTemplate = [
109            'afl_wiki' => $this->wikiID,
110        ];
111
112        $logRows = [];
113        $centralLogRows = [];
114        $loggedLocalFilters = [];
115        $loggedGlobalFilters = [];
116
117        foreach ( $actionsTaken as $filter => $actions ) {
118            [ $filterID, $global ] = GlobalNameUtils::splitGlobalName( $filter );
119            $thisLog = $logTemplate;
120            $thisLog['afl_filter_id'] = $filterID;
121            $thisLog['afl_global'] = (int)$global;
122            $thisLog['afl_actions'] = implode( ',', $actions );
123
124            // Don't log if we were only throttling.
125            // TODO This check should be removed or rewritten using Consequence objects
126            if ( $thisLog['afl_actions'] !== 'throttle' ) {
127                $logRows[] = $thisLog;
128                // Global logging
129                if ( $global ) {
130                    $centralLog = $thisLog + $centralLogTemplate;
131                    $centralLog['afl_filter_id'] = $filterID;
132                    $centralLog['afl_global'] = 0;
133                    $centralLog['afl_title'] = $this->title->getPrefixedText();
134                    $centralLog['afl_namespace'] = 0;
135
136                    $centralLogRows[] = $centralLog;
137                    $loggedGlobalFilters[] = $filterID;
138                } else {
139                    $loggedLocalFilters[] = $filterID;
140                }
141            }
142        }
143
144        if ( !count( $logRows ) ) {
145            return [ 'local' => [], 'global' => [] ];
146        }
147
148        $localLogIDs = $this->insertLocalLogEntries( $logRows, $dbw );
149
150        $globalLogIDs = [];
151        if ( count( $loggedGlobalFilters ) ) {
152            $fdb = $this->centralDBManager->getConnection( DB_PRIMARY );
153            $globalLogIDs = $this->insertCentralLogEntries( $centralLogRows, $fdb );
154        }
155
156        $this->editRevUpdater->setLogIdsForTarget(
157            $this->title,
158            [ 'local' => $localLogIDs, 'global' => $globalLogIDs ]
159        );
160
161        return [ 'local' => $loggedLocalFilters, 'global' => $loggedGlobalFilters ];
162    }
163
164    /**
165     * Creates a template to use for logging taken actions
166     */
167    private function buildLogTemplate(): array {
168        // If $this->user isn't safe to load (e.g. a failure during
169        // AbortAutoAccount), create a dummy anonymous user instead.
170        $user = $this->user->isSafeToLoad() ? $this->user : new User;
171        // Create a template
172        $logTemplate = [
173            'afl_user' => $user->getId(),
174            'afl_user_text' => $user->getName(),
175            'afl_timestamp' => $this->lbFactory->getReplicaDatabase()->timestamp(),
176            'afl_namespace' => $this->title->getNamespace(),
177            'afl_title' => $this->title->getDBkey(),
178            'afl_action' => $this->action,
179            'afl_ip' => $this->options->get( 'AbuseFilterLogIP' ) ? $this->requestIP : ''
180        ];
181        // Hack to avoid revealing IPs of people creating accounts
182        if ( ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' ) && !$user->getId() ) {
183            $logTemplate['afl_user_text'] = $this->vars->getComputedVariable( 'accountname' )->toString();
184        }
185        return $logTemplate;
186    }
187
188    private function newLocalLogEntryFromData( array $data ): ManualLogEntry {
189        // Give grep a chance to find the usages:
190        // logentry-abusefilter-hit
191        $entry = new ManualLogEntry( 'abusefilter', 'hit' );
192        $user = new UserIdentityValue( $data['afl_user'], $data['afl_user_text'] );
193        $entry->setPerformer( $user );
194        $entry->setTarget( $this->title );
195        $filterName = GlobalNameUtils::buildGlobalName(
196            $data['afl_filter_id'],
197            $data['afl_global'] === 1
198        );
199        // Additional info
200        $entry->setParameters( [
201            'action' => $data['afl_action'],
202            'filter' => $filterName,
203            'actions' => $data['afl_actions'],
204            'log' => $data['afl_id'],
205        ] );
206        return $entry;
207    }
208
209    /**
210     * @param array[] $logRows
211     * @param IDatabase $dbw
212     * @return int[]
213     */
214    private function insertLocalLogEntries( array $logRows, IDatabase $dbw ): array {
215        $loggedIDs = [];
216        foreach ( $logRows as $data ) {
217            $data['afl_var_dump'] = $this->storeVarDump( $data['afl_filter_id'], (bool)$data['afl_global'], false );
218            $dbw->newInsertQueryBuilder()
219                ->insertInto( 'abuse_filter_log' )
220                ->row( $data )
221                ->caller( __METHOD__ )
222                ->execute();
223            $loggedIDs[] = $data['afl_id'] = $dbw->insertId();
224
225            // Send data to CheckUser if installed and we
226            // aren't already sending a notification to recentchanges
227            if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' )
228                && !str_contains( $this->options->get( 'AbuseFilterNotifications' ) ?: '', 'rc' )
229            ) {
230                $entry = $this->newLocalLogEntryFromData( $data );
231                $user = $entry->getPerformerIdentity();
232                // Invert the hack from ::buildLogTemplate because CheckUser attempts
233                // to assign an actor id to the non-existing user
234                if (
235                    ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' )
236                    && !$user->getId()
237                ) {
238                    $entry->setPerformer( new UserIdentityValue( 0, $this->requestIP ) );
239                }
240                $rc = $entry->getRecentChange();
241                // We need to send the entries on POSTSEND to ensure that the user definitely exists, as a temporary
242                // account being created by this edit may not exist until after AbuseFilter processes the edit.
243                DeferredUpdates::addCallableUpdate( static function () use ( $rc ) {
244                    // Silence the TransactionProfiler warnings for performing write queries (T359648).
245                    $trxProfiler = Profiler::instance()->getTransactionProfiler();
246                    $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY );
247                    Hooks::updateCheckUserData( $rc );
248                    ScopedCallback::consume( $scope );
249                } );
250            }
251
252            if ( $this->options->get( 'AbuseFilterNotifications' ) !== false ) {
253                $filterID = $data['afl_filter_id'];
254                $global = $data['afl_global'];
255                if (
256                    !$this->options->get( 'AbuseFilterNotificationsPrivate' ) &&
257                    $this->filterLookup->getFilter( $filterID, $global )->isHidden()
258                ) {
259                    continue;
260                }
261                $entry = $this->newLocalLogEntryFromData( $data );
262                $this->publishEntry( $dbw, $entry );
263            }
264        }
265        return $loggedIDs;
266    }
267
268    /**
269     * @param array[] $centralLogRows
270     * @param IDatabase $fdb
271     * @return int[]
272     */
273    private function insertCentralLogEntries( array $centralLogRows, IDatabase $fdb ): array {
274        $this->varManager->computeDBVars( $this->vars );
275        foreach ( $centralLogRows as $index => $data ) {
276            $centralLogRows[$index]['afl_var_dump'] = $this->storeVarDump(
277                $data['afl_filter_id'],
278                // All the filters logged centrally are global. Note, this must not use `afl_global`, because that is
279                // in the perspective of the central wiki, hence false: what we consider global on the current wiki is
280                // local to the central wiki.
281                true,
282                true
283            );
284        }
285
286        $loggedIDs = [];
287        foreach ( $centralLogRows as $row ) {
288            $fdb->newInsertQueryBuilder()
289                ->insertInto( 'abuse_filter_log' )
290                ->row( $row )
291                ->caller( __METHOD__ )
292                ->execute();
293            $loggedIDs[] = $fdb->insertId();
294        }
295        return $loggedIDs;
296    }
297
298    /**
299     * Returns the BlobStore address for use as the value of the afl_var_dump column for an AbuseFilter log entry.
300     *
301     * This method removes protected variables from the var dump that are not used in the filter
302     * associated with the AbuseFilter log to be created. It also de-duplicates var dumps where
303     * this is possible.
304     *
305     * @param int $filterId The filter associated with the AbuseFilter log entry
306     * @param bool $isGlobalFilter If the filter associated with the AbuseFilter log entry is global
307     * @param bool $useCentralDB Whether the dump should be stored in the central database
308     * @return string
309     */
310    private function storeVarDump( int $filterId, bool $isGlobalFilter, bool $useCentralDB ): string {
311        // Generate a key for the varDumps instance cache used to de-duplicate var dumps where possible.
312        // The key for this cache is the protected variables used in the filter along with whether the
313        // var dump is global.
314        $filter = $this->filterLookup->getFilter( $filterId, $isGlobalFilter );
315        $usedVariables = $this->ruleCheckerFactory->newRuleChecker()->getUsedVars( $filter->getRules() );
316        $usedProtectedVariables = $this->afPermissionManager->getUsedProtectedVariables( $usedVariables );
317        if ( count( $usedProtectedVariables ) ) {
318            sort( $usedProtectedVariables );
319            $variablesKey = implode( ',', $usedProtectedVariables );
320        } else {
321            $variablesKey = 0;
322        }
323        $centralDBKey = (int)$useCentralDB;
324
325        // Create a new var dump if the instance cache does not have this key.
326        if (
327            !array_key_exists( $centralDBKey, $this->varDumps ) ||
328            !array_key_exists( $variablesKey, $this->varDumps[$centralDBKey] )
329        ) {
330            // Filter out all protected variables that are not used in the current filter. Any other filter with
331            // the same list of protected filters will also use this var dump
332            $filteredVars = VariableHolder::newFromArray( $this->vars->getVars() );
333            $protectedVariables = $this->afPermissionManager->getProtectedVariables();
334            foreach ( array_keys( $filteredVars->getVars() ) as $varName ) {
335                if ( in_array( $varName, $protectedVariables ) && !in_array( $varName, $usedProtectedVariables ) ) {
336                    $filteredVars->removeVar( $varName );
337                }
338            }
339
340            $this->varDumps[$centralDBKey][$variablesKey] = $this->varBlobStore->storeVarDump(
341                $filteredVars,
342                $useCentralDB
343            );
344        }
345
346        return $this->varDumps[$centralDBKey][$variablesKey];
347    }
348
349    /**
350     * Like ManualLogEntry::publish, but doesn't require an ID (which we don't have) and skips the
351     * tagging part
352     *
353     * @param IDatabase $dbw To cancel the callback if the log insertion fails
354     * @param ManualLogEntry $entry
355     */
356    private function publishEntry( IDatabase $dbw, ManualLogEntry $entry ): void {
357        DeferredUpdates::addCallableUpdate(
358            function () use ( $entry ) {
359                $rc = $entry->getRecentChange();
360                $to = $this->options->get( 'AbuseFilterNotifications' );
361
362                if ( $to === 'rc' || $to === 'rcandudp' ) {
363                    $rc->save( $rc::SEND_NONE );
364                }
365                if ( $to === 'udp' || $to === 'rcandudp' ) {
366                    $rc->notifyRCFeeds();
367                }
368            },
369            DeferredUpdates::POSTSEND,
370            $dbw
371        );
372    }
373
374}