', ' ', ' Require all denied', ' ', ' ', ' Order allow,deny', ' Deny from all', ' ', '', ); } /** * The nginx rule that does the same, for the server block. * * @return string */ function astsp_errlog_nginx_rule() { return 'location ~* \.log$ { deny all; }'; } /** * What is wrong, from the gathered state. Each finding has a key, a Site * Health status (critical or recommended), a label, why it matters, how to * fix it by hand, and the one-click fix Astute Speed Pro has for it, if any. * * The gathered state ($s) has these keys: * bool $debug WP_DEBUG. * bool $display WP_DEBUG_DISPLAY (only matters with WP_DEBUG). * bool $ini_display PHP's own display_errors. * bool $log_enabled WP_DEBUG_LOG writes somewhere. * bool $log_exists A log file is there (on, or left behind). * int $log_size Its size in bytes. * bool $log_in_web It is inside the website's folder. * bool|null $public The web server handed it over (null: not checked). * string $server apache, nginx or other. * bool $blocked Astute Speed Pro's .htaccess block is in place. * * @param array $s The gathered state, keyed as above. * @return list */ function astsp_errlog_assess( array $s ) { $s = array_merge( array( 'debug' => false, 'display' => true, 'ini_display' => false, 'log_enabled' => false, 'log_exists' => false, 'log_size' => 0, 'log_in_web' => false, 'public' => null, 'server' => 'other', 'blocked' => false, ), $s ); $out = array(); if ( ( $s['debug'] && $s['display'] ) || $s['ini_display'] ) { $out[] = array( 'key' => 'errors_displayed', 'status' => 'critical', 'label' => __( 'PHP errors are shown to visitors', 'astute-speed' ), 'why' => __( 'Any warning a plugin or theme raises is printed into the page, with the server\'s folder names. Visitors see a broken page, search engines index the message, and an attacker learns where the files are.', 'astute-speed' ), 'fix' => "define( 'WP_DEBUG_DISPLAY', false );\n@ini_set( 'display_errors', 0 );", 'action' => null, ); } if ( true === $s['public'] ) { $out[] = array( 'key' => 'log_public', 'status' => 'critical', 'label' => __( 'The debug log can be read by anyone', 'astute-speed' ), 'why' => __( 'Anyone who opens the log\'s address can read every error the site has logged: server paths, plugin names and versions, sometimes database queries. Scanners request wp-content/debug.log on every WordPress site they find.', 'astute-speed' ), 'fix' => 'nginx' === $s['server'] ? astsp_errlog_nginx_rule() : implode( "\n", astsp_errlog_htaccess_lines() ), 'action' => 'apache' === $s['server'] && ! $s['blocked'] ? 'block' : null, ); } elseif ( null === $s['public'] && $s['log_exists'] && $s['log_in_web'] && ! $s['blocked'] ) { $out[] = array( 'key' => 'log_in_web', 'status' => 'recommended', 'label' => __( 'The debug log is inside the website\'s folder', 'astute-speed' ), 'why' => __( 'Whether the web server hands it out could not be checked from here. Blocking .log files costs nothing.', 'astute-speed' ), 'fix' => 'nginx' === $s['server'] ? astsp_errlog_nginx_rule() : implode( "\n", astsp_errlog_htaccess_lines() ), 'action' => 'apache' === $s['server'] ? 'block' : null, ); } if ( $s['debug'] ) { $out[] = array( 'key' => 'debug_on', 'status' => 'recommended', 'label' => __( 'WordPress debug mode is on', 'astute-speed' ), 'why' => __( 'Debug mode is for finding a problem, not for a live site: it reports every notice, which slows pages a little and fills the log.', 'astute-speed' ), 'fix' => "define( 'WP_DEBUG', false );\ndefine( 'WP_DEBUG_LOG', false );\ndefine( 'WP_DEBUG_DISPLAY', false );", 'action' => null, ); } if ( $s['log_exists'] && $s['log_size'] > ASTSP_ERRLOG_LARGE ) { $out[] = array( 'key' => 'log_large', 'status' => 'recommended', 'label' => sprintf( /* translators: %s: file size, e.g. 386 MB */ __( 'The debug log is %s', 'astute-speed' ), astsp_errlog_size( $s['log_size'] ) ), 'why' => __( 'A log this size uses disk space and backup time, and nobody reads it. Most of it repeats the same few warnings.', 'astute-speed' ), 'fix' => __( 'Download it if you want to keep it, then delete it with your host\'s file manager.', 'astute-speed' ), 'action' => 'empty', ); } return $out; } /** * A byte count for people: 386 MB, 12 KB. * * @param int $bytes Bytes. * @return string */ function astsp_errlog_size( $bytes ) { $bytes = max( 0, (int) $bytes ); if ( $bytes >= 1024 * 1024 * 1024 ) { return round( $bytes / ( 1024 * 1024 * 1024 ), 1 ) . ' GB'; } if ( $bytes >= 1024 * 1024 ) { return round( $bytes / ( 1024 * 1024 ) ) . ' MB'; } return max( 0, round( $bytes / 1024 ) ) . ' KB'; } // ── Gathering the state on this site ───────────────────────────────────────── /** * The log file to judge: the one WordPress writes to, or an old * wp-content/debug.log left behind after logging was turned off. * * @return string|null */ function astsp_errlog_file() { // WP_CONTENT_DIR is where WordPress itself puts debug.log (wp_debug_mode()). $path = astsp_errlog_log_path( defined( 'WP_DEBUG_LOG' ) ? WP_DEBUG_LOG : false, WP_CONTENT_DIR ); if ( null === $path ) { $default = WP_CONTENT_DIR . '/debug.log'; $path = file_exists( $default ) ? $default : null; } return $path; } /** * The log's web address, when it sits inside the website at all. * * @param string $path Log path. * @return string|null */ function astsp_errlog_url( $path ) { // WordPress itself writes debug.log to WP_CONTENT_DIR (wp_debug_mode()), and // content_url() is that folder's address; no function returns the folder. $relative = astsp_errlog_relative( $path, WP_CONTENT_DIR ); if ( null !== $relative ) { return content_url( $relative ); } // A log in the WordPress folder itself. network_site_url() is the main // WordPress address, so a subdirectory multisite does not add a subsite path. $relative = astsp_errlog_relative( $path, ABSPATH ); if ( null !== $relative ) { return network_site_url( $relative ); } return null; } /** * A file's path below a folder, starting with "/", or null when it is not inside. Pure. * * @param string $path File path. * @param string $root Folder. * @return string|null */ function astsp_errlog_relative( $path, $root ) { if ( ! astsp_errlog_inside( $path, $root ) ) { return null; } $root = rtrim( astsp_errlog_normalize( $root ), '/' ); return substr( astsp_errlog_normalize( $path ), strlen( $root ) ); } /** * Forward slashes, as wp_normalize_path() gives them (also when it is not loaded). Pure. * * @param string $path Path. * @return string */ function astsp_errlog_normalize( $path ) { return function_exists( 'wp_normalize_path' ) ? wp_normalize_path( (string) $path ) : str_replace( '\\', '/', (string) $path ); } /** * Asks the web server for the log, the way a scanner would. Only the first * kilobyte is requested; the answer is kept for six hours. * * @param string|null $url Log URL. * @param bool $fresh Ignore the kept answer. * @return bool|null Null when it could not be checked. */ function astsp_errlog_probe( $url, $fresh = false ) { if ( null === $url ) { return false; } $kept = get_transient( 'astsp_errlog_public' ); if ( ! $fresh && is_array( $kept ) && ( $kept['url'] ?? '' ) === $url ) { return $kept['public']; } $response = wp_remote_get( add_query_arg( 'astute-speed-check', time(), $url ), array( 'timeout' => 10, // http:// to https:// is normal; a redirect to a page is judged as that page. 'redirection' => 3, 'headers' => array( 'Range' => 'bytes=0-1023' ), ) ); $public = is_wp_error( $response ) ? null : astsp_errlog_looks_public( (int) wp_remote_retrieve_response_code( $response ), (string) wp_remote_retrieve_header( $response, 'content-type' ), substr( (string) wp_remote_retrieve_body( $response ), 0, 1024 ) ); set_transient( 'astsp_errlog_public', array( 'url' => $url, 'public' => $public, ), 6 * HOUR_IN_SECONDS ); return $public; } /** * Apache (or LiteSpeed, which reads .htaccess too), nginx, or something else. * * @return string */ function astsp_errlog_server() { global $is_apache, $is_nginx; if ( ! empty( $is_apache ) ) { return 'apache'; } return ! empty( $is_nginx ) ? 'nginx' : 'other'; } /** * Everything astsp_errlog_assess() needs, read from this site. * * @param bool $probe Ask the web server for the log (otherwise only a kept answer is used). * @param bool $fresh Ignore the kept answer. * @return array */ function astsp_errlog_state( $probe = true, $fresh = false ) { $file = astsp_errlog_file(); $size = null !== $file && is_file( $file ) ? (int) @filesize( $file ) : 0; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable is the same as absent here $url = null !== $file ? astsp_errlog_url( $file ) : null; $public = null; if ( null === $file || ! is_file( $file ) ) { $public = false; } elseif ( $probe ) { $public = astsp_errlog_probe( $url, $fresh ); } else { $kept = get_transient( 'astsp_errlog_public' ); $public = is_array( $kept ) && ( $kept['url'] ?? '' ) === $url ? $kept['public'] : null; } return array( 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'display' => ! defined( 'WP_DEBUG_DISPLAY' ) || false !== WP_DEBUG_DISPLAY, // WP-CLI always prints errors; only what web visitors get counts. 'ini_display' => 'cli' !== PHP_SAPI && in_array( strtolower( (string) ini_get( 'display_errors' ) ), array( '1', 'on', 'stdout', 'stderr' ), true ), 'log_enabled' => null !== astsp_errlog_log_path( defined( 'WP_DEBUG_LOG' ) ? WP_DEBUG_LOG : false, WP_CONTENT_DIR ), 'log_exists' => null !== $file && is_file( $file ), 'log_size' => $size, 'log_in_web' => null !== $url, 'log_url' => $url, 'public' => $public, 'server' => astsp_errlog_server(), 'blocked' => false, ); } // ── Site Health ─────────────────────────────────────────────────────────────── add_filter( 'site_status_tests', static function ( $tests ) { $tests['direct']['astsp_error_logs'] = array( 'label' => __( 'PHP errors and the debug log', 'astute-speed' ), 'test' => 'astsp_errlog_site_health', ); return $tests; } ); /** * One Site Health result: the worst finding, with the others listed. * * @return array */ function astsp_errlog_site_health() { $findings = astsp_errlog_assess( astsp_errlog_state() ); $result = array( 'label' => __( 'PHP errors are hidden and the debug log is private', 'astute-speed' ), 'status' => 'good', 'badge' => array( 'label' => __( 'Security', 'astute-speed' ), 'color' => 'blue', ), 'description' => '

