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