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