' . esc_html__( 'Visitors do not see PHP errors, and no debug log can be read from the web.', 'astute-speed' ) . '

', 'actions' => '', 'test' => 'astsp_error_logs', ); if ( ! $findings ) { return $result; } $worst = 'recommended'; $items = ''; foreach ( $findings as $f ) { if ( 'critical' === $f['status'] ) { $worst = 'critical'; } $items .= '
  • ' . esc_html( $f['label'] ) . '. ' . esc_html( $f['why'] ) . '
  • '; } $result['label'] = $findings[0]['label']; $result['status'] = $worst; $result['badge']['color'] = 'critical' === $worst ? 'red' : 'orange'; $result['description'] = ''; $result['actions'] = '

    ' . esc_html__( 'See how to fix it', 'astute-speed' ) . '

    '; return $result; } // ── Page Speed screen ───────────────────────────────────────────────────────── /** * The "PHP errors and the debug log" box. Shows nothing extra on a clean site * beyond one line saying so. * * @return void */ function astsp_errlog_render_box() { $fresh = isset( $_POST['astsp_errlog_recheck'], $_POST['astsp_errlog_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['astsp_errlog_nonce'] ) ), 'astsp_errlog_recheck' ); $state = astsp_errlog_state( true, $fresh ); $findings = astsp_errlog_assess( $state ); $pro = false; echo '

    ' . esc_html__( 'PHP errors and the debug log', 'astute-speed' ) . '

    '; if ( ! $findings ) { echo '

    ' . esc_html__( 'Visitors do not see PHP errors, and no debug log can be read from the web.', 'astute-speed' ) . '

    '; } foreach ( $findings as $f ) { $where = in_array( $f['key'], array( 'errors_displayed', 'debug_on' ), true ) ? __( 'Change these lines in wp-config.php, above "That\'s all, stop editing!". Keep a copy of the file first.', 'astute-speed' ) : ( 'log_large' === $f['key'] ? '' : ( 'nginx' === $state['server'] ? __( 'Add this to the site\'s nginx server block, then reload nginx:', 'astute-speed' ) : __( 'Add this to the site\'s .htaccess:', 'astute-speed' ) ) ); $pro = $pro || null !== $f['action']; echo '
    '; echo '

    ' . esc_html( $f['label'] ) . '
    ' . esc_html( $f['why'] ) . '

    '; if ( '' !== $where ) { echo '

    ' . esc_html( $where ) . '

    '; } echo '
    ' . esc_html( $f['fix'] ) . '
    '; echo '
    '; } if ( $pro ) { echo '

    ' . esc_html__( 'Astute Speed Pro blocks the log and empties a large one with a click, keeping a private copy.', 'astute-speed' ) . ' ' . esc_html__( 'About Astute Speed Pro', 'astute-speed' ) . '

    '; } echo '
    '; wp_nonce_field( 'astsp_errlog_recheck', 'astsp_errlog_nonce' ); echo '

    '; echo '
    '; }