Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
60.16% |
299 / 497 |
|
77.27% |
17 / 22 |
CRAP | |
0.00% |
0 / 1 |
PopulateCheckUserTablesWithSimulatedData | |
60.90% |
299 / 491 |
|
77.27% |
17 / 22 |
724.10 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
38 / 38 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
4.59% |
5 / 109 |
|
0.00% |
0 / 1 |
524.31 | |||
ensureArgumentIsInt | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
applyRemainderAction | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
createRegisteredUser | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
3.00 | |||
getRandomFloat | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
mtRand | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
randomlyAssignXFFHeader | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
5.51 | |||
returnRandomIpExceptExcluded | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
getNewIp | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
generateNewIp | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
generateNewIPv4 | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
generateNewIPv6 | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
11 | |||
getNewUserAgentAndAssociatedClientHints | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
performInsertBatch | |
0.00% |
0 / 82 |
|
0.00% |
0 / 1 |
650 | |||
simulateLogAction | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
5 | |||
performEdit | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
4.02 | |||
incrementAndCheck | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
setNewRandomFakeTime | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
moveFakeTimeForward | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
initUserAgentAndClientHintsCombos | |
100.00% |
99 / 99 |
|
100.00% |
1 / 1 |
1 | |||
getPrefix | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\Maintenance; |
4 | |
5 | use ContentHandler; |
6 | use MailAddress; |
7 | use Maintenance; |
8 | use ManualLogEntry; |
9 | use MediaWiki\Auth\AuthenticationResponse; |
10 | use MediaWiki\Auth\AuthManager; |
11 | use MediaWiki\CheckUser\ClientHints\ClientHintsData; |
12 | use MediaWiki\CheckUser\Hooks as CheckUserHooks; |
13 | use MediaWiki\CheckUser\Services\UserAgentClientHintsManager; |
14 | use MediaWiki\MediaWikiServices; |
15 | use MediaWiki\Request\FauxRequest; |
16 | use MediaWiki\Title\Title; |
17 | use MediaWiki\User\User; |
18 | use MediaWiki\User\UserIdentity; |
19 | use MediaWiki\User\UserIdentityValue; |
20 | use MediaWiki\User\UserRigorOptions; |
21 | use RequestContext; |
22 | use Wikimedia\IPUtils; |
23 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
24 | |
25 | $IP = getenv( 'MW_INSTALL_PATH' ); |
26 | if ( $IP === false ) { |
27 | $IP = __DIR__ . '/../../..'; |
28 | } |
29 | require_once "$IP/maintenance/Maintenance.php"; |
30 | |
31 | /** |
32 | * Populates the CheckUser tables with simulated data that can be useful for |
33 | * testing. |
34 | * |
35 | * WARNING: This should never be run on production wikis. This is intended only for |
36 | * local testing wikis where the DB can be cleared without issue. |
37 | */ |
38 | class PopulateCheckUserTablesWithSimulatedData extends Maintenance { |
39 | |
40 | private const VALID_LOG_EVENTS = [ |
41 | 'move' => [ 'move', 'move_redir' ], |
42 | 'delete' => [ 'delete', 'restore' ], |
43 | 'suppress' => [ 'delete' ], |
44 | 'merge' => [ 'merge' ] |
45 | ]; |
46 | |
47 | /** @var array<string,?ClientHintsData> */ |
48 | private array $userAgentsToClientHintsMap; |
49 | |
50 | private CheckUserHooks $hooks; |
51 | |
52 | private User $userToEmailAndSendPasswordResetsFor; |
53 | |
54 | private ?ClientHintsData $currentClientHintsData; |
55 | |
56 | private array $ipv4Ranges; |
57 | |
58 | private array $ipv6Ranges; |
59 | |
60 | private array $ipsToUse; |
61 | |
62 | private FauxRequest $mainRequest; |
63 | |
64 | public function __construct() { |
65 | parent::__construct(); |
66 | $this->addDescription( 'If you use --num-temp with this script, set ' . |
67 | '$wgTempAccountNameAcquisitionThrottle to null to avoid rate limiting on ' . |
68 | 'temporary account name acquisitions' ); |
69 | $this->addOption( |
70 | 'num-users', |
71 | 'How many users should be created and used for the simulated actions. ' . |
72 | 'The number of actions performed will roughly be split equally between the users. Default is 10.' |
73 | ); |
74 | $this->addOption( |
75 | 'num-anon', |
76 | 'How many IPs should be used for the simulated actions. ' . |
77 | 'The number of actions performed will roughly be split equally between the IPs. Default is 5.' |
78 | ); |
79 | $this->addOption( |
80 | 'num-temp', |
81 | 'How many temporary accounts should be used for the simulated actions. ' . |
82 | 'The number of actions performed will roughly be split equally between the temporary accounts.' . |
83 | 'This is ignored if temporary account creation is disabled. If not ignored, the default is 10.' |
84 | ); |
85 | $this->addOption( |
86 | 'num-used-ips', |
87 | 'How many IPs to select from the ranges in ranges-for-ips. Must not be smaller than num-anon. ' . |
88 | 'These IPs will be used for anon edits, temporary account and user actions. These will also be used ' . |
89 | 'in the XFF header (if set) for actions. Default is 5.' |
90 | ); |
91 | $this->addOption( |
92 | 'ranges-for-ips', |
93 | 'What ranges should the IPs be selected from. Default is one IPv4 and IPv6 range inside ' . |
94 | 'ranges defined as internal.', |
95 | false, false, false, true |
96 | ); |
97 | $this->addArg( |
98 | 'count', |
99 | 'How many items to be added to the CheckUser tables. Default is 1,000 items.', |
100 | false |
101 | ); |
102 | |
103 | $this->requireExtension( 'CheckUser' ); |
104 | } |
105 | |
106 | public function execute() { |
107 | // Check development mode is enabled |
108 | if ( $this->getConfig()->get( 'CheckUserDeveloperMode' ) !== true ) { |
109 | $this->fatalError( |
110 | "CheckUser development mode must be enabled to use this script. To do this, set " . |
111 | "wgCheckUserDeveloperMode to true. Only do this on localhost testing wikis." |
112 | ); |
113 | } |
114 | // Set-up and argument parsing. |
115 | $count = $this->ensureArgumentIsInt( |
116 | $this->getArg( 0, 1000 ), |
117 | 'Count' |
118 | ); |
119 | $numUsers = $this->ensureArgumentIsInt( |
120 | $this->getOption( 'num-users', 10 ), |
121 | 'Number of registered users' |
122 | ); |
123 | $numAnon = $this->ensureArgumentIsInt( |
124 | $this->getOption( 'num-anon', 5 ), |
125 | 'Number of anonymous users' |
126 | ); |
127 | $numTemp = $this->ensureArgumentIsInt( |
128 | $this->getOption( 'num-temp', 10 ), |
129 | 'Number of temporary users' |
130 | ); |
131 | $numIpsToUse = $this->ensureArgumentIsInt( |
132 | $this->getOption( 'num-used-ips', 5 ), |
133 | 'Number of IPs to use' |
134 | ); |
135 | $ipRanges = $this->getOption( 'ranges-for-ips', [ '127.0.0.1/24', 'fd12:3456:789a:1::/40' ] ); |
136 | |
137 | foreach ( $ipRanges as $range ) { |
138 | if ( !IPUtils::isValidRange( $range ) ) { |
139 | $this->fatalError( 'range-for-ips option must be a valid IP address range.' ); |
140 | } |
141 | if ( IPUtils::isIPv4( $range ) ) { |
142 | $this->ipv4Ranges[] = $range; |
143 | } else { |
144 | $this->ipv6Ranges[] = $range; |
145 | } |
146 | } |
147 | |
148 | if ( $numAnon > $numIpsToUse ) { |
149 | $this->fatalError( 'Number of anon users making edits should not exceed the number of IPs used.' ); |
150 | } |
151 | |
152 | if ( !$this->getServiceContainer()->getTempUserConfig()->isEnabled() ) { |
153 | // Only add temporary users if temporary user creation is enabled. |
154 | $numTemp = 0; |
155 | } |
156 | |
157 | $actionsPerActor = intval( floor( $count / array_sum( [ $numUsers, $numAnon, $numTemp ] ) ) ); |
158 | $remainderActions = $count % array_sum( [ $numUsers, $numAnon, $numTemp ] ); |
159 | |
160 | if ( $actionsPerActor < 5 ) { |
161 | $minCount = array_sum( [ $numUsers, $numAnon, $numTemp ] ) * 5; |
162 | $this->fatalError( |
163 | "Minimum actions per actor must be 5. Increase the 'count' argument to at least {$minCount}." |
164 | ); |
165 | } |
166 | |
167 | // Start code that can assume it is safe to perform un-reversible testing actions. |
168 | $this->hooks = new CheckUserHooks(); |
169 | $services = MediaWikiServices::getInstance(); |
170 | $userForEmails = $this->createRegisteredUser(); |
171 | if ( $userForEmails === null ) { |
172 | $this->fatalError( |
173 | "Unable to create a new user to be used as the target for emails and password resets.\n" |
174 | ); |
175 | } |
176 | $this->userToEmailAndSendPasswordResetsFor = $userForEmails; |
177 | $this->mainRequest = new FauxRequest(); |
178 | RequestContext::getMain()->setRequest( $this->mainRequest ); |
179 | $this->initUserAgentAndClientHintsCombos(); |
180 | |
181 | // Get $numIpsToUse IPs. |
182 | $this->ipsToUse = []; |
183 | // First try to get one random IPv4 in the allowed IPv4 range(s). |
184 | if ( count( $this->ipv4Ranges ) ) { |
185 | $this->ipsToUse[] = $this->generateNewIPv4(); |
186 | $numIpsToUse--; |
187 | } |
188 | // Next try to get one random IPv6 in the allowed IPv6 range(s). |
189 | if ( count( $this->ipv6Ranges ) && $numIpsToUse > 0 ) { |
190 | $this->ipsToUse[] = $this->generateNewIPv6(); |
191 | $numIpsToUse--; |
192 | } |
193 | // If IPs can still be chosen then randomly generate |
194 | // them from either IPv4 or IPv6 ranges. |
195 | if ( $numIpsToUse > 0 ) { |
196 | foreach ( range( 0, $numIpsToUse ) as $ignored ) { |
197 | $this->ipsToUse[] = $this->generateNewIp(); |
198 | } |
199 | } |
200 | |
201 | // Get the first IP, user agent and client hints data |
202 | $this->getNewIp(); |
203 | $this->getNewUserAgentAndAssociatedClientHints(); |
204 | |
205 | // First populate using users |
206 | for ( $i = 0; $i < $numUsers; $i++ ) { |
207 | $actionsLeft = $actionsPerActor; |
208 | $this->applyRemainderAction( $actionsLeft, $remainderActions ); |
209 | |
210 | // Find a username that is not already being used |
211 | $this->setNewRandomFakeTime(); |
212 | $lowerLimit = time() - ConvertibleTimestamp::time(); |
213 | $user = $this->createRegisteredUser(); |
214 | // Creating an account causes a log event. |
215 | $actionsLeft--; |
216 | if ( $user === null ) { |
217 | $this->output( "Unable to create new user. Skipping this actor.\n" ); |
218 | continue; |
219 | } |
220 | $this->output( "Processing user with username {$user->getName()}.\n" ); |
221 | |
222 | while ( $actionsLeft > 0 ) { |
223 | if ( $this->getRandomFloat() < 0.3 ) { |
224 | // Assign a new IP to the main request 30% of the time. |
225 | $this->getNewIp(); |
226 | } |
227 | |
228 | $actionsLeft -= $this->performInsertBatch( $user, $actionsLeft, $lowerLimit ); |
229 | } |
230 | } |
231 | |
232 | // Secondly populate using temporary accounts |
233 | for ( $i = 0; $i < $numTemp; $i++ ) { |
234 | $actionsLeft = $actionsPerActor; |
235 | $this->applyRemainderAction( $actionsLeft, $remainderActions ); |
236 | |
237 | $this->setNewRandomFakeTime(); |
238 | $lowerLimit = time() - ConvertibleTimestamp::time(); |
239 | $user = $services->getTempUserCreator()->create( |
240 | null, $this->mainRequest |
241 | )->getUser(); |
242 | // Creating a temporary user creates a log event. |
243 | $actionsLeft--; |
244 | $this->output( "Processing temporary user with username {$user->getName()}.\n" ); |
245 | |
246 | while ( $actionsLeft > 0 ) { |
247 | if ( $this->getRandomFloat() < 0.3 ) { |
248 | // Assign a new IP to the main request 30% of the time. |
249 | $this->getNewIp(); |
250 | } |
251 | |
252 | $actionsLeft -= $this->performInsertBatch( $user, $actionsLeft, $lowerLimit ); |
253 | } |
254 | } |
255 | |
256 | // Lastly populate using IPs |
257 | if ( count( $this->ipsToUse ) < 3 ) { |
258 | // If less than 3 IPs to choose from, keep the original ordering. |
259 | $ipsInOrder = $this->ipsToUse; |
260 | } else { |
261 | // If three or more IPs to choose from, pick the first two and a random |
262 | // selection of the other IPs. This ensures at least one IPv4 and IPv6 |
263 | // address is used if allowed. |
264 | $ipsInOrder = array_slice( $this->ipsToUse, 2 ); |
265 | shuffle( $ipsInOrder ); |
266 | array_unshift( $ipsInOrder, $this->ipsToUse[0], $this->ipsToUse[1] ); |
267 | } |
268 | for ( $i = 0; $i < $numAnon; $i++ ) { |
269 | $actionsLeft = $actionsPerActor; |
270 | $this->applyRemainderAction( $actionsLeft, $remainderActions ); |
271 | |
272 | $user = UserIdentityValue::newAnonymous( IPUtils::prettifyIP( $ipsInOrder[$i] ) ); |
273 | // Assign the request IP as the anon user being used for this loop. |
274 | RequestContext::getMain()->getRequest()->setIP( $user->getName() ); |
275 | $this->output( "Processing anon user with IP {$user->getName()}.\n" ); |
276 | |
277 | while ( $actionsLeft > 0 ) { |
278 | $this->randomlyAssignXFFHeader( $user->getName() ); |
279 | $actionsLeft -= $this->performInsertBatch( $user, $actionsLeft ); |
280 | } |
281 | } |
282 | } |
283 | |
284 | /** |
285 | * Ensure an argument provided via the command line is an integer. |
286 | * If it is not, then exit the script with a fatal error message. |
287 | * |
288 | * @param mixed $argument The argument from the command line (usually in string form) |
289 | * @param string $name The name of the argument used if the argument is not an integer |
290 | * in the fatal error message. |
291 | * @return int The argument as an integer (exit is called if the argument was invalid). |
292 | */ |
293 | private function ensureArgumentIsInt( $argument, string $name ): int { |
294 | if ( ( !$argument || !intval( $argument ) ) && $argument !== '0' ) { |
295 | $this->fatalError( "$name must be an integer" ); |
296 | } |
297 | return intval( $argument ); |
298 | } |
299 | |
300 | /** |
301 | * Reduce the remainder argument by 1 and increase the actions left |
302 | * argument by 1, as long as the remainder argument is above 0. |
303 | * |
304 | * @param int &$actionsLeft The actions left for an actor |
305 | * @param int &$remainderActions The remainder of the floor division |
306 | * @return void |
307 | */ |
308 | private function applyRemainderAction( int &$actionsLeft, int &$remainderActions ) { |
309 | if ( $remainderActions > 0 ) { |
310 | $actionsLeft += 1; |
311 | $remainderActions -= 1; |
312 | } |
313 | } |
314 | |
315 | /** |
316 | * Create a user on the wiki with a username |
317 | * prefixed with CheckUserSimulated and then |
318 | * a random string of hexadecimal characters. |
319 | * |
320 | * @return ?User A user that has just been created or null if this failed. |
321 | */ |
322 | private function createRegisteredUser(): ?User { |
323 | $services = MediaWikiServices::getInstance(); |
324 | // Find a username that doesn't exist. |
325 | $attemptsMade = 0; |
326 | do { |
327 | $user = $services->getUserFactory()->newFromName( |
328 | $this->getPrefix() . wfRandomString(), UserRigorOptions::RIGOR_CREATABLE |
329 | ); |
330 | if ( $attemptsMade > 100 ) { |
331 | return null; |
332 | } |
333 | $attemptsMade++; |
334 | } while ( $user === null || $user->isRegistered() ); |
335 | '@phan-var User $user'; |
336 | // Create an account using this username |
337 | $services->getAuthManager()->autoCreateUser( |
338 | $user, |
339 | AuthManager::AUTOCREATE_SOURCE_MAINT, |
340 | false |
341 | ); |
342 | return $user; |
343 | } |
344 | |
345 | /** |
346 | * Calls wfRandom and returns the value. |
347 | * |
348 | * Called by other code in this class so that tests |
349 | * can mock the return value. |
350 | * |
351 | * @return float A float in the range [0, 1] |
352 | */ |
353 | protected function getRandomFloat(): float { |
354 | return floatval( wfRandom() ); |
355 | } |
356 | |
357 | /** |
358 | * Calls mt_rand and returns the value. |
359 | * |
360 | * Called by code in this class so that tests |
361 | * can mock the return value to test behaviour |
362 | * that is determined randomly using mt_rand. |
363 | * |
364 | * @param int $min See mt_rand documentation |
365 | * @param int $max See mt_rand documentation |
366 | * @return int A random integer, see mt_rand documentation for more details. |
367 | */ |
368 | protected function mtRand( $min, $max ): int { |
369 | return mt_rand( $min, $max ); |
370 | } |
371 | |
372 | /** |
373 | * This method 30% of the time will apply a XFF header to the request |
374 | * and in other cases will clear any existing XFF header. |
375 | * |
376 | * @todo Make the XFF IPs make a bit more sense than using random IPs and/or |
377 | * make some of the XFF strings trusted. |
378 | * |
379 | * @param string $currentIp The current IP address of the request that will not |
380 | * be used in the XFF header. |
381 | */ |
382 | private function randomlyAssignXFFHeader( string $currentIp ): void { |
383 | if ( $this->getRandomFloat() < 0.3 ) { |
384 | $xffIp = $this->returnRandomIpExceptExcluded( [ $currentIp ] ); |
385 | if ( !$xffIp ) { |
386 | $xffValue = false; |
387 | } else { |
388 | $xffValue = IPUtils::prettifyIP( $xffIp ); |
389 | if ( $this->getRandomFloat() < 0.7 ) { |
390 | $xffIp = $this->returnRandomIpExceptExcluded( [ $currentIp, $xffIp ] ); |
391 | if ( $xffIp ) { |
392 | $xffValue = $xffValue . ', ' . IPUtils::prettifyIP( $xffIp ); |
393 | } |
394 | } |
395 | } |
396 | } else { |
397 | $xffValue = false; |
398 | } |
399 | $this->mainRequest->setHeaders( [ 'X-Forwarded-For' => $xffValue ] ); |
400 | } |
401 | |
402 | /** |
403 | * Return a random IP address from the list of IPs chosen |
404 | * in the property self::ipsToUse excluding those provided |
405 | * in the arguments. |
406 | * |
407 | * @param array $ipsExcluded The IPs to exclude from the random selection |
408 | * @return string|null A random IP or null if no IPs are left after the exclusion step. |
409 | */ |
410 | private function returnRandomIpExceptExcluded( array $ipsExcluded ): ?string { |
411 | $ipsToChoose = array_flip( array_filter( |
412 | $this->ipsToUse, |
413 | static function ( $item ) use ( $ipsExcluded ) { |
414 | return !in_array( $item, $ipsExcluded ); |
415 | } |
416 | ) ); |
417 | if ( count( $ipsToChoose ) ) { |
418 | return array_rand( $ipsToChoose ); |
419 | } |
420 | return null; |
421 | } |
422 | |
423 | /** |
424 | * Randomly pick either an IPv4 or IPv6 address |
425 | * from the list of IPs that were already chosen randomly. |
426 | * Also assign the IP as the IP used in the main request. |
427 | * |
428 | * @return string The IP that was chosen |
429 | */ |
430 | private function getNewIp(): string { |
431 | $ip = array_rand( array_flip( $this->ipsToUse ) ); |
432 | $this->randomlyAssignXFFHeader( $ip ); |
433 | RequestContext::getMain()->getRequest()->setIP( $ip ); |
434 | return $ip; |
435 | } |
436 | |
437 | /** |
438 | * Generate a randomly chosen IPv4 or IPv6 |
439 | * address that sits within the allowed |
440 | * ranges. A IPv4 address is returned 50% |
441 | * of the time on average. |
442 | * |
443 | * @return string |
444 | */ |
445 | private function generateNewIp(): string { |
446 | if ( $this->getRandomFloat() < 0.5 ) { |
447 | return $this->generateNewIPv4(); |
448 | } else { |
449 | return $this->generateNewIPv6(); |
450 | } |
451 | } |
452 | |
453 | /** |
454 | * Randomly pick a new IPv4 address that comes from |
455 | * one of the defined ranges. |
456 | * |
457 | * @return string The IP that was chosen |
458 | */ |
459 | private function generateNewIPv4(): string { |
460 | [ $start, $end ] = IPUtils::parseRange( array_rand( array_flip( $this->ipv4Ranges ) ) ); |
461 | $start = ip2long( IPUtils::formatHex( $start ) ); |
462 | $end = ip2long( IPUtils::formatHex( $end ) ); |
463 | $ipAsLong = $this->mtRand( $start, $end ); |
464 | $ip = long2ip( $ipAsLong ); |
465 | return $ip; |
466 | } |
467 | |
468 | /** |
469 | * Randomly pick a new IPv6 address that comes |
470 | * from one of the defined IPv6 ranges. |
471 | * |
472 | * @return string The IP that was chosen |
473 | */ |
474 | private function generateNewIPv6(): string { |
475 | [ $start, $end ] = IPUtils::parseRange( array_rand( array_flip( $this->ipv6Ranges ) ) ); |
476 | $ip = ''; |
477 | $seenDifference = false; |
478 | $lastOnEdgeOfRange = false; |
479 | for ( $i = 0; $i < strlen( $start ); $i++ ) { |
480 | if ( !$seenDifference && $start[$i] === $end[$i] ) { |
481 | // Same character in both end and start of range |
482 | // therefore the randomly selected IPv6 must have |
483 | // this character |
484 | $ip .= $start[$i]; |
485 | } elseif ( !$seenDifference ) { |
486 | // Not the same character, but this is the first difference |
487 | // seen in the characters between $start and $end so far. |
488 | // |
489 | // Choose a random hex character between the hex characters in |
490 | // $start and $end. |
491 | $startAtiAsDec = hexdec( $start[$i] ); |
492 | $endAtiAsDec = hexdec( $end[$i] ); |
493 | $newHexCharacter = dechex( $this->mtRand( $startAtiAsDec, $endAtiAsDec ) ); |
494 | $ip .= $newHexCharacter; |
495 | $seenDifference = true; |
496 | // If the randomly selected hex character is the same as the |
497 | // start of the end character, then the next hex character |
498 | // must be greater than or less than respectively than |
499 | // the character at $i. |
500 | if ( $newHexCharacter == $start[$i] ) { |
501 | $lastOnEdgeOfRange = 'start'; |
502 | } elseif ( $newHexCharacter == $end[$i] ) { |
503 | $lastOnEdgeOfRange = 'end'; |
504 | } |
505 | } elseif ( $lastOnEdgeOfRange === 'start' ) { |
506 | // Ensure the random selection never exceeds the value |
507 | // at $start[$i]. This is to prevent the IP being outside the range. |
508 | $startAtiAsDec = hexdec( $start[$i] ); |
509 | $newHexCharacter = dechex( $this->mtRand( $startAtiAsDec, 15 ) ); |
510 | $ip .= $newHexCharacter; |
511 | if ( $newHexCharacter !== $start[$i] ) { |
512 | $lastOnEdgeOfRange = false; |
513 | } |
514 | } elseif ( $lastOnEdgeOfRange === 'end' ) { |
515 | // Ensure the random selection never exceeds the value |
516 | // at $end[$i]. This is to prevent the IP being outside the range. |
517 | $endAtiAsDec = hexdec( $end[$i] ); |
518 | $newHexCharacter = dechex( $this->mtRand( 0, $endAtiAsDec ) ); |
519 | $ip .= $newHexCharacter; |
520 | if ( $newHexCharacter !== $end[$i] ) { |
521 | $lastOnEdgeOfRange = false; |
522 | } |
523 | } else { |
524 | // Randomly choose any hex character. |
525 | $ip .= dechex( $this->mtRand( 0, 15 ) ); |
526 | } |
527 | } |
528 | $ip = IPUtils::formatHex( $ip ); |
529 | return $ip; |
530 | } |
531 | |
532 | /** |
533 | * This method randomly chooses a User-Agent header string, assigns that |
534 | * to the request and then applies Client Hints headers if the browser |
535 | * that uses the selected User-Agent supports Client Hints. |
536 | * |
537 | * @return void |
538 | */ |
539 | private function getNewUserAgentAndAssociatedClientHints(): void { |
540 | $userAgent = array_rand( $this->userAgentsToClientHintsMap ); |
541 | $this->mainRequest->setHeader( 'User-Agent', $userAgent ); |
542 | /** @var ?ClientHintsData $clientHintsData */ |
543 | $clientHintsData = $this->userAgentsToClientHintsMap[$userAgent]; |
544 | // Unset any existing Client Hints data. |
545 | $clientHintHeadersToUnset = array_filter( |
546 | array_keys( $this->mainRequest->getAllHeaders() ), |
547 | static fn ( $headerName ) => str_starts_with( $headerName, 'SEC-CH-UA' ) |
548 | ); |
549 | foreach ( $clientHintHeadersToUnset as $clientHintHeader ) { |
550 | $this->mainRequest->setHeaders( [ $clientHintHeader => false ] ); |
551 | } |
552 | if ( $clientHintsData !== null ) { |
553 | // Set the Client Hints headers in the faux request. |
554 | $clientHintHeadersToSet = array_filter( array_keys( |
555 | $this->getConfig()->get( 'CheckUserClientHintsHeaders' ) |
556 | ) ); |
557 | foreach ( $clientHintHeadersToSet as $clientHintHeader ) { |
558 | $propertyName = ClientHintsData::HEADER_TO_CLIENT_HINTS_DATA_PROPERTY_NAME[$clientHintHeader]; |
559 | $this->mainRequest->setHeader( |
560 | $clientHintHeader, $clientHintsData->jsonSerialize()[$propertyName] |
561 | ); |
562 | } |
563 | } |
564 | $this->currentClientHintsData = $clientHintsData; |
565 | } |
566 | |
567 | /** |
568 | * Perform a insert batch for a given actor. The inserts will stop and the method |
569 | * will return early if the actions performed reaches $actionsLeft. |
570 | * |
571 | * This method inserts edits by actually performing the edit. It uses ManualLogEntry |
572 | * to create log entries that are visible in Special:Log. Other log events, such as |
573 | * logging in, which are not shown in Special:Log are created by calling the |
574 | * specified hook handler. |
575 | * |
576 | * @param UserIdentity $actor The user/IP/temporary account that will perform these actions |
577 | * @param int &$actionsLeft Must be greater than 0. Represents the number of actions left. |
578 | * @param ?int $lowerLimit The furthest ago the random fake time can be from the current time in seconds. |
579 | * @return int The actions actually performed in this batch. |
580 | */ |
581 | private function performInsertBatch( UserIdentity $actor, int &$actionsLeft, ?int $lowerLimit = null ): int { |
582 | $this->setNewRandomFakeTime( $lowerLimit ); |
583 | if ( $this->getRandomFloat() < 0.3 ) { |
584 | // Assign a new user agent and client hints combo 30% of the time |
585 | $this->getNewUserAgentAndAssociatedClientHints(); |
586 | } |
587 | $services = MediaWikiServices::getInstance(); |
588 | /** @var UserAgentClientHintsManager $userAgentClientHintsManager */ |
589 | $userAgentClientHintsManager = $services->getService( 'UserAgentClientHintsManager' ); |
590 | $actorAsUserObject = $services->getUserFactory()->newFromUserIdentity( $actor ); |
591 | $actionsPerformed = 0; |
592 | // Simulate a failed login 10% of the time. |
593 | if ( $actor->isRegistered() && $this->getRandomFloat() < 0.1 ) { |
594 | $failReasons = []; |
595 | // Simulate good password 30% of the time. |
596 | if ( $this->getRandomFloat() < 0.3 ) { |
597 | $failReasons[] = "good password"; |
598 | // The phrase "locked" comes from the CentralAuth extension and indicates that |
599 | // the account login was made on an account that was locked but the request |
600 | // otherwise used the correct password. |
601 | $failReasons[] = "locked"; |
602 | } else { |
603 | $failReasons[] = "bad password"; |
604 | } |
605 | $this->hooks->onAuthManagerLoginAuthenticateAudit( |
606 | AuthenticationResponse::newFail( wfMessage( 'test' ), $failReasons ), |
607 | $actorAsUserObject, |
608 | $actor->getName(), |
609 | [] |
610 | ); |
611 | if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) { |
612 | return $actionsPerformed; |
613 | } |
614 | } |
615 | if ( $actor->isRegistered() ) { |
616 | // Simulate a login. |
617 | $this->hooks->onAuthManagerLoginAuthenticateAudit( |
618 | AuthenticationResponse::newPass( $actor->getName() ), |
619 | $actorAsUserObject, |
620 | $actor->getName(), |
621 | [] |
622 | ); |
623 | if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) { |
624 | return $actionsPerformed; |
625 | } |
626 | } |
627 | // Perform a random number of edits, capped at 3. |
628 | $editsToPerform = intval( $this->getRandomFloat() * 3 ); |
629 | if ( !$actor->isRegistered() ) { |
630 | // Always perform at least one edit if an anon user. |
631 | $editsToPerform += 1; |
632 | } |
633 | foreach ( range( 0, $editsToPerform ) as $ignored ) { |
634 | $title = null; |
635 | if ( $this->getRandomFloat() < 0.3 ) { |
636 | $title = Title::newFromText( $this->getPrefix() . 'Existing page' ); |
637 | } |
638 | $revisionId = $this->performEdit( $actorAsUserObject, $title ); |
639 | // Send a REST API request for the edit with Client Hints data, if there is data specified. |
640 | if ( $this->currentClientHintsData !== null && $revisionId !== null ) { |
641 | $userAgentClientHintsManager->insertClientHintValues( |
642 | ClientHintsData::newFromJsApi( $this->currentClientHintsData->jsonSerialize() ), |
643 | $revisionId, |
644 | 'revision' |
645 | ); |
646 | } |
647 | if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) { |
648 | return $actionsPerformed; |
649 | } |
650 | } |
651 | // Simulate a random number of log actions, capped at 2. |
652 | $logsToPerform = intval( $this->getRandomFloat() * 2 ); |
653 | foreach ( range( 0, $logsToPerform ) as $ignored ) { |
654 | if ( $actor->isRegistered() ) { |
655 | $type = array_rand( self::VALID_LOG_EVENTS ); |
656 | } else { |
657 | $type = 'move'; |
658 | } |
659 | $action = array_rand( array_flip( self::VALID_LOG_EVENTS[$type] ) ); |
660 | $this->simulateLogAction( $type, $action, $actor ); |
661 | if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) { |
662 | return $actionsPerformed; |
663 | } |
664 | } |
665 | // Simulate an email 10% of the time |
666 | if ( $actor->isRegistered() && $this->getRandomFloat() < 0.1 ) { |
667 | $from = MailAddress::newFromUser( $actorAsUserObject ); |
668 | $to = MailAddress::newFromUser( $this->userToEmailAndSendPasswordResetsFor ); |
669 | $subject = 'Test'; |
670 | $text = wfRandomString(); |
671 | $error = []; |
672 | $this->hooks->onEmailUser( $to, $from, $subject, $text, $error ); |
673 | if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) { |
674 | return $actionsPerformed; |
675 | } |
676 | } |
677 | // Send password reset 10% of the time. |
678 | if ( $this->getRandomFloat() < 0.1 ) { |
679 | $this->hooks->onUser__mailPasswordInternal( |
680 | $actorAsUserObject, |
681 | RequestContext::getMain()->getRequest()->getIP(), |
682 | $this->userToEmailAndSendPasswordResetsFor |
683 | ); |
684 | if ( !$this->incrementAndCheck( $actionsPerformed, $actionsLeft ) ) { |
685 | return $actionsPerformed; |
686 | } |
687 | } |
688 | // Logout 50% of the time |
689 | if ( $actor->isRegistered() && $this->getRandomFloat() < 0.5 ) { |
690 | $html = ''; |
691 | $anonUser = $services->getUserFactory()->newFromName( |
692 | RequestContext::getMain()->getRequest()->getIP(), |
693 | UserRigorOptions::RIGOR_NONE |
694 | ); |
695 | if ( $anonUser ) { |
696 | $this->hooks->onUserLogoutComplete( $anonUser, $html, $actor->getName() ); |
697 | } |
698 | $this->incrementAndCheck( $actionsPerformed, $actionsLeft ); |
699 | } |
700 | return $actionsPerformed; |
701 | } |
702 | |
703 | /** |
704 | * Simulate a log action by creating an entry in Special:Log but not actually |
705 | * performing the action that is referenced in the log entry. |
706 | * |
707 | * Then tell CheckUser about this log entry, so that it is stored in the |
708 | * results list. |
709 | * |
710 | * @param string $type The log type |
711 | * @param string $action The log subtype (otherwise known as action) |
712 | * @param UserIdentity $actor The intended performer of this log action. |
713 | * @return void |
714 | */ |
715 | private function simulateLogAction( string $type, string $action, UserIdentity $actor ): void { |
716 | $logEntry = new ManualLogEntry( $type, $action ); |
717 | $logEntry->setPerformer( $actor ); |
718 | $logEntry->setTarget( Title::newFromText( $this->getPrefix() . 'Existing page' ) ); |
719 | $logEntry->setComment( wfRandomString() ); |
720 | if ( $type === 'move' ) { |
721 | $logEntry->setParameters( [ |
722 | '4::target' => $this->getPrefix() . wfRandomString(), |
723 | '5::noredir' => '0' |
724 | ] ); |
725 | } elseif ( $type === 'merge' ) { |
726 | $logEntry->setParameters( [ |
727 | '4::dest' => $this->getPrefix() . wfRandomString(), |
728 | '5::mergepoint' => $logEntry->getTimestamp() |
729 | ] ); |
730 | } elseif ( $type === 'delete' && $action === 'undelete' ) { |
731 | $logEntry->setParameters( [ |
732 | ':assoc:count' => [ |
733 | 'revisions' => 123, |
734 | 'files' => 1, |
735 | ], |
736 | ] ); |
737 | } |
738 | $id = $logEntry->insert(); |
739 | $this->hooks->onRecentChange_save( $logEntry->getRecentChange( $id ) ); |
740 | } |
741 | |
742 | /** |
743 | * Actually perform an edit using the given actor |
744 | * that is published to Special:RecentChanges (and |
745 | * then by extension Special:CheckUser) |
746 | * |
747 | * @param User $actor The user which is performing the edit |
748 | * @param Title|null $title The title of the page the edit will be performed on. Use null for a random title. |
749 | * @return int|null The revision ID of the edit if successful, otherwise null. |
750 | */ |
751 | private function performEdit( User $actor, ?Title $title ): ?int { |
752 | $tags = 0; |
753 | if ( $this->getRandomFloat() < 0.5 ) { |
754 | // Add minor edit flag 50% of the time. |
755 | $tags = EDIT_MINOR; |
756 | } |
757 | $title ??= Title::newFromText( $this->getPrefix() . wfRandomString() ); |
758 | if ( !$title ) { |
759 | return null; |
760 | } |
761 | $page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title ); |
762 | $status = $page->doUserEditContent( |
763 | ContentHandler::makeContent( |
764 | wfRandomString(), |
765 | $title, |
766 | // Regardless of how the wiki is configure or what extensions are present, |
767 | // force this page to be a wikitext one. |
768 | CONTENT_MODEL_WIKITEXT |
769 | ), |
770 | $actor, |
771 | wfRandomString(), |
772 | $tags |
773 | ); |
774 | if ( !$status->isOK() ) { |
775 | return null; |
776 | } |
777 | return $status->getNewRevision()->getId(); |
778 | } |
779 | |
780 | /** |
781 | * Increment the actions performed counter, move the fake time forward |
782 | * by a random time no greater than 240 seconds and then check if more |
783 | * actions can be performed by checking against the second parameter |
784 | * |
785 | * @param int &$actionsPerformed The number of actions performed on this insert batch |
786 | * @param int $actionsLeft The number of actions left to perform for this actor |
787 | * @return bool Whether more actions can be performed |
788 | */ |
789 | private function incrementAndCheck( int &$actionsPerformed, int $actionsLeft ): bool { |
790 | $actionsPerformed++; |
791 | $this->moveFakeTimeForward(); |
792 | return $actionsPerformed < $actionsLeft; |
793 | } |
794 | |
795 | /** |
796 | * Set the time to a fake time between now and CUDMaxAge seconds ago. |
797 | * |
798 | * @param ?int $lowerLimit The maximum number of seconds ago this random timestamp can be. An hour |
799 | * is always added to this number. |
800 | * @return void |
801 | */ |
802 | private function setNewRandomFakeTime( ?int $lowerLimit = null ): void { |
803 | // Clear any fake time (to allow the ConvertibleTimestamp::time() call to use the real time). |
804 | ConvertibleTimestamp::setFakeTime( false ); |
805 | // Set the new fake time |
806 | // |
807 | // Ensure the new fake time is at least an hour ago from the actual time. |
808 | $newFakeTime = ConvertibleTimestamp::time() - 3600; |
809 | // Ensure the new fake time is appropriately chosen from any time period results can be in. |
810 | if ( $lowerLimit === null ) { |
811 | // Default is to ensure random time cannot be more than CUDMaxAge seconds ago minus 2 hours. |
812 | $lowerLimit = $this->getConfig()->get( 'CUDMaxAge' ) - ( 3600 * 2 ); |
813 | } |
814 | $newFakeTime -= intval( $this->getRandomFloat() * $lowerLimit ); |
815 | ConvertibleTimestamp::setFakeTime( $newFakeTime ); |
816 | } |
817 | |
818 | /** |
819 | * Move the fake time forward by a random number of seconds between 0 and 240 seconds. |
820 | * |
821 | * @return void |
822 | */ |
823 | private function moveFakeTimeForward(): void { |
824 | ConvertibleTimestamp::setFakeTime( |
825 | ConvertibleTimestamp::time() + intval( $this->getRandomFloat() * 239 ) + 1 |
826 | ); |
827 | } |
828 | |
829 | /** |
830 | * Initialise the User-Agent header and Client Hints combinations |
831 | * as the ClientHints objects cannot be created in a constant property. |
832 | * |
833 | * @return void |
834 | */ |
835 | private function initUserAgentAndClientHintsCombos(): void { |
836 | $this->userAgentsToClientHintsMap = [ |
837 | 'Mozilla/5.0 (iPhone13,2; U; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/602.1.50 ' . |
838 | '(KHTML, like Gecko) Version/10.0 Mobile/15E148 Safari/602.1' => |
839 | null, |
840 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0' => |
841 | null, |
842 | ]; |
843 | $this->userAgentsToClientHintsMap[ |
844 | 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) ' . |
845 | 'Chrome/115.0.0.0 Mobile Safari/537.36' |
846 | ] = new ClientHintsData( |
847 | "", |
848 | "64", |
849 | [ |
850 | [ "brand" => "Not/A)Brand", "version" => "99" ], |
851 | [ "brand" => "Google Chrome", "version" => "115" ], |
852 | [ "brand" => "Chromium", "version" => "115" ], |
853 | ], |
854 | null, |
855 | [ |
856 | [ "brand" => "Not/A)Brand", "version" => "99.0.0.0" ], |
857 | [ "brand" => "Google Chrome", "version" => "115.0.5790.171" ], |
858 | [ "brand" => "Chromium", "version" => "115.0.5790.171" ], |
859 | ], |
860 | true, |
861 | "SM-G965U", |
862 | "Android", |
863 | "10.0.0", |
864 | '"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"', |
865 | false |
866 | ); |
867 | $this->userAgentsToClientHintsMap[ |
868 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' . |
869 | 'Chrome/112.0.0.0 Safari/537.36 OPR/98.0.0.0' |
870 | ] = new ClientHintsData( |
871 | "x86", |
872 | "64", |
873 | [ |
874 | [ "brand" => "Chromium", "version" => "112" ], |
875 | [ "brand" => "Not_A Brand", "version" => "24" ], |
876 | [ "brand" => "Opera GX", "version" => "98" ], |
877 | ], |
878 | null, |
879 | [ |
880 | [ "brand" => "Chromium", "version" => "112.0.5615.165" ], |
881 | [ "brand" => "Not_A Brand", "version" => "24.0.0.0" ], |
882 | [ "brand" => "Opera GX", "version" => "98.0.4759.82" ], |
883 | ], |
884 | false, |
885 | "", |
886 | "Windows", |
887 | "15.0.0", |
888 | '"Chromium";v="112", "Not_A Brand";v="24", "Opera GX";v="98"', |
889 | false |
890 | ); |
891 | $this->userAgentsToClientHintsMap[ |
892 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' . |
893 | 'Chrome/115.0.0.0 Safari/537.36' |
894 | ] = new ClientHintsData( |
895 | "x86", |
896 | "64", |
897 | [ |
898 | [ "brand" => "Not/A)Brand", "version" => "99" ], |
899 | [ "brand" => "Google Chrome", "version" => "115" ], |
900 | [ "brand" => "Chromium", "version" => "115" ], |
901 | ], |
902 | null, |
903 | [ |
904 | [ "brand" => "Not/A)Brand", "version" => "99.0.0.0" ], |
905 | [ "brand" => "Google Chrome", "version" => "115.0.5790.171" ], |
906 | [ "brand" => "Chromium", "version" => "115.0.5790.171" ], |
907 | ], |
908 | false, |
909 | "", |
910 | "Windows", |
911 | "15.0.0", |
912 | '"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"', |
913 | false |
914 | ); |
915 | $this->userAgentsToClientHintsMap[ |
916 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' . |
917 | 'Chrome/114.0.0.0 Safari/537.36' |
918 | ] = new ClientHintsData( |
919 | "x86", |
920 | null, |
921 | [ |
922 | [ "brand" => "Not/A)Brand", "version" => "99" ], |
923 | [ "brand" => "Google Chrome", "version" => "114" ], |
924 | [ "brand" => "Chromium", "version" => "114" ], |
925 | ], |
926 | null, |
927 | null, |
928 | false, |
929 | "", |
930 | "Windows", |
931 | null, |
932 | '"Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"', |
933 | null |
934 | ); |
935 | } |
936 | |
937 | private function getPrefix(): string { |
938 | return 'CheckUserSimulated-'; |
939 | } |
940 | } |
941 | |
942 | $maintClass = PopulateCheckUserTablesWithSimulatedData::class; |
943 | require_once RUN_MAINTENANCE_IF_MAIN; |