*/ private $roots = []; /** @param string $additional_root Optional project root, must already be inside ABSPATH. */ public function __construct( $additional_root = '' ) { $roots = [ ABSPATH ]; if ( defined( 'WP_CONTENT_DIR' ) ) { $roots[] = WP_CONTENT_DIR; } if ( $additional_root ) { $roots[] = $additional_root; } foreach ( $roots as $root ) { $real = realpath( $root ); if ( $real && is_dir( $real ) ) { $this->roots[] = wp_normalize_path( untrailingslashit( $real ) ); } } $this->roots = array_unique( $this->roots ); } /** * @param array $request * @return array */ public function read( array $request ) { $path = isset( $request['path'] ) ? (string) $request['path'] : ''; $mode = isset( $request['mode'] ) ? (string) $request['mode'] : 'head'; $max_lines = isset( $request['max_lines'] ) ? (int) $request['max_lines'] : 200; $grep = isset( $request['grep'] ) ? (string) $request['grep'] : ''; $max_lines = max( 1, min( 500, $max_lines ) ); $resolved = $this->resolve_path( $path ); if ( is_wp_error( $resolved ) ) { return [ 'path' => $path, 'status' => 'blocked', 'message' => $resolved->get_error_message(), 'content' => '', ]; } if ( WTeam_Executor_File_Security_Policy::is_hard_blocked_for_read( $resolved ) ) { return [ 'path' => $this->relative_path( $resolved ), 'status' => 'blocked', 'message' => __( 'This file type cannot be sent for security reasons (secret, environment file, database, or binary file).', 'wteam-executor' ), 'content' => '', ]; } if ( ! WTeam_Executor_File_Security_Policy::is_readable_text_candidate( $resolved ) ) { return [ 'path' => $this->relative_path( $resolved ), 'status' => 'blocked', 'message' => __( 'Only text-based configuration, log, and source files can be read.', 'wteam-executor' ), 'content' => '', ]; } $basename = strtolower( basename( $resolved ) ); if ( 'wp-config.php' === $basename || 'wp-config-sample.php' === $basename ) { if ( 'config' !== $mode ) { return [ 'path' => $this->relative_path( $resolved ), 'status' => 'blocked', 'message' => __( 'wp-config.php files can be sent only through safe config mode.', 'wteam-executor' ), 'content' => '', ]; } return $this->read_safe_config_profile( $resolved ); } if ( 'config' === $mode ) { return $this->read_safe_config_profile( $resolved ); } $size = @filesize( $resolved ); if ( false === $size ) { return [ 'path' => $this->relative_path( $resolved ), 'status' => 'unavailable', 'message' => __( 'The file size could not be read.', 'wteam-executor' ), 'content' => '', ]; } if ( $size > self::MAX_FILE_BYTES && 'tail' !== $mode ) { return [ 'path' => $this->relative_path( $resolved ), 'status' => 'too_large', 'message' => __( 'The file is too large. For logs, request tail mode with up to 500 lines.', 'wteam-executor' ), 'content' => '', ]; } if ( 'tail' === $mode ) { $content = $this->tail_lines( $resolved, $max_lines ); } elseif ( 'grep' === $mode ) { $content = $this->grep_lines( $resolved, $grep, $max_lines ); } elseif ( 'lines' === $mode || 'tail_lines' === $mode ) { $content = $this->numbered_lines( $resolved, $mode, isset( $request['start_line'] ) ? (int) $request['start_line'] : 1, $max_lines ); } else { $content = $this->head_lines( $resolved, $max_lines ); } if ( is_wp_error( $content ) ) { return [ 'path' => $this->relative_path( $resolved ), 'status' => 'unavailable', 'message' => $content->get_error_message(), 'content' => '', ]; } if ( is_array( $content ) ) { $content['content'] = $this->limit_text( $this->redact( isset( $content['content'] ) ? $content['content'] : '' ) ); $content['path'] = $this->relative_path( $resolved ); return $content; } $safe_content = $this->redact( $content ); $safe_content = $this->limit_text( $safe_content ); return [ 'path' => $this->relative_path( $resolved ), 'status' => 'ok', 'message' => '', 'content' => $safe_content, ]; } /** * @param string $path * @return string|WP_Error */ private function resolve_path( $path ) { $path = trim( str_replace( '\\', '/', $path ) ); if ( '' === $path ) { return new WP_Error( 'empty_path', __( 'The requested path is empty.', 'wteam-executor' ) ); } if ( false !== strpos( $path, "\0" ) || false !== strpos( $path, '../' ) || false !== strpos( $path, '..\\' ) || 0 === strpos( $path, '/' ) || preg_match( '#^[A-Za-z]:/#', $path ) ) { return new WP_Error( 'unsafe_path', __( 'The file path must use a portable path inside the WordPress installation.', 'wteam-executor' ) ); } if ( 0 === strpos( $path, 'ABSPATH/' ) ) { $path = substr( $path, strlen( 'ABSPATH/' ) ); } if ( 'wp-content' === $path || 0 === strpos( $path, 'wp-content/' ) ) { return new WP_Error( 'unsupported_path_token', __( 'Use WP_CONTENT_DIR for WordPress content paths.', 'wteam-executor' ) ); } $content_token = WTeam_Executor_File_Security_Policy::normalize_content_root_token( $path ); if ( '' !== $content_token && defined( 'WP_CONTENT_DIR' ) ) { $candidate = WTeam_Executor_File_Security_Policy::resolve_content_path( $content_token ); if ( $candidate && is_file( $candidate ) && $this->is_inside_allowed_root( $candidate ) ) { return wp_normalize_path( $candidate ); } return new WP_Error( 'file_not_found', __( 'The requested file was not found in WP_CONTENT_DIR.', 'wteam-executor' ) ); } foreach ( $this->roots as $root ) { $candidate = realpath( $root . '/' . ltrim( $path, '/' ) ); if ( $candidate && is_file( $candidate ) && $this->is_inside_allowed_root( $candidate ) ) { return wp_normalize_path( $candidate ); } } return new WP_Error( 'file_not_found', __( 'The requested file was not found in the allowed WordPress directories.', 'wteam-executor' ) ); } /** * @param string $path * @return bool */ private function is_inside_allowed_root( $path ) { $path = wp_normalize_path( $path ); foreach ( $this->roots as $root ) { if ( 0 === strpos( $path, trailingslashit( $root ) ) || $path === $root ) { return true; } } return false; } /** * @param string $path * @param int $max_lines * @return string|WP_Error */ private function head_lines( $path, $max_lines ) { $handle = @fopen( $path, 'rb' ); if ( ! $handle ) { return new WP_Error( 'file_open_failed', __( 'The file could not be opened for reading.', 'wteam-executor' ) ); } $lines = []; $count = 0; while ( ! feof( $handle ) && $count < $max_lines ) { $line = fgets( $handle ); if ( false === $line ) { break; } $lines[] = $this->sanitize_line( $line ); ++$count; } fclose( $handle ); return implode( "\n", $lines ); } /** * @param string $path * @param int $max_lines * @return string|WP_Error */ private function tail_lines( $path, $max_lines ) { $handle = @fopen( $path, 'rb' ); if ( ! $handle ) { return new WP_Error( 'file_open_failed', __( 'The file could not be opened for reading.', 'wteam-executor' ) ); } fseek( $handle, 0, SEEK_END ); $position = ftell( $handle ); $buffer = ''; $newlines = 0; while ( $position > 0 && $newlines <= $max_lines ) { $chunk_size = min( 8192, $position ); $position -= $chunk_size; fseek( $handle, $position, SEEK_SET ); $chunk = fread( $handle, $chunk_size ); if ( false === $chunk ) { fclose( $handle ); return new WP_Error( 'file_read_failed', __( 'The end of the file could not be read.', 'wteam-executor' ) ); } $buffer = $chunk . $buffer; $newlines = substr_count( $buffer, "\n" ); if ( strlen( $buffer ) > self::MAX_TEXT_CHARS * 2 ) { break; } } fclose( $handle ); $lines = preg_split( '/\r\n|\r|\n/', $buffer ); $lines = array_slice( $lines, -$max_lines ); $lines = array_map( [ $this, 'sanitize_line' ], $lines ); return implode( "\n", $lines ); } /** @return array|WP_Error */ private function numbered_lines( $path, $mode, $start_line, $max_lines ) { $content = @file_get_contents( $path ); if ( ! is_string( $content ) ) { return new WP_Error( 'file_read_failed', __( 'The file could not be read for numbered context.', 'wteam-executor' ) ); } $lines = preg_split( '/\r\n|\r|\n/', $content ); $lines = is_array( $lines ) ? $lines : []; $total = count( $lines ); if ( 'tail_lines' === $mode ) { $start_line = max( 1, $total - $max_lines + 1 ); } else { $start_line = max( 1, (int) $start_line ); } if ( $start_line > $total && $total > 0 ) { return new WP_Error( 'file_context_range', __( 'The requested start_line is outside the file.', 'wteam-executor' ) ); } $slice = array_slice( $lines, $start_line - 1, $max_lines ); $numbered = []; foreach ( $slice as $index => $line ) { $numbered[] = sprintf( '%d | %s', $start_line + $index, $this->sanitize_line( $line ) ); } return [ 'status' => 'ok', 'message' => '', 'start_line' => $start_line, 'end_line' => $start_line + max( 0, count( $slice ) - 1 ), 'total_lines' => $total, 'sha256' => hash( 'sha256', $content ), 'content' => implode( "\n", $numbered ), ]; } /** * @param string $path * @param string $needle * @param int $max_lines * @return string|WP_Error */ private function grep_lines( $path, $needle, $max_lines ) { $needle = trim( $needle ); if ( '' === $needle ) { return new WP_Error( 'missing_grep', __( 'Grep mode requires a search string.', 'wteam-executor' ) ); } $handle = @fopen( $path, 'rb' ); if ( ! $handle ) { return new WP_Error( 'file_open_failed', __( 'The file could not be opened for reading.', 'wteam-executor' ) ); } $matches = []; $line_no = 0; while ( ! feof( $handle ) && count( $matches ) < $max_lines ) { $line = fgets( $handle ); ++$line_no; if ( false === $line ) { break; } if ( false !== stripos( $line, $needle ) ) { $matches[] = sprintf( '%d: %s', $line_no, $this->sanitize_line( $line ) ); } } fclose( $handle ); return $matches ? implode( "\n", $matches ) : '[No matches were found for the requested search string.]'; } /** * @param string $path * @return array */ private function read_safe_config_profile( $path ) { $basename = strtolower( basename( $path ) ); if ( 'wp-config.php' !== $basename && 'wp-config-sample.php' !== $basename ) { return [ 'path' => $this->relative_path( $path ), 'status' => 'blocked', 'message' => __( 'Config mode can be used only with wp-config.php or wp-config-sample.php.', 'wteam-executor' ), 'content' => '', ]; } $allowed_constants = WTeam_Executor_Config_Policy::readable_constants(); $handle = @fopen( $path, 'rb' ); if ( ! $handle ) { return [ 'path' => $this->relative_path( $path ), 'status' => 'unavailable', 'message' => __( 'The configuration file could not be opened.', 'wteam-executor' ), 'content' => '', ]; } $lines = []; while ( ! feof( $handle ) ) { $line = fgets( $handle ); if ( false === $line ) { break; } foreach ( $allowed_constants as $constant ) { if ( false !== strpos( $line, "'" . $constant . "'" ) || false !== strpos( $line, '"' . $constant . '"' ) ) { $lines[] = $this->redact( $this->sanitize_line( $line ) ); break; } } } fclose( $handle ); return [ 'path' => $this->relative_path( $path ), 'status' => 'ok', 'message' => __( 'Safe configuration profile: WordPress debugging and environment flags only.', 'wteam-executor' ), 'content' => $lines ? implode( "\n", $lines ) : '[The selected debugging constants were not found.]', ]; } /** * @param string $line * @return string */ private function sanitize_line( $line ) { $line = str_replace( "\0", '[NUL]', $line ); $line = preg_replace( '/[^\P{C}\t\r\n]/u', '', $line ); return substr( (string) $line, 0, self::MAX_LINE_LENGTH ); } /** * @param string $text * @return string */ public function redact( $text ) { $patterns = [ '/(define\s*\(\s*[\'\"](?:DB_(?:NAME|USER|PASSWORD|HOST)|AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|NONCE_KEY|AUTH_SALT|SECURE_AUTH_SALT|LOGGED_IN_SALT|NONCE_SALT|WTEAM_EXECUTOR_OPENAI_API_KEY)[\'\"]\s*,\s*)[\'\"][^\'\"]*[\'\"](\s*\))/i', '/((?:[\'\"]?(?:api[_-]?key|password|passwd|secret|token|authorization|bearer|client_secret|access_key)[\'\"]?\s*(?:=>|=|:)\s*)[\'\"]?)[^\s,;\}\]\'\"\r\n]+/i', '/(Authorization\s*:\s*Bearer\s+)[^\s\r\n]+/i', '/\bsk-(?:proj-)?[A-Za-z0-9_-]{10,}\b/', '/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----/si', ]; $replacements = [ '$1\'***REDACTED***\'$2', '$1***REDACTED***', '$1***REDACTED***', '***OPENAI_KEY_REDACTED***', '[PRIVATE KEY REDACTED]' ]; return preg_replace( $patterns, $replacements, $text ); } /** * @param string $text * @return string */ private function limit_text( $text ) { if ( strlen( $text ) <= self::MAX_TEXT_CHARS ) { return $text; } return substr( $text, 0, self::MAX_TEXT_CHARS ) . "\n[TRUNCATED: content limit reached]"; } /** * @param string $absolute_path * @return string */ private function relative_path( $absolute_path ) { return WTeam_Executor_File_Security_Policy::to_portable_path( $absolute_path ); }} // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fclose