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