.) and the app's address. * The secret lives in wp_options, as every plugin keeps its keys. */ function stackbrace_settings() { $saved = get_option( STACKBRACE_OPTION, array() ); return array( 'key' => isset( $saved['key'] ) ? (string) $saved['key'] : '', 'app' => isset( $saved['app'] ) ? (string) $saved['app'] : 'https://app.stackbrace.com', 'traps' => ! empty( $saved['traps'] ), ); } /** * Splits a key into the site id and the secret. Null when it is not a key. */ function stackbrace_parse_key( $key ) { $parts = explode( '.', trim( (string) $key ) ); if ( 3 !== count( $parts ) || 'sbk1' !== $parts[0] ) { return null; } if ( ! preg_match( '/^[0-9a-f-]{36}$/', $parts[1] ) || ! preg_match( '/^[A-Za-z0-9_-]{20,}$/', $parts[2] ) ) { return null; } return array( 'site' => $parts[1], 'secret' => $parts[2], ); } /** * What the heartbeat says. Nothing here identifies a person. */ function stackbrace_heartbeat() { global $wp_version; return array( 'kind' => 'heartbeat', 'plugin' => STACKBRACE_VERSION, 'wordpress' => (string) $wp_version, 'php' => PHP_VERSION, 'site_url' => site_url(), 'home_url' => home_url(), 'multisite' => is_multisite(), 'timezone' => wp_timezone_string(), 'sent_at' => gmdate( 'c' ), ); } /** * Posts one report, signed with the secret. Returns what happened, in words, * and keeps it for the settings page. */ function stackbrace_send( $report ) { $settings = stackbrace_settings(); $parsed = stackbrace_parse_key( $settings['key'] ); if ( ! $parsed ) { return stackbrace_remember( false, 'No key is set. Paste the key from Stackbrace below.' ); } $app = untrailingslashit( $settings['app'] ); if ( ! preg_match( '#^https?://#', $app ) ) { return stackbrace_remember( false, 'The app address must start with https://.' ); } $body = wp_json_encode( $report ); $timestamp = (string) time(); $signature = 'v1,' . hash_hmac( 'sha256', $timestamp . '.' . $body, $parsed['secret'] ); $response = wp_remote_post( $app . '/api/plugin/report', array( 'timeout' => 15, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8', 'User-Agent' => 'Stackbrace plugin/' . STACKBRACE_VERSION . ' (' . home_url() . ')', 'stackbrace-site' => $parsed['site'], 'stackbrace-timestamp' => $timestamp, 'stackbrace-signature' => $signature, ), 'body' => $body, ) ); if ( is_wp_error( $response ) ) { return stackbrace_remember( false, 'Could not reach Stackbrace: ' . $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); $said = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $text = is_array( $said ) && isset( $said['message'] ) ? (string) $said['message'] : ''; if ( $code >= 200 && $code < 300 ) { return stackbrace_remember( true, $text ? $text : 'Stackbrace received the report.' ); } return stackbrace_remember( false, 'Stackbrace answered HTTP ' . $code . ( $text ? ': ' . $text : '' ) ); } function stackbrace_remember( $ok, $message ) { update_option( STACKBRACE_STATUS, array( 'ok' => (bool) $ok, 'message' => (string) $message, 'at' => time(), ), false ); return array( 'ok' => (bool) $ok, 'message' => (string) $message, ); } /** * Everything installed, with exact versions, and the settings that decide * whether it stays that way. WordPress is asked for updates first (as its * own dashboard does), so the answer is today's. Nothing here names a * person. */ function stackbrace_inventory() { global $wpdb; if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! function_exists( 'get_core_updates' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } wp_version_check(); wp_update_plugins(); wp_update_themes(); $plugin_updates = get_site_transient( 'update_plugins' ); $auto_plugins = (array) get_site_option( 'auto_update_plugins', array() ); $active = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } $plugins = array(); foreach ( get_plugins() as $file => $data ) { $update = isset( $plugin_updates->response[ $file ]->new_version ) ? (string) $plugin_updates->response[ $file ]->new_version : null; $plugins[] = array( 'slug' => dirname( $file ) === '.' ? basename( $file, '.php' ) : dirname( $file ), 'name' => isset( $data['Name'] ) ? (string) $data['Name'] : $file, 'version' => isset( $data['Version'] ) ? (string) $data['Version'] : '0', 'active' => in_array( $file, $active, true ), 'update' => $update, 'auto_update' => in_array( $file, $auto_plugins, true ), ); } $theme_updates = get_site_transient( 'update_themes' ); $auto_themes = (array) get_site_option( 'auto_update_themes', array() ); $stylesheet = get_stylesheet(); $themes = array(); foreach ( wp_get_themes() as $slug => $theme ) { $update = isset( $theme_updates->response[ $slug ]['new_version'] ) ? (string) $theme_updates->response[ $slug ]['new_version'] : null; $themes[] = array( 'slug' => (string) $slug, 'name' => (string) $theme->get( 'Name' ), 'version' => (string) $theme->get( 'Version' ), 'active' => $slug === $stylesheet, 'update' => $update, 'auto_update' => in_array( $slug, $auto_themes, true ), ); } $core_update = null; foreach ( (array) get_core_updates() as $offer ) { if ( isset( $offer->response ) && 'upgrade' === $offer->response && isset( $offer->current ) ) { $core_update = (string) $offer->current; break; } } $auto = defined( 'WP_AUTO_UPDATE_CORE' ) ? WP_AUTO_UPDATE_CORE : get_site_option( 'auto_update_core_minor', 'enabled' ) !== 'disabled'; if ( true === $auto || 'beta' === $auto || 'rc' === $auto || 'development' === $auto ) { $core_auto = 'all'; } elseif ( false === $auto ) { $core_auto = 'off'; } else { $core_auto = 'minor'; } return array_merge( stackbrace_heartbeat(), array( 'kind' => 'inventory', 'database' => (string) $wpdb->db_server_info(), 'core_update' => $core_update, 'core_auto_update' => $core_auto, 'cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'debug_display' => defined( 'WP_DEBUG' ) && WP_DEBUG && ( ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY ), 'plugins' => $plugins, 'themes' => $themes, ) ); } /** * Where WordPress's own files are: the folder holding wp-load.php. * get_core_checksums() gives every path relative to it and no function * returns it, so this is the one place the plugin reads ABSPATH for a path * (besides loading wp-admin/includes files). Used only to compare core files * with WordPress.org's copy. */ function stackbrace_core_dir() { return trailingslashit( wp_normalize_path( ABSPATH ) ); } /** * The content directory, wherever the site keeps it. Files changed lately * are looked for across all of it, mu-plugins and drop-ins included, which no * function locates; this is the one place the plugin reads WP_CONTENT_DIR. * Uploads, backups and the debug log are found through the functions and * settings that own them. */ function stackbrace_content_dir() { return trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); } /** * A file's path as reported: from the WordPress folder when it is inside it, * otherwise from the content or uploads folder under that folder's name. A * server path never leaves the site. */ function stackbrace_relative( $full ) { static $roots = null; if ( null === $roots ) { $uploads = wp_upload_dir( null, false ); $roots = array( stackbrace_core_dir() => '', stackbrace_content_dir() => 'wp-content/', ); if ( ! empty( $uploads['basedir'] ) ) { $roots[ trailingslashit( wp_normalize_path( $uploads['basedir'] ) ) ] = 'uploads/'; } } $full = wp_normalize_path( $full ); foreach ( $roots as $root => $label ) { if ( 0 === strpos( $full, $root ) ) { return $label . substr( $full, strlen( $root ) ); } } return basename( $full ); } /** * The files: core compared to WordPress.org's checksums for this version, * what changed under wp-content in the last 7 days, and PHP where only * uploads belong. Paths are relative to the install; nothing is read from * a file but its checksum and dates. */ function stackbrace_integrity() { global $wp_version; if ( ! function_exists( 'get_core_checksums' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $limit = 200; $modified = array(); $missing = array(); $unexpected = array(); $checksums = get_core_checksums( $wp_version, get_locale() ); $checked = is_array( $checksums ) && ! empty( $checksums ); $core_files = 0; if ( $checked ) { $known = array(); foreach ( $checksums as $path => $md5 ) { // wp-content is the site's own; WordPress.org's copy of it is only a starting point. if ( 0 === strpos( $path, 'wp-content/' ) ) { continue; } ++$core_files; $known[ $path ] = true; $full = stackbrace_core_dir() . $path; if ( ! file_exists( $full ) ) { if ( count( $missing ) < $limit ) { $missing[] = array( 'path' => $path ); } } elseif ( md5_file( $full ) !== $md5 && count( $modified ) < $limit ) { $modified[] = stackbrace_file_entry( $path, $full ); } } foreach ( array( 'wp-admin', 'wp-includes' ) as $dir ) { if ( ! is_dir( stackbrace_core_dir() . $dir ) ) { continue; } $walk = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( stackbrace_core_dir() . $dir, FilesystemIterator::SKIP_DOTS ) ); foreach ( $walk as $found ) { $path = stackbrace_relative( $found->getPathname() ); if ( ! isset( $known[ $path ] ) && count( $unexpected ) < $limit ) { $unexpected[] = stackbrace_file_entry( $path, $found->getPathname() ); } } } } $recent = array(); $php = array(); $since = time() - 7 * DAY_IN_SECONDS; $seen = 0; $truncated = false; $uploads = wp_upload_dir( null, false ); $upload_dir = empty( $uploads['basedir'] ) ? null : untrailingslashit( wp_normalize_path( $uploads['basedir'] ) ); // Uploads are walked whole: nothing in them is skipped, since a cache // folder there is exactly where code would be hidden. Any file that PHP // could run counts. if ( $upload_dir && is_dir( $upload_dir ) ) { $walk = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $upload_dir, FilesystemIterator::SKIP_DOTS ) ); foreach ( $walk as $found ) { if ( ++$seen > 50000 ) { $truncated = true; break; } if ( ! $found->isFile() ) { continue; } $full = $found->getPathname(); $path = stackbrace_relative( $full ); if ( preg_match( '/\.(ph(p[0-9]*|t|tml|ps|ar)|inc)$/i', $path ) || preg_match( '/(^|\/)\.(user\.ini|htaccess)$/i', $path ) ) { if ( count( $php ) < $limit ) { $php[] = stackbrace_file_entry( $path, $full ); } } if ( $found->getMTime() > $since && count( $recent ) < $limit ) { $recent[] = stackbrace_file_entry( $path, $full ); } } } // The rest of wp-content, with cache, upgrade and log folders left out. $skip = '#/(cache|upgrade|wflogs|backups?|updraft|ai1wm-backups|et-cache|w3tc-config|litespeed|wp-rocket-config|debug\.log)(/|$)#'; if ( ! $truncated && is_dir( stackbrace_content_dir() ) ) { $walk = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( stackbrace_content_dir(), FilesystemIterator::SKIP_DOTS ), function ( $current ) use ( $skip, $upload_dir ) { if ( $current->isDir() && untrailingslashit( wp_normalize_path( $current->getPathname() ) ) === $upload_dir ) { return false; } return ! preg_match( $skip, '/' . $current->getFilename() . ( $current->isDir() ? '/' : '' ) ); } ) ); foreach ( $walk as $found ) { if ( ++$seen > 50000 ) { $truncated = true; break; } if ( ! $found->isFile() ) { continue; } if ( $found->getMTime() > $since && count( $recent ) < $limit ) { $recent[] = stackbrace_file_entry( stackbrace_relative( $found->getPathname() ), $found->getPathname() ); } } } return array( 'kind' => 'integrity', 'plugin' => STACKBRACE_VERSION, 'wordpress' => (string) $wp_version, 'core_checked' => $checked, 'core_files' => $core_files, 'modified' => $modified, 'missing' => $missing, 'unexpected' => $unexpected, 'recent' => $recent, 'php_in_uploads' => $php, 'content_files' => $seen, 'truncated' => $truncated, 'sent_at' => gmdate( 'c' ), ); } function stackbrace_file_entry( $path, $full ) { $exists = file_exists( $full ); $mtime = $exists ? filemtime( $full ) : false; $size = $exists ? filesize( $full ) : false; return array( 'path' => $path, 'mtime' => $mtime ? gmdate( 'c', $mtime ) : null, 'size' => false === $size ? null : (int) $size, ); } /** * The backup plugins and the error log. Each known backup plugin's last * completed run is read from where that plugin records it: an option, its * own table, or the dates of the files it writes. A service that keeps its * backups elsewhere is reported as such. */ function stackbrace_backup_plugins() { global $wpdb; $known = array( 'updraftplus' => array( 'name' => 'UpdraftPlus', 'how' => 'readable', ), 'backwpup' => array( 'name' => 'BackWPup', 'how' => 'readable', ), 'duplicator' => array( 'name' => 'Duplicator', 'how' => 'readable', ), 'duplicator-pro' => array( 'name' => 'Duplicator Pro', 'how' => 'readable', ), 'all-in-one-wp-migration' => array( 'name' => 'All-in-One WP Migration', 'how' => 'readable', ), 'wpvivid-backuprestore' => array( 'name' => 'WPvivid Backup', 'how' => 'readable', ), 'backup-backup' => array( 'name' => 'Backup Migration', 'how' => 'readable', ), 'jetpack' => array( 'name' => 'Jetpack', 'how' => 'offsite', ), 'jetpack-backup' => array( 'name' => 'Jetpack VaultPress Backup', 'how' => 'offsite', ), 'vaultpress' => array( 'name' => 'VaultPress', 'how' => 'offsite', ), 'blogvault-real-time-backup' => array( 'name' => 'BlogVault', 'how' => 'offsite', ), 'backupbuddy' => array( 'name' => 'BackupBuddy', 'how' => 'unreadable', ), 'solid-backups' => array( 'name' => 'Solid Backups', 'how' => 'unreadable', ), 'wp-time-capsule' => array( 'name' => 'WP Time Capsule', 'how' => 'offsite', ), ); if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $active = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } $found = array(); foreach ( get_plugins() as $file => $data ) { $slug = dirname( $file ) === '.' ? basename( $file, '.php' ) : dirname( $file ); if ( ! isset( $known[ $slug ] ) ) { continue; } $last = null; switch ( $slug ) { case 'updraftplus': $record = get_option( 'updraft_last_backup' ); if ( is_array( $record ) && ! empty( $record['backup_time'] ) && ! empty( $record['success'] ) ) { $last = (int) $record['backup_time']; } break; case 'backwpup': foreach ( (array) get_option( 'backwpup_jobs', array() ) as $job ) { if ( is_array( $job ) && ! empty( $job['lastrun'] ) ) { $last = max( (int) $last, (int) $job['lastrun'] ); } } break; case 'duplicator': case 'duplicator-pro': $table = $wpdb->prefix . ( 'duplicator' === $slug ? 'duplicator_packages' : 'duplicator_pro_packages' ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // Duplicator keeps its packages in its own table; this is the only way to read when the last one was made. $made = $wpdb->get_var( "SELECT MAX(created) FROM `{$table}` WHERE status >= 100" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $last = $made ? strtotime( $made ) : null; } break; case 'all-in-one-wp-migration': // Its own constant, which follows a backups folder the owner moved. Defined while it is active. $last = defined( 'AI1WM_BACKUPS_PATH' ) ? stackbrace_newest_file( AI1WM_BACKUPS_PATH, '/\.wpress$/' ) : null; break; case 'wpvivid-backuprestore': $last = stackbrace_wpvivid_last(); break; case 'backup-backup': // Its own constant for the folder its backups are written to. Defined while it is active. $last = defined( 'BMI_BACKUPS' ) ? stackbrace_newest_file( BMI_BACKUPS, '/\.zip$/' ) : null; break; } $found[] = array( 'slug' => $slug, 'name' => $known[ $slug ]['name'], 'active' => in_array( $file, $active, true ), 'last_run' => $last ? gmdate( 'c', $last ) : null, 'how' => $known[ $slug ]['how'], ); } return $found; } /** WPvivid lists every backup it holds, with when it was made, in an option. */ function stackbrace_wpvivid_last() { $last = null; foreach ( (array) get_option( 'wpvivid_backup_list', array() ) as $backup ) { // A backup someone uploaded to WPvivid was not made by it. if ( is_array( $backup ) && ! empty( $backup['create_time'] ) && ( ! isset( $backup['type'] ) || 'Upload' !== $backup['type'] ) ) { $last = max( (int) $last, (int) $backup['create_time'] ); } } return $last ? $last : null; } function stackbrace_newest_file( $dir, $pattern ) { if ( ! is_dir( $dir ) ) { return null; } $newest = null; foreach ( scandir( $dir ) as $name ) { if ( preg_match( $pattern, $name ) && is_file( $dir . '/' . $name ) ) { $newest = max( (int) $newest, (int) filemtime( $dir . '/' . $name ) ); } } return $newest ? $newest : null; } /** The last lines of the debug log, if WordPress writes one. Lines are cut; nothing else is read. */ function stackbrace_log() { $path = null; if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // WordPress points PHP's error log at the file it writes (wp_debug_mode()), wherever that is. $path = (string) ini_get( 'error_log' ); } // WordPress's default place for it: a log left from earlier debugging, // which the web server may still be serving. if ( ! $path && file_exists( stackbrace_content_dir() . 'debug.log' ) ) { $path = stackbrace_content_dir() . 'debug.log'; } if ( ! $path || ! is_readable( $path ) ) { return array( 'present' => false, 'size' => null, 'modified' => null, 'tail' => array(), 'fatal' => 0, ); } $size = (int) filesize( $path ); // Only the end of the file is read: the last 16 KB, then the last error lines of that. $chunk = (string) file_get_contents( $path, false, null, max( 0, $size - 16384 ), 16384 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local file, read from an offset, which WP_Filesystem cannot do. $lines = array_values( array_filter( array_map( 'trim', explode( "\n", $chunk ) ), 'strlen' ) ); // Only error lines go, and they are cleaned first: a stack trace's // arguments, a connection error's password, an email address or a key // in a message must not leave the site. $errors = array(); foreach ( $lines as $line ) { if ( preg_match( '/PHP (Fatal|Parse|Warning|Notice|Deprecated)|Fatal error|WordPress database error/i', $line ) ) { $errors[] = stackbrace_clean_log_line( $line ); } } $tail = array_slice( $errors, -20 ); $fatal = 0; foreach ( $tail as $line ) { if ( preg_match( '/PHP Fatal|Fatal error/i', $line ) ) { ++$fatal; } } $mtime = filemtime( $path ); return array( 'present' => true, 'size' => $size, 'modified' => $mtime ? gmdate( 'c', $mtime ) : null, 'tail' => $tail, 'fatal' => $fatal, ); } function stackbrace_clean_log_line( $line ) { // Server paths become paths from the site's folders. $line = str_replace( array( stackbrace_content_dir(), stackbrace_core_dir() ), array( '/wp-content/', '/' ), $line ); // Function arguments in a trace, which may be passwords or query values. $line = preg_replace( '/\(([^()]{0,400})\)/', '(…)', $line ); // Email addresses, long tokens, and anything named like a secret. $line = preg_replace( '/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/', '[email]', $line ); $line = preg_replace( '/\b[A-Za-z0-9_\-]{32,}\b/', '[token]', $line ); $line = preg_replace( '/(password|passwd|secret|token|api[_-]?key|authorization)([\s:=]+)\S+/i', '$1$2[hidden]', $line ); // A database user name in a connection error. $line = preg_replace( '/(for user )\S+/i', '$1[hidden]', $line ); return substr( $line, 0, 300 ); } function stackbrace_health() { global $wp_version; return array( 'kind' => 'health', 'plugin' => STACKBRACE_VERSION, 'wordpress' => (string) $wp_version, 'backups' => stackbrace_backup_plugins(), 'log' => stackbrace_log(), 'sent_at' => gmdate( 'c' ), ); } /** The hourly reports, through WP-Cron: the inventory, then the files. Nothing is gathered until a key is saved. */ function stackbrace_cron() { $settings = stackbrace_settings(); if ( ! stackbrace_parse_key( $settings['key'] ) ) { return; } $sent = stackbrace_send( stackbrace_inventory() ); if ( $sent['ok'] ) { stackbrace_send( stackbrace_integrity() ); stackbrace_send( stackbrace_users() ); stackbrace_send( stackbrace_health() ); } } add_action( STACKBRACE_EVENT, 'stackbrace_cron' ); function stackbrace_schedule() { if ( ! wp_next_scheduled( STACKBRACE_EVENT ) ) { wp_schedule_event( time() + 60, 'hourly', STACKBRACE_EVENT ); } } register_activation_hook( __FILE__, 'stackbrace_schedule' ); add_action( 'init', 'stackbrace_schedule' ); function stackbrace_unschedule() { $next = wp_next_scheduled( STACKBRACE_EVENT ); if ( $next ) { wp_unschedule_event( $next, STACKBRACE_EVENT ); } } register_deactivation_hook( __FILE__, 'stackbrace_unschedule' ); /** Settings → Stackbrace. */ function stackbrace_menu() { add_options_page( 'Stackbrace', 'Stackbrace', 'manage_options', 'stackbrace', 'stackbrace_page' ); } add_action( 'admin_menu', 'stackbrace_menu' ); function stackbrace_register() { register_setting( 'stackbrace', STACKBRACE_OPTION, array( 'type' => 'array', 'sanitize_callback' => 'stackbrace_sanitize', 'default' => array(), ) ); } add_action( 'admin_init', 'stackbrace_register' ); function stackbrace_sanitize( $input ) { $current = stackbrace_settings(); $key = isset( $input['key'] ) ? trim( (string) $input['key'] ) : ''; // An empty field keeps the key already saved; "clear" removes it. if ( '' === $key ) { $key = $current['key']; } elseif ( 'clear' === $key ) { $key = ''; } elseif ( ! stackbrace_parse_key( $key ) ) { // settings_errors() prints messages as they are given, so every one is escaped here. add_settings_error( 'stackbrace', 'key', esc_html( 'That is not a Stackbrace key. It looks like sbk1...' ) ); $key = $current['key']; } $app = isset( $input['app'] ) ? esc_url_raw( trim( (string) $input['app'] ) ) : $current['app']; if ( '' === $app ) { $app = 'https://app.stackbrace.com'; } $traps = ! empty( $input['traps'] ); if ( $traps && ! $current['traps'] ) { stackbrace_set_traps(); } elseif ( ! $traps && $current['traps'] ) { stackbrace_remove_traps(); } return array( 'key' => $key, 'app' => $app, 'traps' => $traps, ); } /** * The traps. A trap account: a subscriber with no capabilities and a * password nobody is told, whose successful login means someone has the * site's passwords. A trap address: named only in a Disallow line of * robots.txt, whose request means someone is reading it for what to take. */ define( 'STACKBRACE_TRAP_USER', 'wpsupport' ); function stackbrace_trap_path() { $token = get_option( 'stackbrace_trap_token' ); if ( ! $token ) { $token = wp_generate_password( 12, false, false ); update_option( 'stackbrace_trap_token', $token, false ); } // Under the uploads folder's address, wherever the site keeps it: where a // forgotten backup would plausibly sit. $uploads = wp_upload_dir( null, false ); $base = empty( $uploads['baseurl'] ) ? '' : (string) wp_parse_url( $uploads['baseurl'], PHP_URL_PATH ); return trailingslashit( $base ) . 'stackbrace-backup-' . $token . '/'; } function stackbrace_set_traps() { if ( ! username_exists( STACKBRACE_TRAP_USER ) ) { $id = wp_insert_user( array( 'user_login' => STACKBRACE_TRAP_USER, 'user_pass' => wp_generate_password( 64, true, true ), 'user_email' => STACKBRACE_TRAP_USER . '@' . wp_parse_url( home_url(), PHP_URL_HOST ), 'display_name' => 'WP Support', 'role' => 'subscriber', ) ); if ( ! is_wp_error( $id ) ) { $user = new WP_User( $id ); $user->remove_all_caps(); update_user_meta( $id, 'stackbrace_trap', '1' ); } } stackbrace_trap_path(); } function stackbrace_remove_traps() { $id = username_exists( STACKBRACE_TRAP_USER ); if ( $id && get_user_meta( $id, 'stackbrace_trap', true ) === '1' ) { if ( ! function_exists( 'wp_delete_user' ) ) { require_once ABSPATH . 'wp-admin/includes/user.php'; } wp_delete_user( $id ); } delete_option( 'stackbrace_trap_token' ); } function stackbrace_robots( $output ) { $settings = stackbrace_settings(); if ( ! $settings['traps'] ) { return $output; } // Inside the group for every crawler, where a reader looks; a line on its // own after the group would be ignored by a careful parser. $line = 'Disallow: ' . stackbrace_trap_path(); if ( false !== strpos( $output, "Disallow: /wp-admin/\n" ) ) { return str_replace( "Disallow: /wp-admin/\n", "Disallow: /wp-admin/\n" . $line . "\n", $output ); } return "User-agent: *\n" . $line . "\n\n" . $output; } add_filter( 'robots_txt', 'stackbrace_robots', 20 ); /** A request for the trap address: remembered, reported at once, answered 404. */ function stackbrace_watch_path() { $settings = stackbrace_settings(); if ( ! $settings['traps'] || empty( $_SERVER['REQUEST_URI'] ) ) { return; } $path = (string) wp_parse_url( sanitize_text_field( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); if ( 0 === strpos( $path, stackbrace_trap_path() ) || rtrim( $path, '/' ) === rtrim( stackbrace_trap_path(), '/' ) ) { stackbrace_trip( 'path', $path ); status_header( 404 ); nocache_headers(); exit; } } add_action( 'init', 'stackbrace_watch_path', 1 ); /** A successful login as the trap account. */ function stackbrace_watch_login( $login, $user ) { update_user_meta( $user->ID, 'stackbrace_last_login', time() ); if ( STACKBRACE_TRAP_USER === $login && get_user_meta( $user->ID, 'stackbrace_trap', true ) === '1' ) { stackbrace_trip( 'login', null ); } } add_action( 'wp_login', 'stackbrace_watch_login', 10, 2 ); function stackbrace_trip( $trap, $path ) { $event = array( 'trap' => $trap, 'at' => gmdate( 'c' ), 'ip' => isset( $_SERVER['REMOTE_ADDR'] ) ? substr( sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ), 0, 64 ) : '', 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ), 0, 200 ) : '', 'path' => $path ? substr( $path, 0, 300 ) : null, ); $trips = (array) get_option( 'stackbrace_trips', array() ); array_unshift( $trips, $event ); update_option( 'stackbrace_trips', array_slice( $trips, 0, 20 ), false ); // Sent at once, at most once a minute per trap; the next users report carries it too. $last = (int) get_option( 'stackbrace_trip_sent_' . $trap, 0 ); if ( time() - $last >= 60 ) { update_option( 'stackbrace_trip_sent_' . $trap, time(), false ); global $wp_version; stackbrace_send( array( 'kind' => 'canary', 'plugin' => STACKBRACE_VERSION, 'wordpress' => (string) $wp_version, 'trips' => array( $event ), 'sent_at' => gmdate( 'c' ), ) ); } } /** Who can administer the site, and the traps' trips. Login names only. */ function stackbrace_users() { global $wp_version; $counts = count_users(); $roles = array(); if ( isset( $counts['avail_roles'] ) ) { foreach ( (array) $counts['avail_roles'] as $role => $n ) { if ( 'none' !== $role ) { $roles[ (string) $role ] = (int) $n; } } } $admins = array(); foreach ( get_users( array( 'role' => 'administrator', 'number' => 100, 'fields' => array( 'ID', 'user_login', 'user_registered' ), ) ) as $user ) { $last = (int) get_user_meta( $user->ID, 'stackbrace_last_login', true ); $admins[] = array( 'login' => (string) $user->user_login, 'registered' => $user->user_registered ? gmdate( 'c', strtotime( $user->user_registered ) ) : null, 'last_login' => $last ? gmdate( 'c', $last ) : null, ); } $settings = stackbrace_settings(); return array( 'kind' => 'users', 'plugin' => STACKBRACE_VERSION, 'wordpress' => (string) $wp_version, 'total' => isset( $counts['total_users'] ) ? (int) $counts['total_users'] : count( $admins ), 'roles' => $roles, 'administrators' => $admins, 'traps_set' => (bool) $settings['traps'], 'trips' => (array) get_option( 'stackbrace_trips', array() ), 'sent_at' => gmdate( 'c' ), ); } function stackbrace_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( isset( $_POST['stackbrace_send_now'] ) && check_admin_referer( 'stackbrace_send_now' ) ) { $sent = stackbrace_send( stackbrace_inventory() ); if ( $sent['ok'] ) { $sent = stackbrace_send( stackbrace_integrity() ); } if ( $sent['ok'] ) { $sent = stackbrace_send( stackbrace_users() ); } if ( $sent['ok'] ) { $sent = stackbrace_send( stackbrace_health() ); } // The message carries the Stackbrace server's reply, so it is escaped before settings_errors() prints it. add_settings_error( 'stackbrace', 'sent', esc_html( $sent['message'] ), $sent['ok'] ? 'success' : 'error' ); } $settings = stackbrace_settings(); $parsed = stackbrace_parse_key( $settings['key'] ); $status = get_option( STACKBRACE_STATUS, array() ); $next = wp_next_scheduled( STACKBRACE_EVENT ); // After a save WordPress has already shown the messages on a Settings page (options-head.php); this shows the rest, such as "Send a report now". settings_errors( 'stackbrace', false, true ); ?>

Stackbrace

This plugin reports the site's versions and health to Stackbrace. It only talks outward: it opens nothing on this site and accepts no instructions.

Reports

Last attempt ago: Nothing sent yet. Next scheduled report in , when the site has visitors.