$this->slug(), 'risk' => 'none', 'summary' => 'Returns the newest entries from the WordPress debug log (wp-content/debug.log ' . 'or the WP_DEBUG_LOG path), with PHP stack traces grouped to their error. Call it first ' . 'when the site errors or behaves oddly. Secrets, absolute paths, emails, and IPs are ' . 'redacted before the content leaves the site (best-effort masking, not a guarantee). ' . 'Filter by level, time, or a literal substring; the substring matches the redacted text. ' . 'Refuses with debug_log_unavailable when no log exists — then ask the site owner to ' . 'enable WP_DEBUG and WP_DEBUG_LOG in wp-config.php. Read-only.', 'annotations' => array( 'readonly' => true, ), // Prefill for the admin Run panel (v1.4); agents may copy it too. 'example_input' => array( 'level' => 'error', 'lines' => 50 ), 'input_schema' => array( // 'null' tolerated: a parameterless REST GET run sends input=null (M6). 'type' => array( 'object', 'null' ), 'properties' => array( 'level' => array( 'type' => 'string', 'enum' => array( 'fatal', 'error', 'warning', 'notice', 'deprecated', 'all' ), 'default' => 'all', 'description' => 'Only entries of this severity (fatal covers PHP fatal and parse errors).', ), 'since' => array( 'type' => 'string', 'description' => 'Only entries at or after this time (ISO 8601 or Unix epoch, UTC).', ), 'grep' => array( 'type' => 'string', 'description' => 'Only entries containing this literal substring (case-insensitive, matched against the redacted text).', ), 'lines' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_LINES, 'default' => self::DEFAULT_LINES, 'description' => 'Maximum entries returned (newest kept).', ), 'max_bytes' => array( 'type' => 'integer', 'minimum' => self::MIN_BYTES, 'maximum' => self::MAX_BYTES, 'default' => self::DEFAULT_BYTES, 'description' => 'How much of the end of the file to scan.', ), ), ), ); } public function dry_run( $atb_input ) { $atb_details = $this->read( $atb_input ); return $this->output( ! isset( $atb_details['refused'] ), true, $atb_details ); } public function run( $atb_input ) { $atb_details = $this->read( $atb_input ); return $this->output( ! isset( $atb_details['refused'] ), false, $atb_details ); } /** * @param array|null $atb_input * @return array details shape per v1.3 plan §3.1 (or a refusal). */ protected function read( $atb_input ) { $atb_path = $this->log_path(); if ( '' === $atb_path || ! is_file( $atb_path ) || ! is_readable( $atb_path ) ) { return array( 'refused' => true, 'reason' => 'debug_log_unavailable', 'message' => 'No readable debug log at the canonical location. Ask the site owner to add ' . "define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); to wp-config.php " . '(do not edit wp-config.php yourself), reproduce the problem, then call again.', ); } $atb_lines = ( is_array( $atb_input ) && isset( $atb_input['lines'] ) ) ? (int) $atb_input['lines'] : self::DEFAULT_LINES; $atb_lines = max( 1, min( self::MAX_LINES, $atb_lines ) ); $atb_max_bytes = ( is_array( $atb_input ) && isset( $atb_input['max_bytes'] ) ) ? (int) $atb_input['max_bytes'] : self::DEFAULT_BYTES; $atb_max_bytes = max( self::MIN_BYTES, min( self::MAX_BYTES, $atb_max_bytes ) ); $atb_level = ( is_array( $atb_input ) && isset( $atb_input['level'] ) ) ? (string) $atb_input['level'] : 'all'; if ( ! in_array( $atb_level, array( 'fatal', 'error', 'warning', 'notice', 'deprecated' ), true ) ) { $atb_level = 'all'; } $atb_grep = ( is_array( $atb_input ) && isset( $atb_input['grep'] ) ) ? (string) $atb_input['grep'] : ''; $atb_since = 0; if ( is_array( $atb_input ) && isset( $atb_input['since'] ) && '' !== (string) $atb_input['since'] ) { $atb_raw_since = (string) $atb_input['since']; $atb_since = is_numeric( $atb_raw_since ) ? (int) $atb_raw_since : (int) strtotime( $atb_raw_since ); $atb_since = max( 0, $atb_since ); } // Bounded tail read: seek from EOF, drop the leading partial line. // The debug log is append-hot; PHP's stat cache can hold a stale // size for it, which would window the tail read wrongly and // mislabel truncated_read — always measure fresh. clearstatcache( true, $atb_path ); $atb_size = (int) filesize( $atb_path ); $atb_offset = max( 0, $atb_size - $atb_max_bytes ); $atb_handle = fopen( $atb_path, 'rb' ); if ( false === $atb_handle ) { return array( 'refused' => true, 'reason' => 'debug_log_unavailable', 'message' => 'The debug log exists but could not be opened for reading.', ); } if ( $atb_offset > 0 ) { fseek( $atb_handle, $atb_offset ); } $atb_buffer = (string) stream_get_contents( $atb_handle, $atb_max_bytes ); fclose( $atb_handle ); $atb_bytes_scanned = strlen( $atb_buffer ); if ( $atb_offset > 0 ) { $atb_nl = strpos( $atb_buffer, "\n" ); $atb_buffer = ( false === $atb_nl ) ? '' : substr( $atb_buffer, $atb_nl + 1 ); } // Group raw lines into entries: a line starting with "[date]" opens // a new entry; anything else (stack traces, wrapped output) // continues the previous one. $atb_entries = array(); $atb_current = null; foreach ( explode( "\n", $atb_buffer ) as $atb_line ) { $atb_line = rtrim( $atb_line, "\r" ); if ( '' === trim( $atb_line ) ) { continue; } $atb_ts = $this->parse_timestamp( $atb_line ); if ( null !== $atb_ts || null === $atb_current ) { if ( null !== $atb_current ) { $atb_entries[] = $atb_current; } $atb_current = array( 'time_gmt' => null === $atb_ts ? null : gmdate( 'Y-m-d H:i:s', $atb_ts ), 'ts' => $atb_ts, 'level' => $this->classify_level( $atb_line ), 'text' => $this->redact_line( $atb_line ), ); } else { $atb_current['text'] .= "\n" . $this->redact_line( $atb_line ); } } if ( null !== $atb_current ) { $atb_entries[] = $atb_current; } // Filters run on the grouped, already-redacted entries; grep on the // redacted text is deliberate (no secret-probing oracle). $atb_matched = array(); foreach ( $atb_entries as $atb_entry ) { if ( 'all' !== $atb_level && $atb_entry['level'] !== $atb_level ) { continue; } if ( $atb_since > 0 && ( null === $atb_entry['ts'] || $atb_entry['ts'] < $atb_since ) ) { continue; } if ( '' !== $atb_grep && false === stripos( $atb_entry['text'], $atb_grep ) ) { continue; } unset( $atb_entry['ts'] ); $atb_matched[] = $atb_entry; } $atb_total_matched = count( $atb_matched ); if ( $atb_total_matched > $atb_lines ) { // Newest entries win — they are at the end of the file. $atb_matched = array_slice( $atb_matched, -$atb_lines ); } return array( 'log_path' => $this->relative_path( $atb_path ), 'file_size' => $atb_size, 'bytes_scanned' => $atb_bytes_scanned, 'truncated_read' => $atb_offset > 0, 'entries' => $atb_matched, 'returned_entries' => count( $atb_matched ), 'matched_entries' => $atb_total_matched, 'level' => $atb_level, 'redaction' => array( 'applied' => true, 'categories' => array( 'filesystem_paths', 'provider_secrets', 'db_credentials', 'url_query_secrets', 'generic_secrets', 'auth_cookies_nonces', 'emails', 'ip_addresses', ), 'note' => 'Redaction is best-effort defence-in-depth, not a guarantee; treat the output as sensitive.', ), ); } /** * Canonical log path. Never caller-supplied: WP_DEBUG_LOG when it is a * custom path, else wp-content/debug.log (also where WP_DEBUG_LOG=true * writes). Overridable for tests. * * @return string Absolute path ('' when none can be derived). */ protected function log_path() { if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ) { return WP_DEBUG_LOG; } if ( defined( 'WP_CONTENT_DIR' ) ) { return WP_CONTENT_DIR . '/debug.log'; } return ABSPATH . 'wp-content/debug.log'; } /** * Parses the "[dd-Mon-YYYY HH:MM:SS UTC]" prefix core's error logger * writes. Returns a Unix timestamp or null. * * @param string $atb_line * @return int|null */ protected function parse_timestamp( $atb_line ) { if ( ! preg_match( '/^\[(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}(?: [A-Za-z\/_+\-0-9]+)?)\]/', $atb_line, $atb_m ) ) { return null; } $atb_ts = strtotime( $atb_m[1] ); return false === $atb_ts ? null : $atb_ts; } /** * Maps a header line onto the level enum. * * @param string $atb_line * @return string fatal|error|warning|notice|deprecated|other */ protected function classify_level( $atb_line ) { if ( false !== stripos( $atb_line, 'PHP Fatal error' ) || false !== stripos( $atb_line, 'PHP Parse error' ) ) { return 'fatal'; } if ( false !== stripos( $atb_line, 'PHP Recoverable fatal' ) || false !== stripos( $atb_line, 'WordPress database error' ) ) { return 'error'; } if ( false !== stripos( $atb_line, 'PHP Warning' ) ) { return 'warning'; } if ( false !== stripos( $atb_line, 'PHP Deprecated' ) ) { return 'deprecated'; } if ( false !== stripos( $atb_line, 'PHP Notice' ) ) { return 'notice'; } return 'other'; } /** * Masks secrets, absolute paths, emails, and IPs in one line. Since * v1.5.2 the implementation lives in ATB_Redactor (shared with the * audit log and the upgrader messages); kept as a method for the * existing tests and subclasses. * * @param string $atb_line * @return string */ protected function redact_line( $atb_line ) { return ATB_Redactor::text( $atb_line ); } /** * Path as reported to the agent: relative to ABSPATH when inside it. * * @param string $atb_path * @return string */ protected function relative_path( $atb_path ) { $atb_abspath = rtrim( ABSPATH, '/\\' ) . '/'; if ( 0 === strpos( $atb_path, $atb_abspath ) ) { return substr( $atb_path, strlen( $atb_abspath ) ); } return basename( $atb_path ); } } }