From 9bc7ce469be80d3f4f15cb39687d6cc1402e3cdb Mon Sep 17 00:00:00 2001 From: dkmyta <43220201+dkmyta@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:25:48 -0700 Subject: [PATCH] Protect: Add WAF StatCards (#35739) * Add waf stats class blocked requests stats actions, and apply stat card components to firewall header * Adjust approach for storing and retrieving stats * changelog * Fix phpcs enables * Remove FirewallHeader stories edits * Use options over an additional table * Reorg and fix spacing * Fix dynamic/static calls * Make update_daily_summary public for tests * use deprecated setMethods over onlyMethods to bypass PHP 7.0 project test failures * Fix up project versions * Disable summary updates if share data is disabled, and add conditions to stat card display * Remove TODOs * Improve changelog entries * Restructuring, improvements, storybook fixes * Make use of static methods consistent * Add standalone mode handling for update_daily_summary * Fix tests * Add integration tests * Revert unit test removal * Fix naming * Fix project versions * Fix versions * Remove IDC package * Remove duplicate changelog entries * Revert vscode settings.json changes * changelog * Undo wpcomsh plugin updates * Revert prior changes to wpcomsh * Fix versions * Optimize FirewallStatCards * Fix share data option check * Fix styling * Fix translation strings * Ensure no duplicated code and extra debug data is logged appropriately * Fix tests * Fix phan errors * Use supported phpunit method * Use supported phpunit method * Final test fixes * Remove unused code, add plan check for new stats * Fix method naming * Add method for retrieving all time stats * Make direct db connection persistent, add/fix tests * Close db connection if initialized * Add standalone mode direct db handling * Fix project versions * Ignore phpcs rules, data needs to be current * Remove error_log * Address phan errors * Use standard approach * Add update flag to initialization method so we aren't duplicating the action * Fix initialization method calls * Manually update phan baselines * Revert versions updates * Remove duplicate changelog entries * Remove FirewallHeader stories from broken dir * Update stories * Updates to Blocklog Manager class * Fix prop name * Minor enhancements and compatibility improvements * Statcard adjustments * Revert phan baselines changes * Reapply phan baseline changes * Apply naming consistency, update FirewallSubheading to use IconTooltip * Fixes --------- Co-authored-by: Nate Weller --- .../changelog/add-protect-firewall-stat-cards | 4 + projects/js-packages/components/index.ts | 1 + projects/packages/waf/.phan/baseline.php | 3 +- .../changelog/add-protect-firewall-stat-cards | 4 + .../waf/src/class-waf-blocklog-manager.php | 441 ++++++++++++++++++ .../packages/waf/src/class-waf-runner.php | 26 +- .../packages/waf/src/class-waf-runtime.php | 102 +--- projects/packages/waf/src/class-waf-stats.php | 46 +- projects/packages/waf/src/functions.php | 48 ++ .../php/unit/test-waf-blocklog-manager.php | 76 +++ .../waf/tests/php/unit/test-waf-runtime.php | 22 - .../changelog/add-protect-firewall-stat-cards | 5 + .../changelog/add-protect-firewall-stat-cards | 4 + .../add-protect-firewall-stat-cards#2 | 5 + .../protect/src/class-jetpack-protect.php | 3 +- .../firewall-header/firewall-statcards.jsx | 86 ++++ .../firewall-header/firewall-subheading.jsx | 110 +++++ .../js/components/firewall-header/index.jsx | 141 +----- .../firewall-header/stories/index.stories.jsx | 93 ++++ .../firewall-header/styles.module.scss | 53 ++- 20 files changed, 956 insertions(+), 317 deletions(-) create mode 100644 projects/js-packages/components/changelog/add-protect-firewall-stat-cards create mode 100644 projects/packages/waf/changelog/add-protect-firewall-stat-cards create mode 100644 projects/packages/waf/src/class-waf-blocklog-manager.php create mode 100644 projects/packages/waf/tests/php/unit/test-waf-blocklog-manager.php create mode 100644 projects/plugins/jetpack/changelog/add-protect-firewall-stat-cards create mode 100644 projects/plugins/protect/changelog/add-protect-firewall-stat-cards create mode 100644 projects/plugins/protect/changelog/add-protect-firewall-stat-cards#2 create mode 100644 projects/plugins/protect/src/js/components/firewall-header/firewall-statcards.jsx create mode 100644 projects/plugins/protect/src/js/components/firewall-header/firewall-subheading.jsx create mode 100644 projects/plugins/protect/src/js/components/firewall-header/stories/index.stories.jsx diff --git a/projects/js-packages/components/changelog/add-protect-firewall-stat-cards b/projects/js-packages/components/changelog/add-protect-firewall-stat-cards new file mode 100644 index 0000000000000..510067f71ce01 --- /dev/null +++ b/projects/js-packages/components/changelog/add-protect-firewall-stat-cards @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added StatCard component export diff --git a/projects/js-packages/components/index.ts b/projects/js-packages/components/index.ts index 0b00005d00d6f..b4740fcff7404 100644 --- a/projects/js-packages/components/index.ts +++ b/projects/js-packages/components/index.ts @@ -77,4 +77,5 @@ export { default as ProgressBar } from './components/progress-bar'; export { default as UpsellBanner } from './components/upsell-banner'; export { getUserLocale, cleanLocale } from './lib/locale'; export { default as RadioControl } from './components/radio-control'; +export { default as StatCard } from './components/stat-card'; export * from './components/global-notices'; diff --git a/projects/packages/waf/.phan/baseline.php b/projects/packages/waf/.phan/baseline.php index 20223292488da..f80ad2b6433a7 100644 --- a/projects/packages/waf/.phan/baseline.php +++ b/projects/packages/waf/.phan/baseline.php @@ -45,13 +45,12 @@ 'src/class-waf-constants.php' => ['PhanCoalescingNeverNull', 'PhanUndeclaredConstant'], 'src/class-waf-operators.php' => ['PhanTypeMismatchReturn'], 'src/class-waf-rules-manager.php' => ['PhanTypeMismatchArgument'], - 'src/class-waf-runtime.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeArraySuspiciousNullable', 'PhanUndeclaredConstant'], + 'src/class-waf-runtime.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeArraySuspiciousNullable'], 'src/class-waf-transforms.php' => ['PhanPluginInvalidPregRegex', 'PhanTypeInvalidDimOffset'], 'tests/php/integration/test-waf-compatibility.php' => ['PhanParamTooMany'], 'tests/php/unit/functions-wordpress.php' => ['PhanRedefineFunction'], 'tests/php/unit/test-waf-operators.php' => ['PhanTypeMismatchArgumentInternal'], 'tests/php/unit/test-waf-runtime-targets.php' => ['PhanPluginRedundantAssignment'], - 'tests/php/unit/test-waf-runtime.php' => ['PhanImpossibleTypeComparison', 'PhanTypeMismatchArgument'], 'tests/php/unit/test-waf-standalone-bootstrap.php' => ['PhanDeprecatedFunction', 'PhanNoopNew'], ], // 'directory_suppressions' => ['src/directory_name' => ['PhanIssueName1', 'PhanIssueName2']] can be manually added if needed. diff --git a/projects/packages/waf/changelog/add-protect-firewall-stat-cards b/projects/packages/waf/changelog/add-protect-firewall-stat-cards new file mode 100644 index 0000000000000..6841106ca5ac2 --- /dev/null +++ b/projects/packages/waf/changelog/add-protect-firewall-stat-cards @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added Waf_Blocklog_Manager class diff --git a/projects/packages/waf/src/class-waf-blocklog-manager.php b/projects/packages/waf/src/class-waf-blocklog-manager.php new file mode 100644 index 0000000000000..031c71ff71c3b --- /dev/null +++ b/projects/packages/waf/src/class-waf-blocklog-manager.php @@ -0,0 +1,441 @@ +connect_error ) { + error_log( 'Could not connect to the database:' . $conn->connect_error ); + return null; + } + + self::$db_connection = $conn; + return self::$db_connection; + } + + /** + * Close the database connection. + * + * @return void + */ + private static function close_db_connection() { + if ( self::$db_connection ) { + self::$db_connection->close(); + self::$db_connection = null; + } + } + + /** + * Serialize a value for storage in a WordPress option. + * + * @param mixed $value The value to serialize. + * @return string The serialized value. + */ + private static function serialize_option_value( $value ) { + return serialize( $value ); + } + + /** + * Unserialize a value from a WordPress option. + * + * @param string $value The serialized value. + * @return mixed The unserialized value. + */ + private static function unserialize_option_value( string $value ) { + return unserialize( $value ); + } + + /** + * Create the log table when plugin is activated. + * + * @return void + */ + public static function create_blocklog_table() { + global $wpdb; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $sql = " + CREATE TABLE {$wpdb->prefix}jetpack_waf_blocklog ( + log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + timestamp datetime NOT NULL, + rule_id BIGINT NOT NULL, + reason longtext NOT NULL, + PRIMARY KEY (log_id), + KEY timestamp (timestamp) + ) + "; + + dbDelta( $sql ); + } + + /** + * Write block logs to database. + * + * @param array $log_data Log data. + * + * @return void + */ + private static function write_blocklog_row( $log_data ) { + $conn = self::connect_to_wordpress_db(); + + if ( ! $conn ) { + return; + } + + global $table_prefix; + + $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" ); + if ( false !== $statement ) { + $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] ); + $statement->execute(); + + if ( $conn->insert_id > 100 ) { + $conn->query( "DELETE FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id LIMIT 1" ); + } + } + } + + /** + * Get the daily summary stats from the database. + * + * @return array The daily summary stats. + */ + private static function get_daily_summary() { + global $table_prefix; + $db_connection = self::connect_to_wordpress_db(); + if ( ! $db_connection ) { + return array(); + } + + $result = $db_connection->query( "SELECT option_value FROM {$table_prefix}options WHERE option_name = '" . self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY . "'" ); + if ( ! $result ) { + return array(); + } + + $row = $result->fetch_assoc(); + if ( ! $row ) { + return array(); + } + + $daily_summary = self::unserialize_option_value( $row['option_value'] ); + $result->free(); + + return is_array( $daily_summary ) ? $daily_summary : array(); + } + + /** + * Increments the current date's daily summary stat. + * + * @param array $current_value The current value of the daily summary. + * + * @return array The updated daily summary. + */ + public static function increment_daily_summary( array $current_value ) { + $date = gmdate( 'Y-m-d' ); + $value = intval( $current_value[ $date ] ?? 0 ); + $current_value[ $date ] = $value + 1; + + return $current_value; + } + + /** + * Update the daily summary option in the database. + * + * @param array $value The value to update. + * + * @return void + */ + private static function write_daily_summary_row( array $value ) { + global $table_prefix; + $option_name = self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY; + + $db_connection = self::connect_to_wordpress_db(); + if ( ! $db_connection ) { + return; + } + + $updated_value = self::serialize_option_value( $value ); + + $statement = $db_connection->prepare( "INSERT INTO {$table_prefix}options (option_name, option_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE option_value = ?" ); + if ( false !== $statement ) { + $statement->bind_param( 'sss', $option_name, $updated_value, $updated_value ); + $statement->execute(); + } + } + + /** + * Update the daily summary stats for the current date. + * + * @return void + */ + private static function write_daily_summary() { + $stats = self::get_daily_summary(); + $stats = self::increment_daily_summary( $stats ); + $stats = self::filter_last_30_days( $stats ); + + self::write_daily_summary_row( $stats ); + } + + /** + * Get the all-time block count value from the database. + * + * @return int The all-time block count. + */ + private static function get_all_time_block_count_value() { + global $table_prefix; + $db_connection = self::connect_to_wordpress_db(); + if ( ! $db_connection ) { + return 0; + } + + $result = $db_connection->query( "SELECT option_value FROM {$table_prefix}options WHERE option_name = '" . self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT . "'" ); + if ( ! $result ) { + return 0; + } + + $row = $result->fetch_assoc(); + if ( ! $row ) { + return 0; + } + + $all_time_block_count = intval( $row['option_value'] ); + $result->free(); + + return $all_time_block_count; + } + + /** + * Update the all-time block count value in the database. + * + * @param int $value The value to update. + * @return void + */ + private static function write_all_time_block_count_row( int $value ) { + global $table_prefix; + $option_name = self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT; + + $db_connection = self::connect_to_wordpress_db(); + if ( ! $db_connection ) { + return; + } + + $statement = $db_connection->prepare( "INSERT INTO {$table_prefix}options (option_name, option_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE option_value = ?" ); + if ( false !== $statement ) { + $statement->bind_param( 'sii', $option_name, $value, $value ); + $statement->execute(); + } + } + + /** + * Increment the all-time stats. + * + * @return void + */ + private static function write_all_time_block_count() { + $block_count = self::get_all_time_block_count_value(); + if ( ! $block_count ) { + $block_count = self::get_default_all_time_stat_value(); + } + + self::write_all_time_block_count_row( $block_count + 1 ); + } + + /** + * Filters the stats to retain only data for the last 30 days. + * + * @param array $stats The array of stats to prune. + * + * @return array Pruned stats array. + */ + public static function filter_last_30_days( array $stats ) { + $today = gmdate( 'Y-m-d' ); + $one_month_ago = gmdate( 'Y-m-d', strtotime( '-30 days' ) ); + + return array_filter( + $stats, + function ( $date ) use ( $one_month_ago, $today ) { + return $date >= $one_month_ago && $date <= $today; + }, + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Get the total number of blocked requests for today. + * + * @return int + */ + public static function get_current_day_block_count() { + $stats = get_option( self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY, array() ); + $today = gmdate( 'Y-m-d' ); + + return $stats[ $today ] ?? 0; + } + + /** + * Get the total number of blocked requests for last thirty days. + * + * @return int + */ + public static function get_thirty_days_block_counts() { + $stats = get_option( self::BLOCKLOG_OPTION_NAME_DAILY_SUMMARY, array() ); + $total_blocks = 0; + + foreach ( $stats as $count ) { + $total_blocks += intval( $count ); + } + + return $total_blocks; + } + + /** + * Get the total number of blocked requests for all time. + * + * @return int + */ + public static function get_all_time_block_count() { + $all_time_block_count = get_option( self::BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT, false ); + + if ( false !== $all_time_block_count ) { + return intval( $all_time_block_count ); + } + + return self::get_default_all_time_stat_value(); + } + + /** + * Compute the initial all-time stats value. + * + * @return int The initial all-time stats value. + */ + private static function get_default_all_time_stat_value() { + $conn = self::connect_to_wordpress_db(); + if ( ! $conn ) { + return 0; + } + + global $table_prefix; + + $last_log_id_result = $conn->query( "SELECT log_id FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT 1" ); + + $all_time_block_count = 0; + + if ( $last_log_id_result && $last_log_id_result->num_rows > 0 ) { + $row = $last_log_id_result->fetch_assoc(); + if ( $row !== null && isset( $row['log_id'] ) ) { + $all_time_block_count = $row['log_id']; + } + } + + return intval( $all_time_block_count ); + } + + /** + * Get the headers for logging purposes. + * + * @return array The headers. + */ + public static function get_request_headers() { + $all_headers = getallheaders(); + $exclude_headers = array( 'Authorization', 'Cookie', 'Proxy-Authorization', 'Set-Cookie' ); + + foreach ( $exclude_headers as $header ) { + unset( $all_headers[ $header ] ); + } + + return $all_headers; + } + + /** + * Write block logs. We won't write to the file if it exceeds 100 mb. + * + * @param string $rule_id The rule ID that triggered the block. + * @param string $reason The reason for the block. + * + * @return void + */ + public static function write_blocklog( $rule_id, $reason ) { + $log_data = array(); + $log_data['rule_id'] = $rule_id; + $log_data['reason'] = $reason; + $log_data['timestamp'] = gmdate( 'Y-m-d H:i:s' ); + $log_data['request_uri'] = isset( $_SERVER['REQUEST_URI'] ) ? \stripslashes( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $log_data['user_agent'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \stripslashes( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $log_data['referer'] = isset( $_SERVER['HTTP_REFERER'] ) ? \stripslashes( $_SERVER['HTTP_REFERER'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $log_data['content_type'] = isset( $_SERVER['CONTENT_TYPE'] ) ? \stripslashes( $_SERVER['CONTENT_TYPE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $log_data['get_params'] = json_encode( $_GET ); + + if ( defined( 'JETPACK_WAF_SHARE_DEBUG_DATA' ) && JETPACK_WAF_SHARE_DEBUG_DATA ) { + $log_data['post_params'] = json_encode( $_POST ); + $log_data['headers'] = self::get_request_headers(); + } + + if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) { + $file_path = JETPACK_WAF_DIR . '/waf-blocklog'; + $file_exists = file_exists( $file_path ); + + if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) { + $fp = fopen( $file_path, 'a+' ); + + if ( $fp ) { + try { + fwrite( $fp, json_encode( $log_data ) . "\n" ); + } finally { + fclose( $fp ); + } + } + } + } + + self::write_daily_summary(); + self::write_all_time_block_count(); + self::write_blocklog_row( $log_data ); + self::close_db_connection(); + } +} diff --git a/projects/packages/waf/src/class-waf-runner.php b/projects/packages/waf/src/class-waf-runner.php index cfed64e7ec3f4..e15ce7a8290aa 100644 --- a/projects/packages/waf/src/class-waf-runner.php +++ b/projects/packages/waf/src/class-waf-runner.php @@ -326,7 +326,7 @@ public static function activate() { Waf_Rules_Manager::generate_ip_rules(); Waf_Rules_Manager::generate_rules(); - self::create_blocklog_table(); + Waf_Blocklog_Manager::create_blocklog_table(); } /** @@ -353,30 +353,6 @@ public static function initialize_waf_directory() { } } - /** - * Create the log table when plugin is activated. - * - * @return void - */ - public static function create_blocklog_table() { - global $wpdb; - - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - - $sql = " - CREATE TABLE {$wpdb->prefix}jetpack_waf_blocklog ( - log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - timestamp datetime NOT NULL, - rule_id BIGINT NOT NULL, - reason longtext NOT NULL, - PRIMARY KEY (log_id), - KEY timestamp (timestamp) - ) - "; - - dbDelta( $sql ); - } - /** * Deactivates the WAF by deleting the relevant options and emptying rules file. * diff --git a/projects/packages/waf/src/class-waf-runtime.php b/projects/packages/waf/src/class-waf-runtime.php index 30fd1a8d500bc..394d46aa5a6f1 100644 --- a/projects/packages/waf/src/class-waf-runtime.php +++ b/projects/packages/waf/src/class-waf-runtime.php @@ -271,7 +271,7 @@ public function block( $action, $rule_id, $reason, $status_code = 403 ) { $reason = $this->sanitize_output( $reason ); } - $this->write_blocklog( $rule_id, $reason ); + Waf_Blocklog_Manager::write_blocklog( $rule_id, $reason ); error_log( "Jetpack WAF Blocked Request\t$action\t$rule_id\t$status_code\t$reason" ); header( "X-JetpackWAF-Blocked: $status_code - rule $rule_id" ); if ( defined( 'JETPACK_WAF_MODE' ) && 'normal' === JETPACK_WAF_MODE ) { @@ -281,106 +281,6 @@ public function block( $action, $rule_id, $reason, $status_code = 403 ) { } } - /** - * Get the headers for logging purposes. - */ - public function get_request_headers() { - $all_headers = getallheaders(); - $exclude_headers = array( 'Authorization', 'Cookie', 'Proxy-Authorization', 'Set-Cookie' ); - - foreach ( $exclude_headers as $header ) { - unset( $all_headers[ $header ] ); - } - - return $all_headers; - } - - /** - * Write block logs. We won't write to the file if it exceeds 100 mb. - * - * @param string $rule_id Rule id. - * @param string $reason Block reason. - */ - public function write_blocklog( $rule_id, $reason ) { - $log_data = array(); - $log_data['rule_id'] = $rule_id; - $log_data['reason'] = $reason; - $log_data['timestamp'] = gmdate( 'Y-m-d H:i:s' ); - $log_data['request_uri'] = isset( $_SERVER['REQUEST_URI'] ) ? \stripslashes( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $log_data['user_agent'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \stripslashes( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $log_data['referer'] = isset( $_SERVER['HTTP_REFERER'] ) ? \stripslashes( $_SERVER['HTTP_REFERER'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $log_data['content_type'] = isset( $_SERVER['CONTENT_TYPE'] ) ? \stripslashes( $_SERVER['CONTENT_TYPE'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $log_data['get_params'] = json_encode( $_GET ); - - if ( defined( 'JETPACK_WAF_SHARE_DEBUG_DATA' ) && JETPACK_WAF_SHARE_DEBUG_DATA ) { - $log_data['post_params'] = json_encode( $_POST ); - $log_data['headers'] = $this->get_request_headers(); - } - - if ( defined( 'JETPACK_WAF_SHARE_DATA' ) && JETPACK_WAF_SHARE_DATA ) { - $file_path = JETPACK_WAF_DIR . '/waf-blocklog'; - $file_exists = file_exists( $file_path ); - - if ( ! $file_exists || filesize( $file_path ) < ( 100 * 1024 * 1024 ) ) { - $fp = fopen( $file_path, 'a+' ); - - if ( $fp ) { - try { - fwrite( $fp, json_encode( $log_data ) . "\n" ); - } finally { - fclose( $fp ); - } - } - } - } - - $this->write_blocklog_row( $log_data ); - } - - /** - * Write block logs to database. - * - * @param array $log_data Log data. - */ - private function write_blocklog_row( $log_data ) { - $conn = $this->connect_to_wordpress_db(); - - if ( ! $conn ) { - return; - } - - global $table_prefix; - - $statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" ); - if ( false !== $statement ) { - $statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] ); - $statement->execute(); - - if ( $conn->insert_id > 100 ) { - $conn->query( "DELETE FROM {$table_prefix}jetpack_waf_blocklog ORDER BY log_id LIMIT 1" ); - } - } - } - - /** - * Connect to WordPress database. - */ - private function connect_to_wordpress_db() { - if ( ! file_exists( JETPACK_WAF_WPCONFIG ) ) { - return; - } - - require_once JETPACK_WAF_WPCONFIG; - $conn = new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli - - if ( $conn->connect_error ) { - error_log( 'Could not connect to the database:' . $conn->connect_error ); - return null; - } - - return $conn; - } - /** * Redirect. * diff --git a/projects/packages/waf/src/class-waf-stats.php b/projects/packages/waf/src/class-waf-stats.php index f3b4122a2402d..5f5cb4e081424 100644 --- a/projects/packages/waf/src/class-waf-stats.php +++ b/projects/packages/waf/src/class-waf-stats.php @@ -7,53 +7,29 @@ namespace Automattic\Jetpack\Waf; -use Automattic\Jetpack\IP\Utils as IP_Utils; - /** * Retrieves WAF stats. */ class Waf_Stats { /** - * The global stats cache - * - * @var array|null - */ - public static $global_stats = null; - - /** - * Get IP allow list count + * Retrieve blocked requests from database * - * @return int The number of valid IP addresses in the allow list + * @return array */ - public static function get_ip_allow_list_count() { - $ip_allow_list = get_option( Waf_Rules_Manager::IP_ALLOW_LIST_OPTION_NAME ); - - if ( ! $ip_allow_list ) { - return 0; - } - - $results = IP_Utils::get_ip_addresses_from_string( $ip_allow_list ); - - return count( $results ); + public static function get_blocked_requests() { + return array( + 'current_day' => Waf_Blocklog_Manager::get_current_day_block_count(), + 'thirty_days' => Waf_Blocklog_Manager::get_thirty_days_block_counts(), + 'all_time' => Waf_Blocklog_Manager::get_all_time_block_count(), + ); } - /** - * Get IP block list count + /** The global stats cache * - * @return int The number of valid IP addresses in the block list + * @var array|null */ - public static function get_ip_block_list_count() { - $ip_block_list = get_option( Waf_Rules_Manager::IP_BLOCK_LIST_OPTION_NAME ); - - if ( ! $ip_block_list ) { - return 0; - } - - $results = IP_Utils::get_ip_addresses_from_string( $ip_block_list ); - - return count( $results ); - } + public static $global_stats = null; /** * Get Rules version diff --git a/projects/packages/waf/src/functions.php b/projects/packages/waf/src/functions.php index 7d60cd28f88fe..e7391c62cc575 100644 --- a/projects/packages/waf/src/functions.php +++ b/projects/packages/waf/src/functions.php @@ -70,3 +70,51 @@ function flatten_array( $array, $key_prefix = '', $dot_notation = null ) { } return $return; } + +/** + * Polyfill for getallheaders, which is not available in all PHP environments. + * + * @link https://github.com/ralouphie/getallheaders + */ +if ( ! function_exists( 'getallheaders' ) ) { + /** + * Get all HTTP header key/values as an associative array for the current request. + * + * @return array The HTTP header key/value pairs. + */ + function getallheaders() { + // phpcs:disable WordPress.Security.ValidatedSanitizedInput + $headers = array(); + + $copy_server = array( + 'CONTENT_TYPE' => 'Content-Type', + 'CONTENT_LENGTH' => 'Content-Length', + 'CONTENT_MD5' => 'Content-Md5', + ); + + foreach ( $_SERVER as $key => $value ) { + if ( substr( $key, 0, 5 ) === 'HTTP_' ) { + $key = substr( $key, 5 ); + if ( ! isset( $copy_server[ $key ] ) || ! isset( $_SERVER[ $key ] ) ) { + $key = str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', $key ) ) ) ); + $headers[ $key ] = $value; + } + } elseif ( isset( $copy_server[ $key ] ) ) { + $headers[ $copy_server[ $key ] ] = $value; + } + } + + if ( ! isset( $headers['Authorization'] ) ) { + if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { + $headers['Authorization'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } elseif ( isset( $_SERVER['PHP_AUTH_USER'] ) ) { + $basic_pass = $_SERVER['PHP_AUTH_PW'] ?? ''; + $headers['Authorization'] = 'Basic ' . base64_encode( $_SERVER['PHP_AUTH_USER'] . ':' . $basic_pass ); + } elseif ( isset( $_SERVER['PHP_AUTH_DIGEST'] ) ) { + $headers['Authorization'] = $_SERVER['PHP_AUTH_DIGEST']; + } + } + + return $headers; + } +} diff --git a/projects/packages/waf/tests/php/unit/test-waf-blocklog-manager.php b/projects/packages/waf/tests/php/unit/test-waf-blocklog-manager.php new file mode 100644 index 0000000000000..e1835501adee3 --- /dev/null +++ b/projects/packages/waf/tests/php/unit/test-waf-blocklog-manager.php @@ -0,0 +1,76 @@ +assertTrue( file_exists( $waf_log_path ) ); + $this->assertFalse( strpos( $file_content, '{"rule_id":"1337","reason":"test block"' ) === false ); + + unlink( $waf_log_path ); + } + + /** + * Test incrementing the daily summary stats. + */ + public function testIncrementDailySummary() { + $today = gmdate( 'Y-m-d' ); + + $value = array(); + $result = Waf_Blocklog_Manager::increment_daily_summary( $value ); + $this->assertSame( 1, $result[ $today ] ); + + $value = array( + '1999-01-01' => 0, + '1999-01-02' => 123, + $today => 1, + ); + $result = Waf_Blocklog_Manager::increment_daily_summary( $value ); + $this->assertEquals( 2, $result[ $today ] ); + } + + /** + * Test filtering of the daily summary stats. + */ + public function testFilterLast30Days() { + // Generate stats data with dates from 35 days ago to 5 days in the future + $stats = array(); + for ( $i = -35; $i <= 5; $i++ ) { + $date = gmdate( 'Y-m-d', strtotime( "$i days" ) ); + $stats[ $date ] = "data for $date"; + } + + // Generate expected data with dates from 30 days ago to today + $expected_stats = array(); + for ( $i = -30; $i <= 0; $i++ ) { + $date = gmdate( 'Y-m-d', strtotime( "$i days" ) ); + $expected_stats[ $date ] = "data for $date"; + } + + $filtered_stats = Waf_Blocklog_Manager::filter_last_30_days( $stats ); + $this->assertEquals( $expected_stats, $filtered_stats ); + } +} diff --git a/projects/packages/waf/tests/php/unit/test-waf-runtime.php b/projects/packages/waf/tests/php/unit/test-waf-runtime.php index cc88628f7b443..e7823fe8fe19f 100644 --- a/projects/packages/waf/tests/php/unit/test-waf-runtime.php +++ b/projects/packages/waf/tests/php/unit/test-waf-runtime.php @@ -279,28 +279,6 @@ public function testVars() { $this->assertSame( '', $this->runtime->get_var( 'abc' ) ); } - /** - * Test calling the log function and check if a file is written. - * - * @runInSeparateProcess - */ - public function testWriteBlocklog() { - $tmp_dir = sys_get_temp_dir(); - $waf_log_path = $tmp_dir . '/waf-blocklog'; - - define( 'JETPACK_WAF_DIR', $tmp_dir ); - define( 'JETPACK_WAF_WPCONFIG', $tmp_dir . '/wp-config.php' ); - define( 'JETPACK_WAF_SHARE_DATA', true ); - - $this->runtime->write_blocklog( 1337, 'test block' ); - $file_content = file_get_contents( $waf_log_path ); - - $this->assertTrue( file_exists( $waf_log_path ) ); - $this->assertTrue( strpos( $file_content, '{"rule_id":1337,"reason":"test block"' ) !== true ); - - unlink( $waf_log_path ); - } - /** * Test the sanitize output method catches odd cases */ diff --git a/projects/plugins/jetpack/changelog/add-protect-firewall-stat-cards b/projects/plugins/jetpack/changelog/add-protect-firewall-stat-cards new file mode 100644 index 0000000000000..a1c1831fa1ef7 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-protect-firewall-stat-cards @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Updated composer.lock. + + diff --git a/projects/plugins/protect/changelog/add-protect-firewall-stat-cards b/projects/plugins/protect/changelog/add-protect-firewall-stat-cards new file mode 100644 index 0000000000000..244645fa6ebd6 --- /dev/null +++ b/projects/plugins/protect/changelog/add-protect-firewall-stat-cards @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added WAF StatCards for blocked requests diff --git a/projects/plugins/protect/changelog/add-protect-firewall-stat-cards#2 b/projects/plugins/protect/changelog/add-protect-firewall-stat-cards#2 new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/protect/changelog/add-protect-firewall-stat-cards#2 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index fad23ee804cb4..293ccdaeb3ce7 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -457,8 +457,7 @@ public static function get_waf_stats() { } return array( - 'ipAllowListCount' => Waf_Stats::get_ip_allow_list_count(), - 'ipBlockListCount' => Waf_Stats::get_ip_block_list_count(), + 'blockedRequests' => Plan::has_required_plan() ? Waf_Stats::get_blocked_requests() : false, 'automaticRulesLastUpdated' => Waf_Stats::get_automatic_rules_last_updated(), ); } diff --git a/projects/plugins/protect/src/js/components/firewall-header/firewall-statcards.jsx b/projects/plugins/protect/src/js/components/firewall-header/firewall-statcards.jsx new file mode 100644 index 0000000000000..b5ff8c38851d9 --- /dev/null +++ b/projects/plugins/protect/src/js/components/firewall-header/firewall-statcards.jsx @@ -0,0 +1,86 @@ +import { Text, useBreakpointMatch, StatCard } from '@automattic/jetpack-components'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, shield, chartBar } from '@wordpress/icons'; +import { useCallback, useMemo } from 'react'; +import styles from './styles.module.scss'; + +const FirewallStatCards = ( { status, hasPlan, currentDayStats, thirtyDaysStats } ) => { + const [ isSmall ] = useBreakpointMatch( [ 'sm', 'lg' ], [ null, '<' ] ); + + const defaultArgs = useMemo( + () => ( { + className: status !== 'on' || ! hasPlan ? styles.disabled : styles.active, + variant: isSmall ? 'horizontal' : 'square', + } ), + [ status, isSmall, hasPlan ] + ); + + const getIcon = useCallback( + icon => ( + + + { ! isSmall && ! hasPlan && ( + { __( 'Paid feature', 'jetpack-protect' ) } + ) } + + ), + [ isSmall, hasPlan ] + ); + + const getLabel = useCallback( + ( period, units ) => + isSmall ? ( + + { sprintf( + /* translators: Translates to Blocked requests last %1$d: Number of units. %2$s: Unit of time (hours, days, etc) */ + __( 'Blocked requests last %1$d %2$s', 'jetpack-protect' ), + period, + units + ) } + + ) : ( + + { __( 'Blocked requests', 'jetpack-protect' ) } +
+ + { sprintf( + /* translators: Translates to Last %1$d: Number of units. %2$s: Unit of time (hours, days, etc) */ + __( 'Last %1$d %2$s', 'jetpack-protect' ), + period, + units + ) } + +
+ ), + [ isSmall ] + ); + + const currentDayArgs = useMemo( + () => ( { + ...defaultArgs, + icon: getIcon( shield ), + label: getLabel( 24, 'hours' ), + value: status !== 'on' || ! hasPlan ? 0 : currentDayStats, + } ), + [ defaultArgs, getIcon, getLabel, status, hasPlan, currentDayStats ] + ); + + const thirtyDaysArgs = useMemo( + () => ( { + ...defaultArgs, + icon: getIcon( chartBar ), + label: getLabel( 30, 'days' ), + value: status !== 'on' || ! hasPlan ? 0 : thirtyDaysStats, + } ), + [ defaultArgs, getIcon, getLabel, status, hasPlan, thirtyDaysStats ] + ); + + return ( +
+ + +
+ ); +}; + +export default FirewallStatCards; diff --git a/projects/plugins/protect/src/js/components/firewall-header/firewall-subheading.jsx b/projects/plugins/protect/src/js/components/firewall-header/firewall-subheading.jsx new file mode 100644 index 0000000000000..906e71099a519 --- /dev/null +++ b/projects/plugins/protect/src/js/components/firewall-header/firewall-subheading.jsx @@ -0,0 +1,110 @@ +import { Text, Button } from '@automattic/jetpack-components'; +import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; +import { __ } from '@wordpress/i18n'; +import { help } from '@wordpress/icons'; +import { JETPACK_SCAN_SLUG } from '../../constants'; +import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; +import IconTooltip from '../icon-tooltip'; +import styles from './styles.module.scss'; + +const UpgradePrompt = ( { automaticRulesAvailable } ) => { + const { adminUrl } = window.jetpackProtectInitialState || {}; + const firewallUrl = adminUrl + '#/firewall'; + + const { run } = useProductCheckoutWorkflow( { + productSlug: JETPACK_SCAN_SLUG, + redirectUrl: firewallUrl, + useBlogIdSuffix: true, + } ); + + const { recordEventHandler } = useAnalyticsTracks(); + const getScan = recordEventHandler( 'jetpack_protect_waf_header_get_scan_link_click', run ); + + return ( + + ); +}; + +const FirewallSubheadingContent = ( { className, text = '', popover = false } ) => { + return ( +
+ + { text } + + { popover && ( + + ) } +
+ ); +}; + +const FirewallSubheading = ( { + hasPlan, + automaticRulesAvailable, + jetpackWafIpList, + jetpackWafAutomaticRules, + bruteForceProtectionIsEnabled, + wafSupported, +} ) => { + const allRules = wafSupported && jetpackWafAutomaticRules && jetpackWafIpList; + const automaticRules = wafSupported && jetpackWafAutomaticRules && ! jetpackWafIpList; + const manualRules = wafSupported && ! jetpackWafAutomaticRules && jetpackWafIpList; + const noRules = wafSupported && ! jetpackWafAutomaticRules && ! jetpackWafIpList; + + return ( + <> +
+ { wafSupported && bruteForceProtectionIsEnabled && ( + + ) } + { noRules && ( + + ) } + { automaticRules && ( + + ) } + { manualRules && ( + + ) } + { allRules && ( + + ) } +
+ { ! hasPlan && } + + ); +}; + +export default FirewallSubheading; diff --git a/projects/plugins/protect/src/js/components/firewall-header/index.jsx b/projects/plugins/protect/src/js/components/firewall-header/index.jsx index 0cd9e69b7ba35..b543072733dfb 100644 --- a/projects/plugins/protect/src/js/components/firewall-header/index.jsx +++ b/projects/plugins/protect/src/js/components/firewall-header/index.jsx @@ -1,136 +1,23 @@ -import { - AdminSectionHero, - Container, - Col, - Text, - H3, - Button, - Status, -} from '@automattic/jetpack-components'; +import { AdminSectionHero, Container, Col, Text, H3, Status } from '@automattic/jetpack-components'; import { Spinner } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { help } from '@wordpress/icons'; -import React, { useCallback } from 'react'; -import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; import usePlan from '../../hooks/use-plan'; import useWafData from '../../hooks/use-waf-data'; -import IconTooltip from '../icon-tooltip'; +import FirewallStatCards from './firewall-statcards'; +import FirewallSubheading from './firewall-subheading'; import styles from './styles.module.scss'; -const UpgradePrompt = () => { - const { recordEvent } = useAnalyticsTracks(); - const { adminUrl } = window.jetpackProtectInitialState || {}; - const firewallUrl = adminUrl + '#/firewall'; - const { upgradePlan } = usePlan( { redirectUrl: firewallUrl } ); - - const { - config: { automaticRulesAvailable }, - } = useWafData(); - - const getScan = useCallback( () => { - recordEvent( 'jetpack_protect_waf_header_get_scan_link_click' ); - upgradePlan(); - }, [ recordEvent, upgradePlan ] ); - - return ( - - ); -}; - -const FirewallSubheadingContent = ( { className, text = '', popover = false } ) => { - return ( -
- - { text } - - { popover && ( - - ) } -
- ); -}; - -const FirewallSubheading = ( { - jetpackWafIpBlockListEnabled, - jetpackWafIpAllowListEnabled, - hasPlan, - automaticRulesAvailable, - jetpackWafAutomaticRules, - bruteForceProtectionIsEnabled, - wafSupported, -} ) => { - const allowOrBlockListEnabled = jetpackWafIpBlockListEnabled || jetpackWafIpAllowListEnabled; - const allRules = wafSupported && jetpackWafAutomaticRules && allowOrBlockListEnabled; - const automaticRules = wafSupported && jetpackWafAutomaticRules && ! allowOrBlockListEnabled; - const manualRules = wafSupported && ! jetpackWafAutomaticRules && allowOrBlockListEnabled; - const noRules = wafSupported && ! jetpackWafAutomaticRules && ! allowOrBlockListEnabled; - - return ( - <> -
- { wafSupported && bruteForceProtectionIsEnabled && ( - - ) } - { noRules && ( - - ) } - { automaticRules && ( - - ) } - { manualRules && ( - - ) } - { allRules && ( - - ) } -
- { ! hasPlan && wafSupported && } - - ); -}; - const FirewallHeader = ( { status, hasPlan, - automaticRulesEnabled, automaticRulesAvailable, jetpackWafIpBlockListEnabled, jetpackWafIpAllowListEnabled, jetpackWafAutomaticRules, bruteForceProtectionIsEnabled, wafSupported, + currentDayStats, + thirtyDaysStats, standaloneMode, } ) => { return ( @@ -154,7 +41,7 @@ const FirewallHeader = ( {

{ ! wafSupported && __( 'Brute force protection is active', 'jetpack-protect' ) } { wafSupported && - ( automaticRulesEnabled + ( jetpackWafAutomaticRules ? __( 'Automatic firewall is on', 'jetpack-protect' ) : __( 'Firewall is on', @@ -211,7 +98,14 @@ const FirewallHeader = ( { ) } -
+ { wafSupported && ( + + ) } @@ -230,23 +124,28 @@ const ConnectedFirewallHeader = () => { }, isToggling, wafSupported, + stats, isEnabled, } = useWafData(); const { hasPlan } = usePlan(); const isSupportedWafFeatureEnabled = wafSupported ? isEnabled : bruteForceProtection; const currentStatus = isSupportedWafFeatureEnabled ? 'on' : 'off'; + const { currentDay: currentDayBlockCount, thirtyDays: thirtyDayBlockCounts } = stats + ? stats.blockedRequests + : { currentDayStats: 0, thirtyDaysStats: 0 }; return ( ); diff --git a/projects/plugins/protect/src/js/components/firewall-header/stories/index.stories.jsx b/projects/plugins/protect/src/js/components/firewall-header/stories/index.stories.jsx new file mode 100644 index 0000000000000..9ec84e82bc51b --- /dev/null +++ b/projects/plugins/protect/src/js/components/firewall-header/stories/index.stories.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { FirewallHeader } from '../index.jsx'; + +export default { + title: 'Plugins/Protect/Firewall Header', + component: FirewallHeader, +}; + +const Template = args => ; + +export const FirewallOnFree = Template.bind( {} ); +FirewallOnFree.args = { + status: 'on', + hasPlan: false, + automaticRulesAvailable: false, + jetpackWafIpList: true, + jetpackWafAutomaticRules: false, + bruteForceProtectionIsEnabled: true, + wafSupported: true, + currentDayStats: 0, + thirtyDaysStats: 0, + standaloneMode: false, +}; + +export const FirewallOffFree = Template.bind( {} ); +FirewallOffFree.args = { + status: 'off', + hasPlan: false, + automaticRulesAvailable: false, + jetpackWafIpList: false, + jetpackWafAutomaticRules: false, + bruteForceProtectionIsEnabled: false, + wafSupported: true, + currentDayStats: 0, + thirtyDaysStats: 0, + standaloneMode: false, +}; + +export const FirewallOnPaid = Template.bind( {} ); +FirewallOnPaid.args = { + status: 'on', + hasPlan: true, + automaticRulesAvailable: true, + jetpackWafIpList: true, + jetpackWafAutomaticRules: true, + bruteForceProtectionIsEnabled: true, + wafSupported: true, + currentDayStats: 100, + thirtyDaysStats: 30000, + standaloneMode: false, +}; + +export const FirewallOffPaid = Template.bind( {} ); +FirewallOffPaid.args = { + status: 'off', + hasPlan: true, + automaticRulesAvailable: true, + jetpackWafIpList: false, + jetpackWafAutomaticRules: false, + bruteForceProtectionIsEnabled: false, + wafSupported: true, + currentDayStats: 0, + thirtyDaysStats: 0, + standaloneMode: false, +}; + +export const FirewallOnStandalone = Template.bind( {} ); +FirewallOnStandalone.args = { + status: 'on', + hasPlan: true, + automaticRulesAvailable: true, + jetpackWafIpList: true, + jetpackWafAutomaticRules: true, + bruteForceProtectionIsEnabled: true, + wafSupported: true, + currentDayStats: 100, + thirtyDaysStats: 30000, + standaloneMode: true, +}; + +export const FirewallLoading = Template.bind( {} ); +FirewallLoading.args = { + status: 'loading', + hasPlan: true, + automaticRulesAvailable: true, + jetpackWafIpList: false, + jetpackWafAutomaticRules: false, + bruteForceProtectionIsEnabled: false, + wafSupported: true, + currentDayStats: 0, + thirtyDaysStats: 0, + standaloneMode: false, +}; diff --git a/projects/plugins/protect/src/js/components/firewall-header/styles.module.scss b/projects/plugins/protect/src/js/components/firewall-header/styles.module.scss index 25885b45f95a7..ef16a23602243 100644 --- a/projects/plugins/protect/src/js/components/firewall-header/styles.module.scss +++ b/projects/plugins/protect/src/js/components/firewall-header/styles.module.scss @@ -5,6 +5,22 @@ svg.spinner { color: var( --jp-black ); } +.popover-text { + width: 250px; + padding: calc( var( --spacing-base ) * 2 ); // 16px +} + +.loading-text { + font-size: 18px; +} + +.firewall-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; +} + .firewall-subheading { display: flex; justify-content: flex-start; @@ -24,13 +40,6 @@ svg.spinner { margin-top: calc( var( --spacing-base ) * 4 ); // 32px } -.firewall-header { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; -} - .stat-card-wrapper { display: flex; margin-left: auto; @@ -39,10 +48,32 @@ svg.spinner { >:first-child { margin-right: calc( var( --spacing-base ) * 3 ); // 24px } + + .disabled { + opacity: 0.5; + } } -.loading-text { - font-size: 18px; +.stat-card-icon { + width: 100%; + margin-bottom: calc( var( --spacing-base ) * 3 ); // 24px + display: flex; + justify-content: space-between; + align-items: center; +} + +.stat-card-label { + white-space: nowrap; +} + +@media ( max-width: 1115px ) { + .firewall-header { + display: inline-block; + } + + .stat-card-wrapper { + margin-top: calc( var( --spacing-base ) * 3 ); // 24px + } } @media ( max-width: 599px ) { @@ -58,4 +89,8 @@ svg.spinner { margin-bottom: var( --spacing-base ); // 8px } } + + .stat-card-icon { + margin-bottom: 0; + } }