Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
ProtectedVarsAccessLogger
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
4 / 4
11
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logViewProtectedVariableValue
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 createProtectedVariableValueAccessLog
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
7
 createManualLogEntry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use MediaWiki\Deferred\DeferredUpdates;
6use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
7use MediaWiki\Logging\DatabaseLogEntry;
8use MediaWiki\Logging\ManualLogEntry;
9use MediaWiki\Profiler\Profiler;
10use MediaWiki\Title\TitleFactory;
11use MediaWiki\User\ActorStore;
12use MediaWiki\User\UserIdentity;
13use Psr\Log\LoggerInterface;
14use Wikimedia\Assert\Assert;
15use Wikimedia\Rdbms\DBError;
16use Wikimedia\Rdbms\IConnectionProvider;
17
18/**
19 * Defines the API for the component responsible for logging when a user views the value of protected variables.
20 */
21class ProtectedVarsAccessLogger {
22    /**
23     * Represents a user viewing the value of a protected variable
24     *
25     * @var string
26     */
27    public const ACTION_VIEW_PROTECTED_VARIABLE_VALUE = 'view-protected-var-value';
28
29    /**
30     * @var string
31     */
32    public const LOG_TYPE = 'abusefilter-protected-vars';
33
34    /**
35     * @param LoggerInterface $logger
36     * @param IConnectionProvider $lbFactory
37     * @param ActorStore $actorStore
38     * @param AbuseFilterHookRunner $hookRunner
39     * @param TitleFactory $titleFactory
40     * @param int $delay The number of seconds after which a duplicate log entry can be
41     *  created for a debounced log
42     * @internal Use {@link AbuseLoggerFactory::getProtectedVarsAccessLogger} instead
43     */
44    public function __construct(
45        private readonly LoggerInterface $logger,
46        private readonly IConnectionProvider $lbFactory,
47        private readonly ActorStore $actorStore,
48        private readonly AbuseFilterHookRunner $hookRunner,
49        private readonly TitleFactory $titleFactory,
50        private readonly int $delay
51    ) {
52        Assert::parameter( $delay > 0, 'delay', 'delay must be positive' );
53    }
54
55    /**
56     * Log when the user views the values of protected variables
57     *
58     * @param UserIdentity $performer
59     * @param string $target
60     * @param string[] $viewedVariables The variables associated with the values the user saw.
61     * @param int|null $timestamp
62     */
63    public function logViewProtectedVariableValue(
64        UserIdentity $performer,
65        string $target,
66        array $viewedVariables,
67        ?int $timestamp = null
68    ): void {
69        if ( !$timestamp ) {
70            $timestamp = (int)wfTimestamp();
71        }
72        // Create the log on POSTSEND, as this can be called in a context of a GET request through the
73        // QueryAbuseLog API (T379083).
74        DeferredUpdates::addCallableUpdate( function () use ( $performer, $target, $timestamp, $viewedVariables ) {
75            // We need to create a log entry and PostSend-GET expects no writes are performed, so we need to
76            // silence the warnings created by this.
77            $trxProfiler = Profiler::instance()->getTransactionProfiler();
78            $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY );
79            $this->createProtectedVariableValueAccessLog(
80                $performer,
81                $target,
82                $timestamp,
83                [ 'variables' => $viewedVariables ]
84            );
85        } );
86    }
87
88    /**
89     * Actually creates the log for when a user views the value of protected variables
90     *
91     * @param UserIdentity $performer
92     * @param string $target
93     * @param int $timestamp
94     * @param array $params
95     */
96    private function createProtectedVariableValueAccessLog(
97        UserIdentity $performer,
98        string $target,
99        int $timestamp,
100        array $params
101    ): void {
102        // Allow external extensions to hook into this logger and pass along all known
103        // values. External extensions can abort this hook to stop additional logging
104        if ( !$this->hookRunner->onAbuseFilterLogProtectedVariableValueAccess(
105            $performer,
106            $target,
107            self::ACTION_VIEW_PROTECTED_VARIABLE_VALUE,
108            true,
109            $timestamp,
110            $params
111        ) ) {
112            // Don't continue if the hook returns false
113            return;
114        }
115
116        $dbw = $this->lbFactory->getPrimaryDatabase();
117        $shouldLog = false;
118
119        // Don't log more than one protected variable access log if the same log was created
120        // within the delay period.
121        $timestampMinusDelay = $timestamp - $this->delay;
122        $actorId = $this->actorStore->findActorId( $performer, $dbw );
123        $targetAsTitle = $this->titleFactory->makeTitle( NS_USER, $target );
124        if ( !$actorId ) {
125            $shouldLog = true;
126        } else {
127            $logRows = DatabaseLogEntry::newSelectQueryBuilder( $dbw )
128                ->where( [
129                    'log_type' => self::LOG_TYPE,
130                    'log_action' => self::ACTION_VIEW_PROTECTED_VARIABLE_VALUE,
131                    'log_actor' => $actorId,
132                    'log_namespace' => $targetAsTitle->getNamespace(),
133                    'log_title' => $targetAsTitle->getDBkey(),
134                    $dbw->expr( 'log_timestamp', '>', $dbw->timestamp( $timestampMinusDelay ) ),
135                ] )
136                ->caller( __METHOD__ )
137                ->fetchResultSet();
138
139            $params['variables'] = array_values( $params['variables'] );
140            $variablesToFind = $params['variables'];
141
142            foreach ( $logRows as $logRow ) {
143                $logEntry = DatabaseLogEntry::newFromRow( $logRow );
144                $loggedVariables = $logEntry->getParameters()['variables'] ?? [];
145
146                $variablesToFind = array_diff( $variablesToFind, $loggedVariables );
147                if ( count( $variablesToFind ) === 0 ) {
148                    break;
149                }
150            }
151
152            $shouldLog = count( $variablesToFind ) > 0;
153        }
154
155        // Actually write to logging table
156        if ( $shouldLog ) {
157            $logEntry = $this->createManualLogEntry( self::ACTION_VIEW_PROTECTED_VARIABLE_VALUE );
158            $logEntry->setPerformer( $performer );
159            $logEntry->setTarget( $targetAsTitle );
160            $logEntry->setParameters( $params );
161            $logEntry->setTimestamp( wfTimestamp( TS_MW, $timestamp ) );
162
163            try {
164                $logEntry->insert( $dbw );
165            } catch ( DBError $e ) {
166                $this->logger->critical(
167                    'AbuseFilter protected variable log entry was not recorded. ' .
168                    'This means access to private data can occur without this being auditable. ' .
169                    'Immediate fix required.'
170                );
171
172                throw $e;
173            }
174        }
175    }
176
177    /**
178     * @internal
179     *
180     * @param string $subtype
181     * @return ManualLogEntry
182     */
183    protected function createManualLogEntry( string $subtype ): ManualLogEntry {
184        return new ManualLogEntry( self::LOG_TYPE, $subtype );
185    }
186}