Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.00% covered (danger)
50.00%
72 / 144
23.81% covered (danger)
23.81%
5 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Waf_Blocklog_Manager
50.00% covered (danger)
50.00%
72 / 144
23.81% covered (danger)
23.81%
5 / 21
510.00
0.00% covered (danger)
0.00%
0 / 1
 get_blocklog_file_path
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 connect_to_wordpress_db
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
10.15
 close_db_connection
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 serialize_option_value
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unserialize_option_value
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create_blocklog_table
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 write_blocklog_row
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
8.74
 get_daily_summary
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
15.55
 increment_daily_summary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 write_daily_summary_row
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 write_daily_summary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_all_time_block_count_value
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
10.75
 write_all_time_block_count_row
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 write_all_time_block_count
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 filter_last_30_days
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_current_day_block_count
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_thirty_days_block_counts
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_all_time_block_count
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_default_all_time_stat_value
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
18.35
 get_request_headers
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 write_blocklog
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
12.08
1<?php
2/**
3 * Blocklog manager for the WAF
4 *
5 * @package automattic/jetpack-waf
6 */
7
8namespace Automattic\Jetpack\Waf;
9
10/**
11 * Class used to manage blocklog operations
12 */
13class Waf_Blocklog_Manager {
14
15    const BLOCKLOG_OPTION_NAME_DAILY_SUMMARY        = 'jetpack_waf_blocklog_daily_summary';
16    const BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT = 'jetpack_waf_all_time_block_count';
17
18    /**
19     * Database connection.
20     *
21     * @var \mysqli|null
22     */
23    private static $db_connection = null;
24
25    /**
26     * Gets the path to the waf-blocklog file.
27     *
28     * @return string The waf-blocklog file path.
29     */
30    public static function get_blocklog_file_path() {
31        return trailingslashit( JETPACK_WAF_DIR ) . 'waf-blocklog';
32    }
33
34    /**
35     * Connect to WordPress database.
36     *
37     * @return \mysqli|null
38     */
39    private static function connect_to_wordpress_db() {
40        if ( self::$db_connection !== null ) {
41            return self::$db_connection;
42        }
43
44        if ( ! file_exists( JETPACK_WAF_WPCONFIG ) ) {
45            return null;
46        }
47
48        require_once JETPACK_WAF_WPCONFIG;
49        // @phan-suppress-next-line PhanUndeclaredConstant - These constants are defined in the wp-config file.
50        $conn = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli
51
52        if ( $conn->connect_error ) {
53            error_log( 'Could not connect to the database:' . $conn->connect_error );
54            return null;
55        }
56
57        self::$db_connection = $conn;
58        return self::$db_connection;
59    }
60
61    /**
62     * Close the database connection.
63     *
64     * @return void
65     */
66    private static function close_db_connection() {
67        if ( self::$db_connection ) {
68            self::$db_connection->close();
69            self::$db_connection = null;
70        }
71    }
72
73    /**
74     * Serialize a value for storage in a WordPress option.
75     *
76     * @param mixed $value The value to serialize.
77     * @return string The serialized value.
78     */
79    private static function serialize_option_value( $value ) {
80        return serialize( $value );
81    }
82
83    /**
84     * Unserialize a value from a WordPress option.
85     *
86     * @param string $value The serialized value.
87     * @return mixed The unserialized value.
88     */
89    private static function unserialize_option_value( string $value ) {
90        return unserialize( $value );
91    }
92
93    /**
94     * Create the log table when plugin is activated.
95     *
96     * @return void
97     */
98    public static function create_blocklog_table() {
99        global $wpdb;
100
101        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
102
103        $sql = "
104        CREATE TABLE {$wpdb->prefix}jetpack_waf_blocklog (
105            log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
106            timestamp datetime NOT NULL,
107            rule_id BIGINT NOT NULL,
108            reason longtext NOT NULL,
109            PRIMARY KEY (log_id),
110            KEY timestamp (timestamp)
111        )
112        ";
113
114        dbDelta( $sql );
115    }
116
117    /**
118     * Write block logs to database.
119     *
120     * @param array $log_data Log data.
121     *
122     * @return void
123     */
124    private static function write_blocklog_row( $log_data ) {
125        $conn = self::connect_to_wordpress_db();
126
127        if ( ! $conn ) {
128            return;
129        }
130
131        global $table_prefix;
132
133        $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" );
134        if ( false !== $statement ) {
135            $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] );
136            $statement->execute();
137
138            if ( $conn->insert_id > 100 ) {
139                $conn->query( "DELETE FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id LIMIT 1" );
140            }
141        }
142    }
143
144    /**
145     * Get the daily summary stats from the database.
146     *
147     * @return array The daily summary stats.
148     */
149    private static function get_daily_summary() {
150        global $table_prefix;
151        $db_connection = self::connect_to_wordpress_db();
152        if ( ! $db_connection ) {
153            return array();
154        }
155
156        $result = $db_connection->query( "SELECT option_value FROM {$table_prefix}options WHERE option_name = '" . self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY . "'" );
157        if ( ! $result ) {
158            return array();
159        }
160
161        $row = $result->fetch_assoc();
162        if ( ! $row ) {
163            return array();
164        }
165
166        $daily_summary = self::unserialize_option_value( $row['option_value'] );
167        $result->free();
168
169        return is_array( $daily_summary ) ? $daily_summary : array();
170    }
171
172    /**
173     * Increments the current date's daily summary stat.
174     *
175     * @param array $current_value The current value of the daily summary.
176     *
177     * @return array The updated daily summary.
178     */
179    public static function increment_daily_summary( array $current_value ) {
180        $date                   = gmdate( 'Y-m-d' );
181        $value                  = intval( $current_value[ $date ] ?? 0 );
182        $current_value[ $date ] = $value + 1;
183
184        return $current_value;
185    }
186
187    /**
188     * Update the daily summary option in the database.
189     *
190     * @param array $value The value to update.
191     *
192     * @return void
193     */
194    private static function write_daily_summary_row( array $value ) {
195        global $table_prefix;
196        $option_name = self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY;
197
198        $db_connection = self::connect_to_wordpress_db();
199        if ( ! $db_connection ) {
200            return;
201        }
202
203        $updated_value = self::serialize_option_value( $value );
204
205        $statement = $db_connection->prepare( "INSERT INTO {$table_prefix}options (option_name, option_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE option_value = ?" );
206        if ( false !== $statement ) {
207            $statement->bind_param( 'sss', $option_name, $updated_value, $updated_value );
208            $statement->execute();
209        }
210    }
211
212    /**
213     * Update the daily summary stats for the current date.
214     *
215     * @return void
216     */
217    private static function write_daily_summary() {
218        $stats = self::get_daily_summary();
219        $stats = self::increment_daily_summary( $stats );
220        $stats = self::filter_last_30_days( $stats );
221
222        self::write_daily_summary_row( $stats );
223    }
224
225    /**
226     * Get the all-time block count value from the database.
227     *
228     * @return int The all-time block count.
229     */
230    private static function get_all_time_block_count_value() {
231        global $table_prefix;
232        $db_connection = self::connect_to_wordpress_db();
233        if ( ! $db_connection ) {
234            return 0;
235        }
236
237        $result = $db_connection->query( "SELECT option_value FROM {$table_prefix}options WHERE option_name = '" . self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT . "'" );
238        if ( ! $result ) {
239            return 0;
240        }
241
242        $row = $result->fetch_assoc();
243        if ( ! $row ) {
244            return 0;
245        }
246
247        $all_time_block_count = intval( $row['option_value'] );
248        $result->free();
249
250        return $all_time_block_count;
251    }
252
253    /**
254     * Update the all-time block count value in the database.
255     *
256     * @param int $value The value to update.
257     * @return void
258     */
259    private static function write_all_time_block_count_row( int $value ) {
260        global $table_prefix;
261        $option_name = self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT;
262
263        $db_connection = self::connect_to_wordpress_db();
264        if ( ! $db_connection ) {
265            return;
266        }
267
268        $statement = $db_connection->prepare( "INSERT INTO {$table_prefix}options (option_name, option_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE option_value = ?" );
269        if ( false !== $statement ) {
270            $statement->bind_param( 'sii', $option_name, $value, $value );
271            $statement->execute();
272        }
273    }
274
275    /**
276     * Increment the all-time stats.
277     *
278     * @return void
279     */
280    private static function write_all_time_block_count() {
281        $block_count = self::get_all_time_block_count_value();
282        if ( ! $block_count ) {
283            $block_count = self::get_default_all_time_stat_value();
284        }
285
286        self::write_all_time_block_count_row( $block_count + 1 );
287    }
288
289    /**
290     * Filters the stats to retain only data for the last 30 days.
291     *
292     * @param array $stats The array of stats to prune.
293     *
294     * @return array Pruned stats array.
295     */
296    public static function filter_last_30_days( array $stats ) {
297        $today         = gmdate( 'Y-m-d' );
298        $one_month_ago = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
299
300        return array_filter(
301            $stats,
302            function ( $date ) use ( $one_month_ago, $today ) {
303                return $date >= $one_month_ago && $date <= $today;
304            },
305            ARRAY_FILTER_USE_KEY
306        );
307    }
308
309    /**
310     * Get the total number of blocked requests for today.
311     *
312     * @return int
313     */
314    public static function get_current_day_block_count() {
315        $stats = get_option( self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY, array() );
316        $today = gmdate( 'Y-m-d' );
317
318        return $stats[ $today ] ?? 0;
319    }
320
321    /**
322     * Get the total number of blocked requests for last thirty days.
323     *
324     * @return int
325     */
326    public static function get_thirty_days_block_counts() {
327        $stats        = get_option( self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY, array() );
328        $total_blocks = 0;
329
330        foreach ( $stats as $count ) {
331            $total_blocks += intval( $count );
332        }
333
334        return $total_blocks;
335    }
336
337    /**
338     * Get the total number of blocked requests for all time.
339     *
340     * @return int
341     */
342    public static function get_all_time_block_count() {
343        $all_time_block_count = get_option( self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT, false );
344
345        if ( false !== $all_time_block_count ) {
346            return intval( $all_time_block_count );
347        }
348
349        return self::get_default_all_time_stat_value();
350    }
351
352    /**
353     * Compute the initial all-time stats value.
354     *
355     * @return int The initial all-time stats value.
356     */
357    private static function get_default_all_time_stat_value() {
358        $conn = self::connect_to_wordpress_db();
359        if ( ! $conn ) {
360            return 0;
361        }
362
363        global $table_prefix;
364
365        $last_log_id_result = $conn->query( "SELECT log_id FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT 1" );
366
367        $all_time_block_count = 0;
368
369        if ( $last_log_id_result && $last_log_id_result->num_rows > 0 ) {
370            $row = $last_log_id_result->fetch_assoc();
371            if ( $row !== null && isset( $row['log_id'] ) ) {
372                $all_time_block_count = $row['log_id'];
373            }
374        }
375
376        return intval( $all_time_block_count );
377    }
378
379    /**
380     * Get the headers for logging purposes.
381     *
382     * @return array The headers.
383     */
384    public static function get_request_headers() {
385        $all_headers     = getallheaders();
386        $exclude_headers = array( 'Authorization', 'Cookie', 'Proxy-Authorization', 'Set-Cookie' );
387
388        foreach ( $exclude_headers as $header ) {
389            unset( $all_headers[ $header ] );
390        }
391
392        return $all_headers;
393    }
394
395    /**
396     * Write block logs. We won't write to the file if it exceeds 100 mb.
397     *
398     * @param string $rule_id The rule ID that triggered the block.
399     * @param string $reason  The reason for the block.
400     *
401     * @return void
402     */
403    public static function write_blocklog( $rule_id, $reason ) {
404        $log_data                 = array();
405        $log_data['rule_id']      = $rule_id;
406        $log_data['reason']       = $reason;
407        $log_data['timestamp']    = gmdate( 'Y-m-d H:i:s' );
408        $log_data['request_uri']  = isset( $_SERVER['REQUEST_URI'] ) ? \stripslashes( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
409        $log_data['user_agent']   = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \stripslashes( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
410        $log_data['referer']      = isset( $_SERVER['HTTP_REFERER'] ) ? \stripslashes( $_SERVER['HTTP_REFERER'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
411        $log_data['content_type'] = isset( $_SERVER['CONTENT_TYPE'] ) ? \stripslashes( $_SERVER['CONTENT_TYPE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
412        $log_data['get_params']   = json_encode( $_GET, JSON_UNESCAPED_SLASHES );
413
414        if ( defined( 'JETPACK_WAF_SHARE_DEBUG_DATA' ) && JETPACK_WAF_SHARE_DEBUG_DATA ) {
415            $log_data['post_params'] = json_encode( $_POST, JSON_UNESCAPED_SLASHES );
416            $log_data['headers']     = self::get_request_headers();
417        }
418
419        if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) {
420            $file_path   = JETPACK_WAF_DIR . '/waf-blocklog';
421            $file_exists = file_exists( $file_path );
422
423            if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) {
424                $fp = fopen( $file_path, 'a+' );
425
426                if ( $fp ) {
427                    try {
428                        fwrite( $fp, json_encode( $log_data, JSON_UNESCAPED_SLASHES ) . "\n" );
429                    } finally {
430                        fclose( $fp );
431                    }
432                }
433            }
434        }
435
436        self::write_daily_summary();
437        self::write_all_time_block_count();
438        self::write_blocklog_row( $log_data );
439        self::close_db_connection();
440    }
441}