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