__( 'Loading reference checksums', 'siteaudithub' ), 'enumerate' => __( 'Listing files', 'siteaudithub' ), 'inspect' => __( 'Inspecting files', 'siteaudithub' ), 'database' => __( 'Checking the database for injected code', 'siteaudithub' ), 'finalize' => __( 'Comparing with baseline and finalizing', 'siteaudithub' ), 'done' => __( 'Complete', 'siteaudithub' ), ); return isset( $labels[ $stage ] ) ? $labels[ $stage ] : $stage; } /** * Progress estimate. * * @param object $scan Scan. * @return int */ public function progress( $scan ) { switch ( $scan->stage ) { case 'prepare': return 1; case 'enumerate': return 3; case 'inspect': return $scan->total_items > 0 ? (int) floor( 5 + 90 * min( 1, $scan->processed_items / $scan->total_items ) ) : 5; case 'database': return 95; case 'finalize': return 97; } return 0; } /** * One unit of work. * * @param object $scan Scan. * @param float $deadline Deadline. * @return bool */ public function step( $scan, $deadline ) { switch ( $scan->stage ) { case 'prepare': $this->prepare( $scan ); return true; case 'enumerate': $this->enumerate( $scan, $deadline ); return true; case 'inspect': $this->inspect( $scan, $deadline ); return true; case 'database': $this->database( $scan ); return true; case 'finalize': return $this->finalize( $scan, $deadline ); } return false; } /** * Move to a stage. * * @param object $scan Scan. * @param string $stage Stage. * @param array $state State. */ private function go( $scan, $stage, array $state ) { Repository::update_scan( $scan->id, array( 'stage' => $stage, 'state' => $state, ) ); } /* ------------------------------------------------------------------ */ /** * Map a path to its display key (relative to the install root where possible). * * @param string $abs Absolute path. * @return string */ public static function key_for( $abs ) { $abs = wp_normalize_path( $abs ); $root = Paths::root(); if ( 0 === strpos( $abs, $root ) ) { return substr( $abs, strlen( $root ) ); } $content = Paths::content(); if ( 0 === strpos( $abs, $content ) ) { return '[wp-content]/' . substr( $abs, strlen( $content ) ); } return $abs; } /** * Map a key back to an absolute path, confined to the WordPress roots. * * @param string $key Key. * @return string|false */ public static function path_for( $key ) { if ( 0 === strpos( $key, '[wp-content]/' ) ) { $abs = Paths::content() . substr( $key, strlen( '[wp-content]/' ) ); } else { $abs = Paths::root() . $key; } return Security::safe_path( $abs, array( Paths::root(), Paths::content() ) ); } /** * Content-relative path ("plugins/foo/bar.php") for a key, or ''. * * @param string $key Key. * @return string */ private static function content_rel( $key ) { if ( 0 === strpos( $key, '[wp-content]/' ) ) { return substr( $key, strlen( '[wp-content]/' ) ); } $abs = wp_normalize_path( Paths::root() . $key ); $content = Paths::content(); return 0 === strpos( $abs, $content ) ? substr( $abs, strlen( $content ) ) : ''; } /** * Load checksums. * * @param object $scan Scan. */ private function prepare( $scan ) { global $wp_version; $state = $scan->state; $state['core_checksums'] = 0; if ( ! empty( $scan->config['integrity_wporg_checksums'] ) ) { $sums = self::core_checksums(); if ( $sums ) { $state['core_checksums'] = count( $sums ); } else { $state['core_error'] = __( 'Official core checksums could not be downloaded.', 'siteaudithub' ); } } $state['wp_version'] = $wp_version; // New-file detection needs a previous complete scan to compare with. $state['has_baseline'] = self::has_baseline() ? 1 : 0; // Versions now and at the previous scan: a file that changed while its // plugin / theme / WordPress version stayed the same was not updated. $state['versions'] = self::versions(); $previous = Repository::latest_completed( 'integrity' ); $state['prev_versions'] = ( $previous && isset( $previous->summary['versions'] ) && is_array( $previous->summary['versions'] ) ) ? $previous->summary['versions'] : array(); $state['dirs'] = array( array( untrailingslashit( Paths::root() ), 0 ), array( untrailingslashit( Paths::core( 'wp-admin' ) ), 1 ), array( untrailingslashit( Paths::core( 'wp-includes' ) ), 1 ), array( untrailingslashit( Paths::content() ), 1 ), ); $state['files'] = 0; $state['limit_hit'] = 0; $state['symlinks'] = 0; $state['pcs_fetch'] = 0; $this->go( $scan, 'enumerate', $state ); } /** * Official core checksums (cached one day). * * @return array path => md5 */ public static function core_checksums() { global $wp_version; $locale = get_locale(); $key = 'siteaudithub_core_sums_' . md5( $wp_version . $locale ); $cached = get_transient( $key ); if ( is_array( $cached ) && $cached ) { return $cached; } if ( ! function_exists( 'get_core_checksums' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } $sums = get_core_checksums( $wp_version, $locale ); if ( ! is_array( $sums ) && 'en_US' !== $locale ) { $sums = get_core_checksums( $wp_version, 'en_US' ); } if ( ! is_array( $sums ) || ! $sums ) { return array(); } set_transient( $key, $sums, DAY_IN_SECONDS ); return $sums; } /** * Walk directories (resumable stack) and register files. * * @param object $scan Scan. * @param float $deadline Deadline. */ private function enumerate( $scan, $deadline ) { $state = $scan->state; $max = (int) $scan->config['integrity_max_files']; $updir = Paths::uploads(); $own = trailingslashit( wp_normalize_path( SITEAUDITHUB_DIR ) ); $core = array( Paths::core( 'wp-admin' ), Paths::core( 'wp-includes' ) ); $root = Paths::root(); $content = Paths::content(); $batch = array(); $dirs = 0; while ( ! empty( $state['dirs'] ) && microtime( true ) < $deadline - 1 && $dirs < 400 ) { list( $dir, $recursive ) = array_pop( $state['dirs'] ); ++$dirs; $entries = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $entries ) { $batch[] = array( self::key_for( $dir ) . '/', 'unreadable-dir' ); continue; } foreach ( $entries as $name ) { if ( '.' === $name || '..' === $name ) { continue; } $path = $dir . DIRECTORY_SEPARATOR . $name; $norm = wp_normalize_path( $path ); if ( is_link( $path ) ) { ++$state['symlinks']; continue; // Never follow symlinks (loops, paths outside the site). } if ( is_dir( $path ) ) { if ( in_array( $name, self::SKIP_DIRS, true ) ) { continue; } if ( ! $recursive ) { // A folder directly in the site root. WordPress's own folders are queued // explicitly; any other folder is reported and its files are inspected too. if ( ! in_array( strtolower( $name ), self::ROOT_DIRS_ALLOWED, true ) && trailingslashit( $norm ) !== $content ) { $known = in_array( strtolower( $name ), self::ROOT_DIRS_KNOWN, true ); Repository::add_finding( $scan->id, Catalog::finding( $known ? 'int_root_dir_known' : 'int_root_dir', self::key_for( $path ) . '/', __( 'Folder in the WordPress root that is not part of WordPress.', 'siteaudithub' ) ) ); $state['dirs'][] = array( $path, 1 ); } continue; } // SiteAuditHub never reports on its own files. if ( trailingslashit( $norm ) === $own ) { continue; } if ( $updir && empty( $scan->config['integrity_include_uploads'] ) && trailingslashit( $norm ) === $updir ) { continue; } // The install root is walked non-recursively; core and content roots are queued explicitly. $state['dirs'][] = array( $path, 1 ); continue; } $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); $is_core = false; foreach ( $core as $c ) { if ( 0 === strpos( $norm, $c ) ) { $is_core = true; } } $is_root = trailingslashit( wp_normalize_path( dirname( $path ) ) ) === $root; $in_upload = $updir && 0 === strpos( $norm, $updir ); $special = in_array( strtolower( $name ), array( '.htaccess', '.user.ini', 'php.ini' ), true ); if ( $is_core || $is_root ) { $include = true; $source = $is_core ? 'core' : 'root'; } elseif ( $in_upload ) { if ( empty( $scan->config['integrity_include_uploads'] ) ) { continue; } $include = $special || in_array( $ext, array_merge( self::UPLOAD_EXEC, self::SCRIPT_EXT, array( 'ico', 'htm', 'html', 'svg' ) ), true ); $source = 'uploads'; } else { $include = $special || in_array( $ext, array_merge( self::PHP_EXT, self::SCRIPT_EXT, array( 'ico' ) ), true ); if ( 0 === strpos( $norm, $own ) ) { continue; // Own files are excluded from the scan. } $source = 'content'; } if ( ! $include ) { continue; } if ( $state['files'] >= $max ) { $state['limit_hit'] = 1; $state['dirs'] = array(); break 2; } ++$state['files']; $batch[] = array( self::key_for( $path ), $source ); if ( count( $batch ) >= 200 ) { $this->insert_files( $scan->id, $batch ); $batch = array(); } } } $this->insert_files( $scan->id, $batch ); $total = Repository::count_items( $scan->id, 'file' ); Repository::update_scan( $scan->id, array( 'state' => $state, 'total_items' => $total, ) ); if ( empty( $state['dirs'] ) ) { $this->go( $scan, 'inspect', $state ); } } /** * Bulk insert file items. * * @param int $scan_id Scan. * @param array $batch [key, source]. */ private function insert_files( $scan_id, array $batch ) { global $wpdb; if ( ! $batch ) { return; } $args = array( Db::table( 'items' ) ); foreach ( $batch as $row ) { $status = 'unreadable-dir' === $row[1] ? 'failed' : 'pending'; array_push( $args, (int) $scan_id, sha1( $row[0] ), $row[0], $row[1], $status ); } $rows = implode( ', ', array_fill( 0, count( $batch ), "(%d, %s, %s, 'file', %s, %s)" ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $rows holds only placeholder groups, one per file. $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (scan_id, item_hash, item_key, item_type, source, status) VALUES $rows", $args ) ); } /** * Inspect a batch of files. * * @param object $scan Scan. * @param float $deadline Deadline. */ private function inspect( $scan, $deadline ) { global $wpdb; $table = Db::table( 'items' ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, item_key, source, attempts FROM %i WHERE scan_id = %d AND item_type = 'file' AND status = 'pending' ORDER BY id ASC LIMIT 150", $table, $scan->id ) ); if ( ! $rows ) { if ( 0 === Repository::count_items( $scan->id, 'file', array( 'processing' ) ) ) { $this->go( $scan, 'database', $scan->state ); } return; } $ids = array_map( 'intval', wp_list_pluck( $rows, 'id' ) ); $in = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in holds only %d placeholders, one per ID. $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'processing', claimed_at = %s, attempts = attempts + 1 WHERE id IN ( $in )", array_merge( array( $table, Db::now() ), $ids ) ) ); $state = $scan->state; $core_sums = ! empty( $state['core_checksums'] ) ? self::core_checksums() : array(); $plugins = $this->plugin_versions(); foreach ( $rows as $row ) { if ( microtime( true ) > $deadline ) { $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'pending' WHERE id = %d", $table, $row->id ) ); continue; } $this->inspect_file( $scan, $row, $core_sums, $plugins, $state ); } Repository::update_scan( $scan->id, array( 'state' => $state ) ); $counts = Repository::item_status_counts( $scan->id, 'file' ); $total = array_sum( $counts ); Repository::update_scan( $scan->id, array( 'total_items' => $total, 'processed_items' => $total - ( isset( $counts['pending'] ) ? $counts['pending'] : 0 ) - ( isset( $counts['processing'] ) ? $counts['processing'] : 0 ), 'failed_items' => isset( $counts['failed'] ) ? $counts['failed'] : 0, ) ); } /** * Installed plugin versions keyed by folder (or single-file name). * * @return array */ private function plugin_versions() { $out = array(); foreach ( Paths::plugins() as $file => $data ) { $slug = false !== strpos( $file, '/' ) ? dirname( $file ) : $file; $out[ $slug ] = $data['Version']; } return $out; } /** * Published checksums for a WordPress.org plugin version (cached). * * @param string $slug Slug. * @param string $version Version. * @param array $state State (fetch counter, by reference). * @return array|null files map, or null when unavailable. */ private function plugin_checksums( $slug, $version, array &$state ) { $key = 'siteaudithub_pcs_' . md5( $slug . '@' . $version ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return empty( $cached['files'] ) ? null : $cached['files']; } if ( $state['pcs_fetch'] >= 40 ) { return null; // Bound external requests per scan. } ++$state['pcs_fetch']; $url = sprintf( 'https://downloads.wordpress.org/plugin-checksums/%s/%s.json', rawurlencode( $slug ), rawurlencode( $version ) ); $response = wp_safe_remote_get( $url, array( 'timeout' => 10, 'limit_response_size' => 8 * 1024 * 1024, ) ); $files = array(); if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { $json = json_decode( wp_remote_retrieve_body( $response ), true ); if ( is_array( $json ) && isset( $json['files'] ) && is_array( $json['files'] ) ) { foreach ( $json['files'] as $path => $sums ) { $files[ (string) $path ] = array( 'md5' => isset( $sums['md5'] ) ? (array) $sums['md5'] : array(), 'sha256' => isset( $sums['sha256'] ) ? (array) $sums['sha256'] : array(), ); } } } set_transient( $key, array( 'files' => $files ), $files ? WEEK_IN_SECONDS : DAY_IN_SECONDS ); return $files ? $files : null; } /** * Inspect one file. Read-only. * * @param object $scan Scan. * @param object $row Item. * @param array $core_sums Core checksums. * @param array $plugins Plugin versions. * @param array $state State (by reference). */ private function inspect_file( $scan, $row, array $core_sums, array $plugins, array &$state ) { $key = $row->item_key; $findings = array(); $data = array(); $status = 'done'; $abs = self::path_for( $key ); $name = basename( $key ); $ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); $max = (int) $scan->config['integrity_max_file_kb'] * 1024; $is_php = in_array( $ext, self::PHP_EXT, true ); if ( false === $abs || ! is_file( $abs ) || ! is_readable( $abs ) ) { Repository::add_finding( $scan->id, Catalog::finding( 'int_unreadable', $key, __( 'File could not be opened for reading.', 'siteaudithub' ) ) ); Repository::update_item( $row->id, array( 'status' => 'failed', 'processed_at' => Db::now(), ) ); return; } $size = (int) @filesize( $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $hash = @hash_file( 'sha256', $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $verified = ''; $data = array( 'h' => $hash ? $hash : '', 's' => $size, ); // Core verification. if ( 'core' === $row->source || 'root' === $row->source ) { if ( $core_sums ) { if ( isset( $core_sums[ $key ] ) ) { $md5 = @md5_file( $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $md5 === $core_sums[ $key ] ) { $verified = 'core'; } else { $findings[] = Catalog::finding( 'int_core_mismatch', $key, sprintf( 'md5 %s ≠ %s', $md5, $core_sums[ $key ] ) ); } } elseif ( 'core' === $row->source ) { $findings[] = Catalog::finding( 'int_core_unexpected', $key, size_format( $size ), array( 'severity' => $is_php ? 'high' : 'low' ) ); } elseif ( $is_php && ! in_array( strtolower( $name ), self::CORE_ROOT_FILES, true ) ) { $findings[] = Catalog::finding( 'int_root_unexpected', $key, size_format( $size ) ); } elseif ( ! $is_php && self::root_file_unknown( $name ) ) { $findings[] = Catalog::finding( 'int_root_file', $key, size_format( $size ) ); } } elseif ( 'root' === $row->source ) { // Without checksums: compare with the files WordPress ships in the root. if ( $is_php && ! in_array( strtolower( $name ), self::CORE_ROOT_FILES, true ) ) { $findings[] = Catalog::finding( 'int_root_unexpected', $key, size_format( $size ) ); } elseif ( ! $is_php && self::root_file_unknown( $name ) ) { $findings[] = Catalog::finding( 'int_root_file', $key, size_format( $size ) ); } } } // Plugin verification. $rel = self::content_rel( $key ); if ( ! $verified && $rel && 0 === strpos( $rel, 'plugins/' ) && ! empty( $scan->config['integrity_wporg_checksums'] ) ) { $parts = explode( '/', substr( $rel, strlen( 'plugins/' ) ), 2 ); $slug = $parts[0]; if ( isset( $parts[1] ) && isset( $plugins[ $slug ] ) && $plugins[ $slug ] && 'self' !== $row->source ) { $sums = $this->plugin_checksums( $slug, $plugins[ $slug ], $state ); if ( null !== $sums ) { $inner = $parts[1]; if ( isset( $sums[ $inner ] ) ) { $ok = in_array( $hash, $sums[ $inner ]['sha256'], true ); if ( ! $ok && $sums[ $inner ]['md5'] ) { $ok = in_array( @md5_file( $abs ), $sums[ $inner ]['md5'], true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } if ( $ok ) { $verified = 'plugin'; } elseif ( $is_php || in_array( $ext, self::SCRIPT_EXT, true ) ) { $findings[] = Catalog::finding( 'int_plugin_mismatch', $key, $slug . ' ' . $plugins[ $slug ] ); } // Non-code files (.htaccess, .txt…) sometimes differ from WordPress.org's own // checksum list even in the official zip; their content is still inspected below. } elseif ( $is_php ) { $findings[] = Catalog::finding( 'int_plugin_extra', $key, $slug . ' ' . $plugins[ $slug ] ); } } } } $data['v'] = $verified; // Suspicious names and locations. if ( preg_match( '/\.(?:jpe?g|png|gif|webp|ico|txt|pdf|zip|bak|css|js)\.(?:php\d?|phtml|phar|pht)$/i', $name ) ) { $findings[] = Catalog::finding( 'int_disguised', $key, $name ); } // Well-known developer tool configs (bundled by many libraries) are not // reported by name outside uploads; their content is still inspected. $dev_config = 'uploads' !== $row->source && in_array( strtolower( $name ), array( '.php-cs-fixer.dist.php', '.php-cs-fixer.php', '.php_cs.dist.php', '.phpstorm.meta.php', '.phan.php', '.rector.php' ), true ); if ( ! $dev_config && '.' === substr( $name, 0, 1 ) && in_array( $ext, self::PHP_EXT, true ) ) { $findings[] = Catalog::finding( 'int_hidden_php', $key, $name ); } if ( $rel && false === strpos( $rel, '/' ) && $is_php && ! in_array( strtolower( $name ), array( 'index.php', 'advanced-cache.php', 'object-cache.php', 'db.php', 'db-error.php', 'install.php', 'maintenance.php', 'sunrise.php', 'blog-deleted.php', 'blog-inactive.php', 'blog-suspended.php', 'php-error.php', 'fatal-error-handler.php' ), true ) ) { $findings[] = Catalog::finding( 'int_content_php', $key, size_format( $size ) ); } // Uploads: executables do not belong there. if ( 'uploads' === $row->source && in_array( $ext, self::UPLOAD_EXEC, true ) ) { $findings[] = Catalog::finding( 'int_uploads_exec', $key, size_format( $size ) ); } // Content inspection for everything not verified against an official checksum. $inspect = ! $verified && 'self' !== $row->source && ( $is_php || in_array( $ext, array_merge( self::SCRIPT_EXT, array( 'ico', 'htm', 'html', 'svg', 'cgi', 'pl', 'py', 'sh' ) ), true ) || in_array( strtolower( $name ), array( '.htaccess', '.user.ini', 'php.ini' ), true ) ); if ( $inspect ) { if ( $size > $max ) { // Reported once per scan as a grouped summary (see finalize()). $data['big'] = 1; } else { $content = @file_get_contents( $abs ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $content ) { $findings[] = Catalog::finding( 'int_unreadable', $key, __( 'Read error.', 'siteaudithub' ) ); $status = 'failed'; } else { $findings = array_merge( $findings, self::match_content( $key, $name, $ext, $content ) ); } } } // Baseline comparison for files without an official reference. if ( ! $verified && $hash ) { $base = $this->baseline_hash( $key ); $updated = self::owner_updated( $key, $state ); if ( null !== $base && $base !== $hash ) { if ( false === $updated ) { // Same version as last scan, different contents: edited, not updated. $findings[] = Catalog::finding( 'int_modified', $key, self::owner_label( $key, $state ) ); $data['hold'] = 1; // Keep the old hash so the change is reported until it is fixed. } else { $findings[] = Catalog::finding( 'int_changed', $key, __( 'SHA-256 differs from the previous integrity scan.', 'siteaudithub' ) ); } } elseif ( null === $base && ! empty( $state['has_baseline'] ) && 'root' !== $row->source ) { // Root files have their own check (unknown names are always reported there). // Not there last time: reported in finalize(), grouped per plugin/theme. $data['new'] = 1; if ( true === $updated ) { $data['upd'] = 1; // Explained by an update or a new install. } } } foreach ( $findings as $finding ) { Repository::add_finding( $scan->id, $finding ); } Repository::update_item( $row->id, array( 'status' => $status, 'data' => $data, 'findings_count' => count( $findings ), 'processed_at' => Db::now(), ) ); } /** * Run the signature sets that apply to a file. * * @param string $key Key. * @param string $name File name. * @param string $ext Extension. * @param string $content Content. * @return array Findings. */ public static function match_content( $key, $name, $ext, $content ) { $findings = array(); $lname = strtolower( $name ); $is_conf = in_array( $lname, array( '.htaccess', '.user.ini', 'php.ini' ), true ); $has_php = false !== stripos( $content, ' 'high', 'fp_extra' => 'SAH-H304' ) ); } foreach ( Signatures::htaccess() as $id => $sig ) { if ( preg_match( $sig[0], $content, $m ) ) { // Known security-plugin firewall bootstrap (e.g. Wordfence WAF) is expected. if ( 'SAH-H303' === $id && preg_match( '/wordfence-waf\.php|ninjafirewall|sucuri/i', $m[0] ) ) { continue; } $findings[] = Catalog::finding( 'int_htaccess', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'severity' => $sig[2], 'fp_extra' => $id ) ); } } return $findings; } $php_like = in_array( $ext, self::PHP_EXT, true ) || ( $has_php && in_array( $ext, array( 'ico', 'png', 'jpg', 'gif', 'svg', 'htm', 'html', 'txt' ), true ) ); if ( $php_like ) { if ( ! in_array( $ext, self::PHP_EXT, true ) ) { $findings[] = Catalog::finding( 'int_suspicious', $key, __( 'PHP code inside a non-PHP file type.', 'siteaudithub' ), array( 'severity' => 'high', 'fp_extra' => 'php-in-' . $ext ) ); } foreach ( Signatures::known() as $id => $sig ) { if ( preg_match( $sig[0], $content, $m ) ) { $findings[] = Catalog::finding( 'int_signature', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'fp_extra' => $id ) ); } } // Stand-alone upload forms that skip WordPress' permission checks (uploader shells). if ( strlen( $content ) < 30000 && false !== stripos( $content, 'move_uploaded_file' ) && false !== strpos( $content, '$_FILES' ) && preg_match( '/type\s*=\s*[\'"]?file/i', $content ) && ! preg_match( '/current_user_can|wp_verify_nonce|check_admin_referer|check_ajax_referer|wp_handle_upload|is_user_logged_in/', $content ) ) { $findings[] = Catalog::finding( 'int_uploader', $key, __( 'File upload form with move_uploaded_file() and no WordPress permission or nonce check.', 'siteaudithub' ) ); } foreach ( Signatures::suspicious_php() as $id => $sig ) { if ( preg_match( $sig[0], $content, $m ) ) { $findings[] = Catalog::finding( 'int_suspicious', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'severity' => $sig[2], 'fp_extra' => $id ) ); } } } if ( in_array( $ext, self::SCRIPT_EXT, true ) || in_array( $ext, array( 'htm', 'html', 'svg' ), true ) ) { foreach ( Signatures::suspicious_js() as $id => $sig ) { if ( preg_match( $sig[0], $content, $m ) ) { $findings[] = Catalog::finding( 'int_suspicious', $key, $id . ': ' . $sig[1] . "\n" . self::excerpt( $m[0] ), array( 'severity' => $sig[2], 'fp_extra' => $id ) ); } } } return $findings; } /** * Safe short excerpt of matched code (escaped on output). * * @param string $match Match. * @return string */ private static function excerpt( $match ) { $match = preg_replace( '/[^\x20-\x7E]/', '?', (string) $match ); return strlen( $match ) > 160 ? substr( $match, 0, 157 ) . '...' : $match; } /** * Report files that did not exist during the previous scan. * * A handful of new files in one place is listed file by file (that is how * a dropped backdoor looks). Many new files inside one plugin or theme * usually mean it was installed or updated, so they become one note. * * @param object $scan Scan. */ private function report_new_files( $scan ) { global $wpdb; $items = Db::table( 'items' ); $keys = $wpdb->get_col( $wpdb->prepare( "SELECT item_key FROM %i WHERE scan_id = %d AND item_type = 'file' AND data LIKE %s ORDER BY item_key ASC LIMIT 5000", $items, $scan->id, '%"new":1%' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from Db::table(). if ( ! $keys ) { return; } $updated = array_flip( (array) $wpdb->get_col( $wpdb->prepare( "SELECT item_key FROM %i WHERE scan_id = %d AND item_type = 'file' AND data LIKE %s LIMIT 5000", $items, $scan->id, '%"upd":1%' ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from Db::table(). $groups = array(); foreach ( $keys as $key ) { $rel = self::content_rel( $key ); $parts = explode( '/', $rel ); if ( $rel && in_array( $parts[0], array( 'plugins', 'themes' ), true ) && count( $parts ) > 2 ) { $group = $parts[0] . '/' . $parts[1]; } else { $group = ''; // Single files: always listed one by one. } $groups[ $group ][] = $key; } foreach ( $groups as $group => $list ) { // Files added by an update/new install of their plugin or theme are one note, never a threat. $explained = '' !== $group && count( array_intersect_key( array_flip( $list ), $updated ) ) === count( $list ); if ( '' !== $group && ( count( $list ) > 5 || $explained ) ) { $text = sprintf( /* translators: 1: number of files, 2: folder */ __( '%1$d new code files in wp-content/%2$s since the previous scan:', 'siteaudithub' ), count( $list ), $group ); Repository::add_finding( $scan->id, Catalog::finding( 'int_new_group', 'wp-content/' . $group . '/', $text . "\n" . implode( "\n", array_slice( $list, 0, 30 ) ) ) ); continue; } foreach ( $list as $key ) { self::hold( $scan->id, $key ); $ext = strtolower( pathinfo( $key, PATHINFO_EXTENSION ) ); Repository::add_finding( $scan->id, Catalog::finding( 'int_new_file', $key, __( 'Not present in the previous integrity scan.', 'siteaudithub' ), array( 'severity' => in_array( $ext, self::PHP_EXT, true ) ? 'high' : 'medium' ) ) ); } } } /** * Whether a non-PHP file in the site root is unknown (not shipped by * WordPress and not a common server / SEO / icon file). * * @param string $name File name. * @return bool */ private static function root_file_unknown( $name ) { $lower = strtolower( $name ); return ! in_array( $lower, self::CORE_ROOT_FILES, true ) && ! in_array( $lower, self::ROOT_ALLOWED, true ) && ! preg_match( self::ROOT_ALLOWED_PATTERN, $lower ); } /** * Keep a reported file out of the next baseline refresh. * * @param int $scan_id Scan. * @param string $key File key. */ private static function hold( $scan_id, $key ) { $row = Repository::find_item( $scan_id, $key ); if ( $row ) { $data = Db::decode( $row->data ); $data['hold'] = 1; Repository::update_item( (int) $row->id, array( 'data' => $data ) ); } } /** * Installed versions: WordPress core, every plugin and every theme. * * @return array owner => version */ private static function versions() { global $wp_version; $out = array( 'core' => (string) $wp_version ); foreach ( Paths::plugins() as $file => $data ) { $slug = false !== strpos( $file, '/' ) ? dirname( $file ) : $file; $out[ 'plugins/' . $slug ] = (string) $data['Version']; } foreach ( wp_get_themes() as $slug => $theme ) { $out[ 'themes/' . $slug ] = (string) $theme->get( 'Version' ); } return $out; } /** * Which installed package a file belongs to. * * @param string $key File key. * @return string 'core', 'plugins/{slug}', 'themes/{slug}' or '' (no package). */ private static function owner( $key ) { $rel = self::content_rel( $key ); if ( '' === $rel ) { return 0 === strpos( $key, '[wp-content]/' ) ? '' : 'core'; // Root, wp-admin, wp-includes. } $parts = explode( '/', $rel ); if ( isset( $parts[1] ) && in_array( $parts[0], array( 'plugins', 'themes' ), true ) ) { // Single-file plugins (hello.php) are their own package. return count( $parts ) > 2 || 'plugins' === $parts[0] ? $parts[0] . '/' . $parts[1] : ''; } return ''; } /** * Whether the file's package was updated (or newly installed) since the * previous scan. * * @param string $key File key. * @param array $state Scan state. * @return bool|null true = updated, false = same version, null = unknown (no earlier version data). */ private static function owner_updated( $key, array $state ) { if ( empty( $state['prev_versions'] ) || empty( $state['versions'] ) ) { return null; } $owner = self::owner( $key ); if ( '' === $owner ) { return false; // Loose files (uploads, wp-content root, mu-plugins…) are never "updated". } if ( ! isset( $state['prev_versions'][ $owner ] ) ) { return true; // Newly installed since the previous scan. } return ! isset( $state['versions'][ $owner ] ) || $state['versions'][ $owner ] !== $state['prev_versions'][ $owner ]; } /** * Evidence text for a file changed without an update. * * @param string $key File key. * @param array $state Scan state. * @return string */ private static function owner_label( $key, array $state ) { $owner = self::owner( $key ); if ( '' !== $owner && isset( $state['versions'][ $owner ] ) ) { /* translators: 1: plugin/theme folder or "core", 2: version */ return sprintf( __( 'Changed since the previous scan while %1$s stayed at version %2$s.', 'siteaudithub' ), 'core' === $owner ? 'WordPress' : $owner, $state['versions'][ $owner ] ); } return __( 'Changed since the previous scan, and no update explains it.', 'siteaudithub' ); } /** * Whether a previous complete scan left a baseline. * * @return bool */ private static function has_baseline() { global $wpdb; $table = Db::table( 'baseline' ); return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i LIMIT 1', $table ) ); } /** * Stored hash of a file from the previous complete scan. * * @param string $key Key. * @return string|null */ private function baseline_hash( $key ) { global $wpdb; $table = Db::table( 'baseline' ); $hash = $wpdb->get_var( $wpdb->prepare( 'SELECT file_hash FROM %i WHERE path_hash = %s', $table, sha1( $key ) ) ); return null === $hash ? null : (string) $hash; } /** * Database checks: injected scripts/iframes in content, code in options, * recently created administrators. Read-only and bounded. * * @param object $scan Scan. */ private function database( $scan ) { global $wpdb; $id = (int) $scan->id; $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $decod = '/\b(?:eval|atob|unescape|String\.fromCharCode|document\.write)\s*\(/i'; // 1. Posts and pages. $posts = $wpdb->get_results( "SELECT ID, post_title, post_type, post_content FROM {$wpdb->posts} WHERE post_type NOT IN ('revision','customize_changeset','oembed_cache','user_request','wp_global_styles') AND post_status IN ('publish','private','draft','pending','future') AND ( post_content LIKE '%ID; $label = sprintf( '%s #%d "%s"', $post->post_type, $post->ID, wp_html_excerpt( $post->post_title, 60, '…' ) ); $content = (string) $post->post_content; if ( preg_match_all( '#]*>([\s\S]*?)#i', $content, $scripts, PREG_SET_ORDER ) ) { foreach ( $scripts as $sc ) { if ( preg_match( $decod, $sc[1] ) ) { Repository::add_finding( $id, Catalog::finding( 'int_db_script', $key, $label . "\n" . self::excerpt( $sc[0] ) ) ); break; } } } if ( preg_match_all( '#]*\bsrc\s*=\s*["\']?((?:https?:)?//[^"\'\s>]+)#i', $content, $srcs ) ) { $external = array(); foreach ( $srcs[1] as $src ) { $src_host = strtolower( (string) wp_parse_url( 0 === strpos( $src, '//' ) ? 'https:' . $src : $src, PHP_URL_HOST ) ); if ( $src_host && $src_host !== $host && 'www.' . $src_host !== $host && $src_host !== 'www.' . $host ) { $external[] = $src; } } if ( $external ) { Repository::add_finding( $id, Catalog::finding( 'int_db_external_script', $key, $label . "\n" . implode( "\n", array_slice( array_unique( $external ), 0, 10 ) ) ) ); } } if ( preg_match( '#]*(?:\b(?:width|height)\s*=\s*["\']?[01](?:px)?["\'\s>]|display\s*:\s*none|visibility\s*:\s*hidden)[^>]*>#i', $content, $m ) ) { Repository::add_finding( $id, Catalog::finding( 'int_db_iframe', $key, $label . "\n" . self::excerpt( $m[0] ) ) ); } } // 2. Options (skipping transients and SiteAuditHub's own data). $options = $wpdb->get_results( $wpdb->prepare( 'SELECT option_name, option_value FROM %i WHERE ( option_value LIKE %s OR option_value LIKE %s OR option_value LIKE %s ) AND option_name NOT LIKE %s AND option_name NOT LIKE %s AND option_name NOT LIKE %s LIMIT 100', $wpdb->options, '%esc_like( '_transient' ) . '%', $wpdb->esc_like( '_site_transient' ) . '%', $wpdb->esc_like( 'siteaudithub_' ) . '%' ) ); foreach ( (array) $options as $opt ) { $value = (string) $opt->option_value; $bad = ( preg_match( '/\beval\s*\(/i', $value ) && preg_match( '/base64_decode|gzinflate|str_rot13|\$_(?:GET|POST|REQUEST|COOKIE)/i', $value ) ) || ( preg_match( '#]*>([\s\S]*?)#i', $value, $sc ) && preg_match( $decod, $sc[1] ) ); if ( $bad ) { Repository::add_finding( $id, Catalog::finding( 'int_db_option', 'option:' . $opt->option_name, self::excerpt( $value ) ) ); } elseif ( false !== stripos( $value, 'option_name, self::excerpt( $value ), array( 'severity' => 'info', 'class' => 'manual', 'title' => __( 'Script stored in a setting', 'siteaudithub' ) ) ) ); } } // 3. Administrators created in the last 7 days. $admins = get_users( array( 'role' => 'administrator', 'date_query' => array( array( 'after' => '7 days ago' ) ), 'number' => 50, 'fields' => array( 'user_login', 'user_registered' ), ) ); foreach ( $admins as $admin ) { /* translators: 1: username, 2: date */ Repository::add_finding( $id, Catalog::finding( 'int_db_new_admin', 'user:' . $admin->user_login, sprintf( __( '%1$s, registered %2$s UTC', 'siteaudithub' ), $admin->user_login, $admin->user_registered ) ) ); } $this->go( $scan, 'finalize', $scan->state ); } /** * Finalize: missing core files, baseline refresh (chunked), summary. * * @param object $scan Scan. * @param float $deadline Deadline. * @return bool */ private function finalize( $scan, $deadline ) { global $wpdb; $state = $scan->state; $items = Db::table( 'items' ); $base = Db::table( 'baseline' ); if ( empty( $state['missing_done'] ) ) { if ( ! empty( $state['core_checksums'] ) ) { $missing = array(); foreach ( self::core_checksums() as $path => $md5 ) { if ( 0 === strpos( $path, 'wp-content/' ) ) { continue; // Bundled themes/plugins may be removed legitimately. } if ( ! file_exists( Paths::root() . $path ) ) { $missing[] = $path; } } foreach ( array_slice( $missing, 0, 200 ) as $path ) { Repository::add_finding( $scan->id, Catalog::finding( 'int_core_missing', $path ) ); } } if ( empty( $state['core_checksums'] ) ) { $reason = empty( $scan->config['integrity_wporg_checksums'] ) ? __( 'Verification against WordPress.org checksums is disabled in settings.', 'siteaudithub' ) : ( isset( $state['core_error'] ) ? $state['core_error'] : '' ); Repository::add_finding( $scan->id, Catalog::finding( 'int_checksums_unavailable', '', $reason ) ); } $big = $wpdb->get_col( $wpdb->prepare( "SELECT item_key FROM %i WHERE scan_id = %d AND item_type = 'file' AND data LIKE %s ORDER BY item_key ASC LIMIT 500", $items, $scan->id, '%"big":1%' ) ); if ( $big ) { $text = sprintf( /* translators: 1: number of files, 2: size limit */ _n( '%1$d file is larger than the %2$s inspection limit:', '%1$d files are larger than the %2$s inspection limit:', count( $big ), 'siteaudithub' ), count( $big ), size_format( (int) $scan->config['integrity_max_file_kb'] * 1024 ) ); Repository::add_finding( $scan->id, Catalog::finding( 'int_too_large', '', $text . "\n" . implode( "\n", array_slice( $big, 0, 100 ) ) ) ); } $this->report_new_files( $scan ); if ( ! empty( $state['limit_hit'] ) ) { /* translators: %d: limit */ Repository::add_finding( $scan->id, Catalog::finding( 'int_limit_reached', '', sprintf( __( 'Limit: %d files.', 'siteaudithub' ), (int) $scan->config['integrity_max_files'] ) ) ); } $state['missing_done'] = 1; $state['base_cursor'] = 0; Repository::update_scan( $scan->id, array( 'state' => $state ) ); return true; } // Refresh baseline in chunks (only after a complete inspection). $cursor = (int) $state['base_cursor']; $now = Db::now(); while ( microtime( true ) < $deadline ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, item_key, data FROM %i WHERE scan_id = %d AND item_type = 'file' AND status = 'done' AND id > %d ORDER BY id ASC LIMIT 300", $items, $scan->id, $cursor ) ); if ( ! $rows ) { break; } $values = array(); $seen = array(); $groups = 0; foreach ( $rows as $row ) { $cursor = (int) $row->id; $d = Db::decode( $row->data ); if ( ! empty( $d['hold'] ) ) { // Reported threat: keep the old fingerprint (or none) so it is reported again next time. $seen[] = sha1( $row->item_key ); continue; } if ( ! empty( $d['h'] ) ) { array_push( $values, sha1( $row->item_key ), $row->item_key, $d['h'], (int) $d['s'], $now ); ++$groups; } } if ( $values ) { $placeholders = implode( ', ', array_fill( 0, $groups, '(%s, %s, %s, %d, %s)' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders holds only placeholder groups, one per file. $wpdb->query( $wpdb->prepare( "REPLACE INTO %i (path_hash, rel_path, file_hash, file_size, updated_at) VALUES $placeholders", array_merge( array( $base ), $values ) ) ); } if ( $seen ) { $in = implode( ', ', array_fill( 0, count( $seen ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in holds only %s placeholders, one per path hash. $wpdb->query( $wpdb->prepare( "UPDATE %i SET updated_at = %s WHERE path_hash IN ( $in )", array_merge( array( $base, $now ), $seen ) ) ); } $state['base_cursor'] = $cursor; Repository::update_scan( $scan->id, array( 'state' => $state ) ); } if ( microtime( true ) >= $deadline ) { return true; } // Forget files that no longer exist (not seen in this complete scan). if ( empty( $state['limit_hit'] ) && $scan->started_at ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE updated_at < %s', $base, $scan->started_at ) ); } $counts = Repository::item_status_counts( $scan->id, 'file' ); $verified = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE scan_id = %d AND item_type = 'file' AND data LIKE %s", $items, $scan->id, '%"v":"core"%' ) ); $vplugin = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE scan_id = %d AND item_type = 'file' AND data LIKE %s", $items, $scan->id, '%"v":"plugin"%' ) ); Scanner::complete( $scan, array( 'versions' => isset( $state['versions'] ) ? $state['versions'] : array(), 'files' => array_sum( $counts ), 'files_status' => $counts, 'verified_core' => $verified, 'verified_plugins' => $vplugin, 'core_checksums' => (int) $state['core_checksums'], 'limit_hit' => (int) $state['limit_hit'], 'symlinks' => (int) $state['symlinks'], 'signatures' => Signatures::VERSION, ) ); return false; } }