diff --git a/inc/agent-api.php b/inc/agent-api.php index b825966..6b4707c 100644 --- a/inc/agent-api.php +++ b/inc/agent-api.php @@ -19,7 +19,7 @@ class TI_Parrot_Agent_API { const REST_NAMESPACE = 'pirate-parrot/v1'; - const SCHEMA_VERSION = '1.0'; + const SCHEMA_VERSION = '1.1'; // hard cap for a single section payload, in bytes const MAX_SECTION_BYTES = 262144; @@ -45,12 +45,15 @@ function register_routes() { } $routes = array( - '/manifest' => 'get_manifest', - '/site' => 'get_site', - '/products' => 'get_products_index', - '/products/(?P[a-z0-9_-]+)' => 'get_product', - '/logs' => 'get_logs', - '/crashes' => 'get_crashes', + '/manifest' => 'get_manifest', + '/site' => 'get_site', + '/products' => 'get_products_index', + '/products/(?P[a-z0-9_-]+)' => 'get_product', + '/logs' => 'get_logs', + '/crashes' => 'get_crashes', + '/integrity' => 'get_integrity_index', + '/integrity/(?P[a-z0-9_.-]+)' => 'get_integrity_product', + '/integrity/(?P[a-z0-9_.-]+)/file' => 'get_integrity_file', ); foreach ( $routes as $route => $callback ) { register_rest_route( @@ -118,6 +121,12 @@ function get_manifest( $request ) { 'label' => __( 'Product crash reports', 'pirate-parrot' ), 'route' => '/crashes', ), + array( + 'slug' => 'integrity', + 'label' => __( 'Product integrity', 'pirate-parrot' ), + 'route' => '/integrity', + 'products' => wp_list_pluck( TI_Parrot_Integrity::detect(), 'slug' ), + ), ); foreach ( $this->get_product_sections() as $slug => $label ) { $sections[] = array( @@ -347,6 +356,37 @@ function get_crashes( $request ) { return $this->respond( array( 'products' => $products ) ); } + function get_integrity_index( $request ) { + return $this->respond( TI_Parrot_Integrity::index() ); + } + + function get_integrity_product( $request ) { + $product = TI_Parrot_Integrity::find( $request['slug'], (string) $request->get_param( 'type' ) ); + if ( null === $product ) { + return new WP_Error( 'pp_unknown_product', __( 'No ThemeIsle product with this slug is installed.', 'pirate-parrot' ), array( 'status' => 404 ) ); + } + + return $this->respond( TI_Parrot_Integrity::check( $product ) ); + } + + function get_integrity_file( $request ) { + $product = TI_Parrot_Integrity::find( $request['slug'], (string) $request->get_param( 'type' ) ); + if ( null === $product ) { + return new WP_Error( 'pp_unknown_product', __( 'No ThemeIsle product with this slug is installed.', 'pirate-parrot' ), array( 'status' => 404 ) ); + } + $result = TI_Parrot_Integrity::read_chunk( + $product, + $request->get_param( 'path' ), + (int) $request->get_param( 'offset' ), + (int) $request->get_param( 'length' ) + ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return $this->respond( $result ); + } + function respond( $data ) { $data = $this->redact( $data ); $encoded = wp_json_encode( $data ); diff --git a/inc/integrity.php b/inc/integrity.php new file mode 100644 index 0000000..af6b3ba --- /dev/null +++ b/inc/integrity.php @@ -0,0 +1,835 @@ + 5 && ( $max_execution - 5 ) < $time_budget ) { + $time_budget = $max_execution - 5; + } + $limits = array( + 'max_file_bytes' => self::MAX_FILE_BYTES, + 'max_total_bytes' => self::MAX_TOTAL_BYTES, + 'time_budget' => $time_budget, + 'max_files' => self::MAX_FILES, + 'max_list_items' => self::MAX_LIST_ITEMS, + 'max_chunk_bytes' => self::MAX_CHUNK_BYTES, + 'file_max_bytes' => self::FILE_MAX_BYTES, + ); + + return apply_filters( 'pirate_parrot_integrity_limits', $limits ); + } + + /** + * Directory / file names never reported as "added" and never recursed. + * + * @return array + */ + public static function ignored_names() { + return array( '.git', '.svn', '.hg', 'node_modules', '.DS_Store', 'Thumbs.db' ); + } + + /** + * All detected ThemeIsle products, each an array with keys: + * slug, type (plugin|theme), version, active, path (relative, safe to + * emit), wordpress_available and dir (absolute; internal, stripped + * before output). + * + * @return array List of product arrays. + */ + public static function detect() { + $products = array(); + $seen = array(); + + self::detect_plugins( $products, $seen ); + self::detect_themes( $products, $seen ); + self::detect_registered( $products, $seen ); + + $products = apply_filters( 'pirate_parrot_integrity_products', $products ); + + $valid = array(); + if ( is_array( $products ) ) { + foreach ( $products as $product ) { + if ( ! is_array( $product ) || empty( $product['slug'] ) || empty( $product['type'] ) || empty( $product['dir'] ) ) { + continue; + } + if ( ! is_dir( $product['dir'] ) ) { + continue; + } + $valid[] = $product; + } + } + + usort( $valid, array( 'TI_Parrot_Integrity', 'compare_products' ) ); + + return $valid; + } + + /** + * usort callback: order by type then slug. + */ + public static function compare_products( $a, $b ) { + if ( $a['type'] !== $b['type'] ) { + return strcmp( $a['type'], $b['type'] ); + } + + return strcmp( $a['slug'], $b['slug'] ); + } + + /** + * Whether the directory bundles the ThemeIsle SDK. + */ + public static function has_sdk( $dir ) { + return is_dir( $dir . '/' . self::SDK_DIR ); + } + + /** + * The "WordPress Available" file header of a product basefile. + */ + public static function is_wordpress_available( $basefile ) { + if ( ! is_file( $basefile ) ) { + return false; + } + $headers = get_file_data( $basefile, array( 'WordPress Available' => 'WordPress Available' ) ); + + return isset( $headers['WordPress Available'] ) && 'yes' === strtolower( trim( $headers['WordPress Available'] ) ); + } + + /** + * Register one detected product, deduplicating by resolved directory. + */ + private static function add_product( &$products, &$seen, $product ) { + $real = realpath( $product['dir'] ); + if ( false === $real ) { + return; + } + if ( isset( $seen[ $real ] ) ) { + return; + } + $seen[ $real ] = true; + $products[] = $product; + } + + private static function detect_plugins( &$products, &$seen ) { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $plugins = get_plugins(); + foreach ( $plugins as $file => $data ) { + $rel_dir = dirname( $file ); + if ( '.' === $rel_dir || '' === $rel_dir ) { + // single-file plugins cannot bundle the SDK + continue; + } + $dir = WP_PLUGIN_DIR . '/' . $rel_dir; + if ( ! self::has_sdk( $dir ) ) { + continue; + } + self::add_product( + $products, + $seen, + array( + 'slug' => basename( $rel_dir ), + 'type' => 'plugin', + 'version' => isset( $data['Version'] ) ? (string) $data['Version'] : '', + 'active' => is_plugin_active( $file ), + 'path' => $file, + 'wordpress_available' => self::is_wordpress_available( WP_PLUGIN_DIR . '/' . $file ), + 'dir' => $dir, + ) + ); + } + } + + private static function detect_themes( &$products, &$seen ) { + if ( ! function_exists( 'wp_get_themes' ) ) { + return; + } + $themes = wp_get_themes(); + foreach ( $themes as $stylesheet => $theme ) { + $dir = $theme->get_stylesheet_directory(); + if ( ! self::has_sdk( $dir ) ) { + continue; + } + // parent counts as active while its child runs + $active = ( get_stylesheet() === $stylesheet || get_template() === $stylesheet ); + self::add_product( + $products, + $seen, + array( + 'slug' => basename( $dir ), + 'type' => 'theme', + 'version' => (string) $theme->get( 'Version' ), + 'active' => $active, + 'path' => $stylesheet . '/style.css', + 'wordpress_available' => self::is_wordpress_available( $dir . '/style.css' ), + 'dir' => $dir, + ) + ); + } + } + + /** + * Products registered with the SDK at runtime — authoritative, and the + * only way to reach mu-plugins or custom locations. These are active by + * definition (their code ran to register the filter). + */ + private static function detect_registered( &$products, &$seen ) { + $basefiles = apply_filters( 'themeisle_sdk_products', array() ); + if ( ! is_array( $basefiles ) ) { + return; + } + foreach ( $basefiles as $basefile ) { + if ( ! is_string( $basefile ) || '' === $basefile || ! is_file( $basefile ) ) { + continue; + } + $dir = dirname( $basefile ); + $type = ( 'style.css' === basename( $basefile ) ) ? 'theme' : 'plugin'; + $data = get_file_data( $basefile, array( 'Version' => 'Version' ) ); + self::add_product( + $products, + $seen, + array( + 'slug' => basename( $dir ), + 'type' => $type, + 'version' => isset( $data['Version'] ) ? (string) $data['Version'] : '', + 'active' => true, + 'path' => basename( $dir ) . '/' . basename( $basefile ), + 'wordpress_available' => self::is_wordpress_available( $basefile ), + 'dir' => $dir, + ) + ); + } + } + + /** + * Find a detected product by slug (and optionally type, for the rare + * slug shared between a plugin and a theme). + * + * @return array|null + */ + public static function find( $slug, $type = '' ) { + $slug = strtolower( (string) $slug ); + $type = (string) $type; + foreach ( self::detect() as $product ) { + if ( strtolower( $product['slug'] ) !== $slug ) { + continue; + } + if ( '' !== $type && $product['type'] !== $type ) { + continue; + } + + return $product; + } + + return null; + } + + /** + * Public shape of a product entry: internal dir stripped, route added. + */ + public static function public_product( $product ) { + $route = '/integrity/' . $product['slug']; + + return array( + 'slug' => $product['slug'], + 'type' => $product['type'], + 'version' => $product['version'], + 'active' => $product['active'], + 'path' => $product['path'], + 'wordpress_available' => $product['wordpress_available'], + 'route' => $route, + ); + } + + /** + * /integrity payload. + */ + public static function index() { + $out = array(); + foreach ( self::detect() as $product ) { + $out[] = self::public_product( $product ); + } + + return array( 'products' => $out ); + } + + /** + * Reject anything that is not a plain relative path inside the product: + * no NUL/control chars, no backslashes, no absolute paths, no drive + * letters, no empty/./.. segments. Rejects rather than normalizes, and + * is applied to requested paths AND manifest paths (the manifest is + * remote data). + */ + public static function is_safe_relative_path( $path ) { + if ( ! is_string( $path ) || '' === $path || strlen( $path ) > self::MAX_PATH_LENGTH ) { + return false; + } + if ( preg_match( '/[\x00-\x1F\x7F]/', $path ) ) { + return false; + } + if ( false !== strpos( $path, '\\' ) ) { + return false; + } + if ( '/' === $path[0] ) { + return false; + } + if ( preg_match( '/^[A-Za-z]:/', $path ) ) { + return false; + } + $segments = explode( '/', $path ); + foreach ( $segments as $segment ) { + if ( '' === $segment || '.' === $segment || '..' === $segment ) { + return false; + } + } + + return true; + } + + /** + * Manifest fetch. Source order is decided by the product's + * "WordPress Available" header: wp.org first for free plugins, our API + * first for everything else. Themes only have our API (wp.org publishes + * no theme checksums). A 404 falls through to the other source; network + * or decode failures do not. + * + * @return array {status, source, url, files, error} + */ + public static function fetch_manifest( $product ) { + $result = array( + 'status' => 'no_manifest', + 'source' => '', + 'url' => '', + 'files' => array(), + 'error' => '', + ); + + $version = (string) $product['version']; + if ( '' === $version || ! preg_match( '/^[A-Za-z0-9._-]+$/', $version ) ) { + return $result; + } + + $base = apply_filters( 'pirate_parrot_checksum_api', self::THEMEISLE_CHECKSUM_BASE ); + $ours = array( + 'source' => 'themeisle', + 'url' => $base . rawurlencode( $product['slug'] ) . '/' . rawurlencode( $version ) . '.json', + ); + + $candidates = array( $ours ); + if ( 'plugin' === $product['type'] ) { + $wporg = array( + 'source' => 'wporg', + 'url' => self::WPORG_CHECKSUM_BASE . rawurlencode( $product['slug'] ) . '/' . rawurlencode( $version ) . '.json', + ); + if ( ! empty( $product['wordpress_available'] ) ) { + $candidates = array( $wporg, $ours ); + } else { + $candidates = array( $ours, $wporg ); + } + } + + foreach ( $candidates as $candidate ) { + $fetched = self::fetch_manifest_url( $candidate['url'] ); + if ( 'not_found' === $fetched['status'] ) { + continue; + } + $result['source'] = $candidate['source']; + $result['url'] = $candidate['url']; + if ( 'ok' === $fetched['status'] ) { + $result['status'] = 'ok'; + $result['files'] = $fetched['files']; + } else { + $result['status'] = 'error'; + $result['error'] = $fetched['error']; + } + + return $result; + } + + return $result; + } + + /** + * One manifest URL fetch. + * + * @return array {status: ok|not_found|error, files, error} + */ + public static function fetch_manifest_url( $url ) { + $response = wp_remote_get( + $url, + array( + 'timeout' => self::HTTP_TIMEOUT, + 'redirection' => 2, + 'limit_response_size' => self::MAX_MANIFEST_BYTES, + 'user-agent' => 'PirateParrot', + 'headers' => array( 'Accept' => 'application/json' ), + ) + ); + if ( is_wp_error( $response ) ) { + return array( + 'status' => 'error', + 'files' => array(), + 'error' => $response->get_error_code() . ': ' . $response->get_error_message(), + ); + } + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( 404 === $code ) { + return array( + 'status' => 'not_found', + 'files' => array(), + 'error' => '', + ); + } + if ( 200 !== $code ) { + return array( + 'status' => 'error', + 'files' => array(), + 'error' => 'http_' . $code, + ); + } + $body = wp_remote_retrieve_body( $response ); + $decoded = json_decode( $body, true ); + if ( ! is_array( $decoded ) || ! isset( $decoded['files'] ) || ! is_array( $decoded['files'] ) ) { + return array( + 'status' => 'error', + 'files' => array(), + 'error' => 'bad_manifest', + ); + } + + return array( + 'status' => 'ok', + 'files' => $decoded['files'], + 'error' => '', + ); + } + + /** + * Expected sha256 hashes of one manifest entry. wp.org emits an array + * of hashes when a version zip was rebuilt; accept both shapes. + * + * @return array Lowercased hex strings, possibly empty. + */ + public static function expected_hashes( $entry ) { + $raw = array(); + if ( is_array( $entry ) && isset( $entry['sha256'] ) ) { + $raw = is_array( $entry['sha256'] ) ? $entry['sha256'] : array( $entry['sha256'] ); + } + $out = array(); + foreach ( $raw as $hash ) { + if ( ! is_string( $hash ) ) { + continue; + } + $hash = strtolower( $hash ); + if ( preg_match( '/^[a-f0-9]{64}$/', $hash ) ) { + $out[] = $hash; + } + } + + return $out; + } + + /** + * RecursiveCallbackFilterIterator accept callback: prune symlinks, + * VCS/junk names. + */ + public static function filter_entry( $current, $key, $iterator ) { + if ( $current->isLink() ) { + return false; + } + + return ! in_array( $current->getFilename(), self::ignored_names(), true ); + } + + /** + * List regular files under $root as relative-path => size. + * + * @param string $root Resolved product directory. + * @param array $limits From limits(). + * @param bool $complete By-ref; false when the walk was cut short. + * + * @return array + */ + public static function walk( $root, $limits, &$complete ) { + $files = array(); + $root_norm = wp_normalize_path( $root ); + try { + $dir_iterator = new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ); + $filtered = new RecursiveCallbackFilterIterator( $dir_iterator, array( 'TI_Parrot_Integrity', 'filter_entry' ) ); + $iterator = new RecursiveIteratorIterator( $filtered, RecursiveIteratorIterator::LEAVES_ONLY ); + $iterator->setMaxDepth( self::MAX_WALK_DEPTH ); + foreach ( $iterator as $info ) { + if ( ! $info->isFile() ) { + continue; + } + if ( count( $files ) >= $limits['max_files'] ) { + $complete = false; + break; + } + $rel = substr( wp_normalize_path( $info->getPathname() ), strlen( $root_norm ) + 1 ); + if ( ! is_string( $rel ) || '' === $rel ) { + continue; + } + $files[ $rel ] = (int) $info->getSize(); + } + } catch ( Exception $e ) { + // unreadable subdirectory: partial listing only degrades "added" + $complete = false; + } + + return $files; + } + + /** + * The integrity report for one product. + */ + public static function check( $product ) { + $limits = self::limits(); + $start = microtime( true ); + $deadline = $start + $limits['time_budget']; + + $report = array( + 'slug' => $product['slug'], + 'type' => $product['type'], + 'version' => $product['version'], + 'active' => $product['active'], + 'path' => $product['path'], + 'status' => 'error', + 'source' => '', + 'manifest_url' => '', + 'error' => '', + 'complete' => true, + 'counts' => array( + 'manifest_files' => 0, + 'local_files' => 0, + 'checked' => 0, + 'ok' => 0, + 'modified' => 0, + 'missing' => 0, + 'added' => 0, + 'skipped' => 0, + ), + 'modified' => array(), + 'missing' => array(), + 'added' => array(), + 'skipped' => array(), + 'truncated' => array( + 'modified' => false, + 'missing' => false, + 'added' => false, + 'skipped' => false, + ), + 'elapsed_ms' => 0, + 'hashed_bytes' => 0, + 'checked_at' => gmdate( 'c' ), + ); + + $manifest = self::fetch_manifest( $product ); + $report['source'] = $manifest['source']; + $report['manifest_url'] = $manifest['url']; + if ( 'ok' !== $manifest['status'] ) { + $report['status'] = $manifest['status']; + $report['error'] = $manifest['error']; + $report['elapsed_ms'] = (int) round( ( microtime( true ) - $start ) * 1000 ); + + return $report; + } + + $root = realpath( $product['dir'] ); + if ( false === $root ) { + $report['status'] = 'error'; + $report['error'] = 'unresolvable_product_dir'; + + return $report; + } + + $complete = true; + $local = self::walk( $root, $limits, $complete ); + $report['counts']['local_files'] = count( $local ); + + $manifest_files = $manifest['files']; + ksort( $manifest_files ); + $report['counts']['manifest_files'] = count( $manifest_files ); + + $modified = array(); + $missing = array(); + $skipped = array(); + $hashed = 0; + + foreach ( $manifest_files as $rel => $entry ) { + if ( ! self::is_safe_relative_path( $rel ) ) { + $skipped[] = array( + 'path' => is_string( $rel ) ? substr( $rel, 0, self::MAX_PATH_LENGTH ) : '', + 'reason' => 'bad_manifest_path', + ); + continue; + } + // stat manifest entries directly: an aborted walk must degrade + // "added" only, never fabricate "missing" + unset( $local[ $rel ] ); + $abs = $root . '/' . $rel; + if ( is_link( $abs ) ) { + $skipped[] = array( + 'path' => $rel, + 'reason' => 'symlink', + ); + continue; + } + if ( ! is_file( $abs ) ) { + $missing[] = $rel; + continue; + } + $report['counts']['checked']++; + $size = (int) filesize( $abs ); + if ( $size > $limits['max_file_bytes'] ) { + $skipped[] = array( + 'path' => $rel, + 'reason' => 'too_large', + 'size' => $size, + ); + continue; + } + if ( microtime( true ) > $deadline || ( $hashed + $size ) > $limits['max_total_bytes'] ) { + $complete = false; + $skipped[] = array( + 'path' => $rel, + 'reason' => 'budget', + ); + continue; + } + if ( ! is_readable( $abs ) ) { + $skipped[] = array( + 'path' => $rel, + 'reason' => 'unreadable', + ); + continue; + } + $expected = self::expected_hashes( $entry ); + if ( empty( $expected ) ) { + $skipped[] = array( + 'path' => $rel, + 'reason' => 'no_hash', + ); + continue; + } + $actual = hash_file( 'sha256', $abs ); + $hashed += $size; + if ( false === $actual ) { + $skipped[] = array( + 'path' => $rel, + 'reason' => 'unreadable', + ); + continue; + } + if ( in_array( $actual, $expected, true ) ) { + $report['counts']['ok']++; + } else { + $modified[] = array( + 'path' => $rel, + 'expected' => $expected[0], + 'actual' => $actual, + 'size' => $size, + ); + } + } + + $added = array_keys( $local ); + sort( $added ); + + $report['counts']['modified'] = count( $modified ); + $report['counts']['missing'] = count( $missing ); + $report['counts']['added'] = count( $added ); + $report['counts']['skipped'] = count( $skipped ); + + $cap = $limits['max_list_items']; + if ( count( $modified ) > $cap ) { + $modified = array_slice( $modified, 0, $cap ); + $report['truncated']['modified'] = true; + } + if ( count( $missing ) > $cap ) { + $missing = array_slice( $missing, 0, $cap ); + $report['truncated']['missing'] = true; + } + if ( count( $added ) > $cap ) { + $added = array_slice( $added, 0, $cap ); + $report['truncated']['added'] = true; + } + if ( count( $skipped ) > $cap ) { + $skipped = array_slice( $skipped, 0, $cap ); + $report['truncated']['skipped'] = true; + } + + $report['modified'] = $modified; + $report['missing'] = $missing; + $report['added'] = $added; + $report['skipped'] = $skipped; + $report['complete'] = $complete; + $report['hashed_bytes'] = $hashed; + $report['elapsed_ms'] = (int) round( ( microtime( true ) - $start ) * 1000 ); + $report['status'] = 'ok'; + if ( count( $modified ) > 0 || count( $missing ) > 0 || count( $added ) > 0 ) { + $report['status'] = 'modified'; + } + if ( ! $complete ) { + $report['status'] = 'partial'; + } + $report['source'] = $manifest['source']; + $report['manifest_url'] = $manifest['url']; + + return $report; + } + + /** + * Resolve a requested relative path to a real file inside the product + * directory. Every prefix is checked for symlinks BEFORE realpath so we + * never resolve through a link (also avoids open_basedir warnings). + * Error messages deliberately never echo the requested path. + * + * @return string|WP_Error Absolute path on success. + */ + public static function resolve_file( $product, $rel ) { + if ( ! self::is_safe_relative_path( $rel ) ) { + return new WP_Error( 'pp_bad_path', __( 'Invalid file path.', 'pirate-parrot' ), array( 'status' => 400 ) ); + } + $root = realpath( $product['dir'] ); + if ( false === $root ) { + return new WP_Error( 'pp_file_not_found', __( 'File not found.', 'pirate-parrot' ), array( 'status' => 404 ) ); + } + $segments = explode( '/', $rel ); + $current = $root; + foreach ( $segments as $segment ) { + $current .= '/' . $segment; + if ( is_link( $current ) ) { + return new WP_Error( 'pp_bad_path', __( 'Invalid file path.', 'pirate-parrot' ), array( 'status' => 400 ) ); + } + } + $real = realpath( $current ); + if ( false === $real || ! is_file( $real ) || 'file' !== filetype( $real ) ) { + return new WP_Error( 'pp_file_not_found', __( 'File not found.', 'pirate-parrot' ), array( 'status' => 404 ) ); + } + $root_norm = wp_normalize_path( $root ); + $real_norm = wp_normalize_path( $real ); + if ( 0 !== strpos( $real_norm, $root_norm . '/' ) ) { + return new WP_Error( 'pp_bad_path', __( 'Invalid file path.', 'pirate-parrot' ), array( 'status' => 400 ) ); + } + + return $real; + } + + /** + * One base64 chunk of a product file. + * + * @return array|WP_Error + */ + public static function read_chunk( $product, $rel, $offset, $length ) { + $limits = self::limits(); + $real = self::resolve_file( $product, $rel ); + if ( is_wp_error( $real ) ) { + return $real; + } + $size = (int) filesize( $real ); + if ( $size > $limits['file_max_bytes'] ) { + return new WP_Error( 'pp_file_too_large', __( 'File exceeds the retrievable size cap.', 'pirate-parrot' ), array( 'status' => 413 ) ); + } + $offset = (int) $offset; + if ( $offset < 0 || $offset > $size ) { + return new WP_Error( 'pp_bad_range', __( 'Offset out of range.', 'pirate-parrot' ), array( 'status' => 400 ) ); + } + $length = (int) $length; + if ( $length <= 0 ) { + $length = self::DEFAULT_CHUNK_BYTES; + } + if ( $length > $limits['max_chunk_bytes'] ) { + $length = $limits['max_chunk_bytes']; + } + if ( ! is_readable( $real ) ) { + return new WP_Error( 'pp_file_unreadable', __( 'File is not readable.', 'pirate-parrot' ), array( 'status' => 500 ) ); + } + $data = ''; + if ( $offset < $size ) { + $data = file_get_contents( $real, false, null, $offset, $length ); + if ( false === $data ) { + return new WP_Error( 'pp_file_unreadable', __( 'File is not readable.', 'pirate-parrot' ), array( 'status' => 500 ) ); + } + } + $sha256 = hash_file( 'sha256', $real ); + + return array( + 'slug' => $product['slug'], + 'path' => $rel, + 'size' => $size, + 'sha256' => false === $sha256 ? '' : $sha256, + 'mtime' => gmdate( 'c', (int) filemtime( $real ) ), + 'offset' => $offset, + 'length' => strlen( $data ), + 'eof' => ( $offset + strlen( $data ) ) >= $size, + 'encoding' => 'base64', + 'content' => base64_encode( $data ), + ); + } +} diff --git a/pirate-parrot.php b/pirate-parrot.php index 24f9f30..6c467df 100644 --- a/pirate-parrot.php +++ b/pirate-parrot.php @@ -648,6 +648,7 @@ function get_status_message( $message ) { } require_once trailingslashit( plugin_dir_path( __FILE__ ) ) . 'inc/product-settings.php'; +require_once trailingslashit( plugin_dir_path( __FILE__ ) ) . 'inc/integrity.php'; require_once trailingslashit( plugin_dir_path( __FILE__ ) ) . 'inc/agent-api.php'; $ti_parrot = new TI_Parrot(); diff --git a/tests/test-integrity.php b/tests/test-integrity.php new file mode 100644 index 0000000..bc9da59 --- /dev/null +++ b/tests/test-integrity.php @@ -0,0 +1,807 @@ + response array|WP_Error for the pre_http_request stub. + * + * @var array + */ + public static $http = array(); + + /** + * URLs requested through the stub, in order. + * + * @var array + */ + public static $requests = array(); + + /** + * Limit overrides merged by the pirate_parrot_integrity_limits filter. + * + * @var array + */ + public static $limits_override = array(); + + /** + * Basefile returned by the themeisle_sdk_products filter stub. + * + * @var string + */ + public static $sdk_basefile = ''; + + /** + * Extra directories to remove on tear_down. + * + * @var array + */ + public static $cleanup_dirs = array(); + + public function set_up() { + parent::set_up(); + self::$products = array(); + self::$replace = true; + self::$http = array(); + self::$requests = array(); + self::$limits_override = array(); + self::$sdk_basefile = ''; + self::$cleanup_dirs = array(); + self::$root = sys_get_temp_dir() . '/pp-int-' . uniqid(); + mkdir( self::$root, 0777, true ); + + $this->parrot = new TI_Parrot(); + $this->parrot->generate_new_parrot(); + $this->agent_token = $this->parrot->get_agent_token(); + + add_filter( 'pirate_parrot_integrity_products', array( 'Test_Integrity', 'inject_products' ) ); + add_filter( 'pre_http_request', array( 'Test_Integrity', 'fake_http' ), 10, 3 ); + add_filter( 'pirate_parrot_integrity_limits', array( 'Test_Integrity', 'override_limits' ) ); + } + + public function tear_down() { + self::rmrf( self::$root ); + foreach ( self::$cleanup_dirs as $dir ) { + self::rmrf( $dir ); + } + parent::tear_down(); + } + + // ---------------------------------------------------------------- helpers + + public static function inject_products( $products ) { + if ( ! self::$replace ) { + return $products; + } + + return self::$products; + } + + public static function fake_http( $preempt, $args, $url ) { + self::$requests[] = $url; + if ( isset( self::$http[ $url ] ) ) { + return self::$http[ $url ]; + } + + return array( + 'response' => array( + 'code' => 404, + 'message' => 'Not Found', + ), + 'body' => '', + 'headers' => array(), + 'cookies' => array(), + ); + } + + public static function override_limits( $limits ) { + return array_merge( $limits, self::$limits_override ); + } + + public static function register_sdk_basefile( $basefiles ) { + $basefiles[] = self::$sdk_basefile; + + return $basefiles; + } + + public static function rmrf( $path ) { + if ( '' === $path ) { + return; + } + if ( is_link( $path ) || is_file( $path ) ) { + unlink( $path ); + + return; + } + if ( ! is_dir( $path ) ) { + return; + } + $entries = scandir( $path ); + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + self::rmrf( $path . '/' . $entry ); + } + rmdir( $path ); + } + + private function request( $route, $token = null, $params = array() ) { + $request = new WP_REST_Request( 'GET', '/' . TI_Parrot_Agent_API::REST_NAMESPACE . $route ); + if ( null !== $token ) { + $request->set_header( 'Authorization', 'Bearer ' . $token ); + } + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_do_request( $request ); + } + + /** + * Write files into a fixture product dir (plus the SDK marker) and + * register it for injection. + * + * @return string The product directory. + */ + private function make_product( $slug, $files, $type = 'plugin', $version = '1.2.3', $wp_available = false ) { + $dir = self::$root . '/' . $slug; + foreach ( $files as $rel => $contents ) { + $abs = $dir . '/' . $rel; + $parent = dirname( $abs ); + if ( ! is_dir( $parent ) ) { + mkdir( $parent, 0777, true ); + } + file_put_contents( $abs, $contents ); + } + $sdk_dir = $dir . '/vendor/codeinwp/themeisle-sdk'; + if ( ! is_dir( $sdk_dir ) ) { + mkdir( $sdk_dir, 0777, true ); + } + file_put_contents( $sdk_dir . '/load.php', " $slug, + 'type' => $type, + 'version' => $version, + 'active' => true, + 'path' => 'plugin' === $type ? $slug . '/' . $slug . '.php' : $slug . '/style.css', + 'wordpress_available' => $wp_available, + 'dir' => $dir, + ); + + return $dir; + } + + /** + * Manifest body matching the on-disk fixture state. + */ + private function manifest_for( $slug, $version, $dir, $paths, $type = 'plugin' ) { + $files = array(); + foreach ( $paths as $rel ) { + $abs = $dir . '/' . $rel; + $files[ $rel ] = array( + 'md5' => md5_file( $abs ), + 'sha256' => hash_file( 'sha256', $abs ), + ); + } + $body = array(); + $body[ $type ] = $slug; + $body['version'] = $version; + $body['files'] = $files; + + return $body; + } + + private function ok_response( $body ) { + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => is_array( $body ) ? wp_json_encode( $body ) : $body, + 'headers' => array(), + 'cookies' => array(), + ); + } + + private function themeisle_url( $slug, $version ) { + return 'https://api.themeisle.com/checksum/' . $slug . '/' . $version . '.json'; + } + + private function wporg_url( $slug, $version ) { + return 'https://downloads.wordpress.org/plugin-checksums/' . $slug . '/' . $version . '.json'; + } + + /** + * Standard fixture: one pro plugin whose manifest matches disk. + */ + private function make_clean_product() { + $files = array( + 'fake-product.php' => " "readme body\n", + 'inc/helper.php' => " "make_product( 'fake-product', $files ); + + $manifest = $this->manifest_for( 'fake-product', '1.2.3', $dir, array_keys( $files ) ); + // SDK loader ships inside the zip too + $manifest['files']['vendor/codeinwp/themeisle-sdk/load.php'] = array( + 'md5' => md5_file( $dir . '/vendor/codeinwp/themeisle-sdk/load.php' ), + 'sha256' => hash_file( 'sha256', $dir . '/vendor/codeinwp/themeisle-sdk/load.php' ), + ); + self::$http[ $this->themeisle_url( 'fake-product', '1.2.3' ) ] = $this->ok_response( $manifest ); + + return $dir; + } + + // ------------------------------------------------------------- detection + + public function test_detection_scans_real_plugin_and_theme_dirs() { + self::$replace = false; + + $plugin_dir = WP_PLUGIN_DIR . '/pp-fake-sdk'; + self::$cleanup_dirs[] = $plugin_dir; + mkdir( $plugin_dir . '/vendor/codeinwp/themeisle-sdk', 0777, true ); + file_put_contents( + $plugin_dir . '/pp-fake-sdk.php', + "assertArrayHasKey( 'pp-fake-sdk', $by_slug ); + $this->assertArrayNotHasKey( 'pp-no-sdk', $by_slug ); + $plugin = $by_slug['pp-fake-sdk']; + $this->assertSame( 'plugin', $plugin['type'] ); + $this->assertSame( '9.9.9', $plugin['version'] ); + $this->assertFalse( $plugin['active'] ); + $this->assertSame( 'pp-fake-sdk/pp-fake-sdk.php', $plugin['path'] ); + $this->assertTrue( $plugin['wordpress_available'] ); + + $this->assertArrayHasKey( 'pp-fake-theme', $by_slug ); + $theme = $by_slug['pp-fake-theme']; + $this->assertSame( 'theme', $theme['type'] ); + $this->assertSame( '2.0.0', $theme['version'] ); + $this->assertFalse( $theme['active'] ); + $this->assertTrue( $theme['wordpress_available'] ); + } + + public function test_detection_unions_sdk_registered_basefiles_and_dedupes() { + self::$replace = false; + + $plugin_dir = WP_PLUGIN_DIR . '/pp-fake-sdk'; + self::$cleanup_dirs[] = $plugin_dir; + mkdir( $plugin_dir . '/vendor/codeinwp/themeisle-sdk', 0777, true ); + file_put_contents( + $plugin_dir . '/pp-fake-sdk.php', + " must not appear twice + self::$sdk_basefile = $plugin_dir . '/pp-fake-sdk.php'; + add_filter( 'themeisle_sdk_products', array( 'Test_Integrity', 'register_sdk_basefile' ) ); + + // a second product only known through the runtime filter (custom path) + $custom_dir = self::$root . '/custom-loc'; + mkdir( $custom_dir . '/vendor/codeinwp/themeisle-sdk', 0777, true ); + file_put_contents( + $custom_dir . '/custom-loc.php', + "assertSame( 1, count( array_keys( $slugs, 'pp-fake-sdk', true ) ), 'Registered basefile of a scanned plugin must be deduplicated.' ); + $this->assertContains( 'custom-loc', $slugs ); + + $by_slug = array(); + foreach ( $products as $product ) { + $by_slug[ $product['slug'] ] = $product; + } + $this->assertTrue( $by_slug['custom-loc']['active'], 'Runtime-registered products are active by definition.' ); + $this->assertSame( '3.0.0', $by_slug['custom-loc']['version'] ); + } + + public static function register_second_basefile( $basefiles ) { + $basefiles[] = $GLOBALS['pp_second_basefile']; + + return $basefiles; + } + + public function test_integrity_index_lists_products_without_internals() { + $this->make_clean_product(); + + $response = $this->request( '/integrity', $this->agent_token ); + $this->assertSame( 200, $response->get_status() ); + $products = $response->get_data()['products']; + $this->assertCount( 1, $products ); + $this->assertSame( 'fake-product', $products[0]['slug'] ); + $this->assertSame( '/integrity/fake-product', $products[0]['route'] ); + $this->assertArrayNotHasKey( 'dir', $products[0] ); + } + + public function test_manifest_lists_integrity_section() { + $this->make_clean_product(); + + $response = $this->request( '/manifest', $this->agent_token ); + $sections = $response->get_data()['sections']; + $by_slug = array(); + foreach ( $sections as $section ) { + $by_slug[ $section['slug'] ] = $section; + } + $this->assertArrayHasKey( 'integrity', $by_slug ); + $this->assertSame( '/integrity', $by_slug['integrity']['route'] ); + $this->assertContains( 'fake-product', $by_slug['integrity']['products'] ); + } + + // ------------------------------------------------------------ comparison + + public function test_status_ok_when_manifest_matches_disk() { + $this->make_clean_product(); + + $response = $this->request( '/integrity/fake-product', $this->agent_token ); + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + + $this->assertSame( 'ok', $data['status'] ); + $this->assertSame( 'themeisle', $data['source'] ); + $this->assertTrue( $data['complete'] ); + $this->assertSame( array(), $data['modified'] ); + $this->assertSame( array(), $data['missing'] ); + $this->assertSame( array(), $data['added'] ); + $this->assertSame( 5, $data['counts']['manifest_files'] ); + $this->assertSame( 5, $data['counts']['ok'] ); + } + + public function test_modified_files_are_reported_with_hashes() { + $dir = $this->make_clean_product(); + file_put_contents( $dir . '/inc/helper.php', "request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'modified', $data['status'] ); + $this->assertSame( 2, $data['counts']['modified'] ); + $paths = wp_list_pluck( $data['modified'], 'path' ); + $this->assertContains( 'inc/helper.php', $paths ); + // redact() must not blank a file path that merely looks credential-ish + $this->assertContains( 'inc/auth-token.php', $paths ); + foreach ( $data['modified'] as $row ) { + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $row['expected'] ); + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $row['actual'] ); + $this->assertNotSame( $row['expected'], $row['actual'] ); + } + } + + public function test_missing_and_added_files_are_reported() { + $dir = $this->make_clean_product(); + unlink( $dir . '/readme.txt' ); + file_put_contents( $dir . '/dropped-in.php', "request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'modified', $data['status'] ); + $this->assertSame( array( 'readme.txt' ), $data['missing'] ); + $this->assertSame( array( 'dropped-in.php' ), $data['added'] ); + } + + // ------------------------------------------------------ manifest sources + + public function test_wordpress_available_plugin_asks_wporg_first() { + $files = array( + 'free-product.php' => "make_product( 'free-product', $files, 'plugin', '2.0.0', true ); + + $manifest = $this->manifest_for( 'free-product', '2.0.0', $dir, array_keys( $files ) ); + $manifest['files']['vendor/codeinwp/themeisle-sdk/load.php'] = array( + 'md5' => md5_file( $dir . '/vendor/codeinwp/themeisle-sdk/load.php' ), + 'sha256' => hash_file( 'sha256', $dir . '/vendor/codeinwp/themeisle-sdk/load.php' ), + ); + self::$http[ $this->wporg_url( 'free-product', '2.0.0' ) ] = $this->ok_response( $manifest ); + + $data = $this->request( '/integrity/free-product', $this->agent_token )->get_data(); + + $this->assertSame( 'ok', $data['status'] ); + $this->assertSame( 'wporg', $data['source'] ); + $this->assertSame( array( $this->wporg_url( 'free-product', '2.0.0' ) ), self::$requests ); + } + + public function test_pro_plugin_asks_our_api_first_and_falls_back_to_wporg() { + $files = array( + 'fake-product.php' => "make_product( 'fake-product', $files ); + + $manifest = $this->manifest_for( 'fake-product', '1.2.3', $dir, array_keys( $files ) ); + $manifest['files']['vendor/codeinwp/themeisle-sdk/load.php'] = array( + 'md5' => md5_file( $dir . '/vendor/codeinwp/themeisle-sdk/load.php' ), + 'sha256' => hash_file( 'sha256', $dir . '/vendor/codeinwp/themeisle-sdk/load.php' ), + ); + // ours 404s (not stubbed), wp.org answers + self::$http[ $this->wporg_url( 'fake-product', '1.2.3' ) ] = $this->ok_response( $manifest ); + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'ok', $data['status'] ); + $this->assertSame( 'wporg', $data['source'] ); + $this->assertSame( + array( + $this->themeisle_url( 'fake-product', '1.2.3' ), + $this->wporg_url( 'fake-product', '1.2.3' ), + ), + self::$requests + ); + } + + public function test_no_manifest_when_all_sources_404() { + $this->make_product( + 'fake-product', + array( 'fake-product.php' => "request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'no_manifest', $data['status'] ); + $this->assertCount( 2, self::$requests ); + } + + public function test_theme_has_no_wporg_fallback() { + $this->make_product( + 'fake-theme', + array( 'style.css' => "/*\nTheme Name: Fake Theme\nVersion: 1.2.3\n*/\n" ), + 'theme' + ); + + $data = $this->request( '/integrity/fake-theme', $this->agent_token )->get_data(); + + $this->assertSame( 'no_manifest', $data['status'] ); + $this->assertSame( array( $this->themeisle_url( 'fake-theme', '1.2.3' ) ), self::$requests ); + } + + public function test_network_error_yields_error_status() { + $this->make_product( + 'fake-product', + array( 'fake-product.php' => "themeisle_url( 'fake-product', '1.2.3' ) ] = new WP_Error( 'http_request_failed', 'cURL error 28' ); + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'error', $data['status'] ); + $this->assertStringContainsString( 'http_request_failed', $data['error'] ); + } + + public function test_non_json_body_yields_bad_manifest_error() { + $this->make_product( + 'fake-product', + array( 'fake-product.php' => "themeisle_url( 'fake-product', '1.2.3' ) ] = $this->ok_response( 'captive portal' ); + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'error', $data['status'] ); + $this->assertSame( 'bad_manifest', $data['error'] ); + } + + public function test_array_valued_sha256_matches_any_hash() { + $dir = $this->make_clean_product(); + $url = $this->themeisle_url( 'fake-product', '1.2.3' ); + $body = json_decode( self::$http[ $url ]['body'], true ); + // wp.org publishes arrays when a zip was rebuilt: wrong-then-right + $real = $body['files']['readme.txt']['sha256']; + $body['files']['readme.txt']['sha256'] = array( str_repeat( '0', 64 ), $real ); + self::$http[ $url ] = $this->ok_response( $body ); + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'ok', $data['status'] ); + } + + public function test_manifest_path_traversal_is_skipped() { + $url = $this->themeisle_url( 'fake-product', '1.2.3' ); + $this->make_clean_product(); + $body = json_decode( self::$http[ $url ]['body'], true ); + $body['files']['../../wp-config.php'] = array( + 'md5' => str_repeat( 'a', 32 ), + 'sha256' => str_repeat( 'a', 64 ), + ); + self::$http[ $url ] = $this->ok_response( $body ); + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $reasons = wp_list_pluck( $data['skipped'], 'reason' ); + $this->assertContains( 'bad_manifest_path', $reasons ); + // everything else still verifies + $this->assertSame( 5, $data['counts']['ok'] ); + } + + // ----------------------------------------------------------------- limits + + public function test_oversized_files_are_skipped_not_hashed() { + self::$limits_override = array( 'max_file_bytes' => 10 ); + $this->make_clean_product(); + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $reasons = wp_list_pluck( $data['skipped'], 'reason' ); + $this->assertContains( 'too_large', $reasons ); + $this->assertTrue( $data['complete'] ); + $this->assertSame( 'ok', $data['status'], 'Size-skips alone must not flag the product as modified.' ); + } + + public function test_exhausted_budget_yields_partial_status() { + self::$limits_override = array( 'time_budget' => 0 ); + $this->make_clean_product(); + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 'partial', $data['status'] ); + $this->assertFalse( $data['complete'] ); + $reasons = array_unique( wp_list_pluck( $data['skipped'], 'reason' ) ); + $this->assertSame( array( 'budget' ), array_values( $reasons ) ); + } + + public function test_added_list_is_truncated_with_full_counts() { + self::$limits_override = array( 'max_list_items' => 20 ); + $dir = $this->make_clean_product(); + for ( $i = 0; $i < 50; $i++ ) { + file_put_contents( $dir . '/extra-' . str_pad( $i, 3, '0', STR_PAD_LEFT ) . '.txt', 'x' ); + } + + $data = $this->request( '/integrity/fake-product', $this->agent_token )->get_data(); + + $this->assertSame( 50, $data['counts']['added'] ); + $this->assertCount( 20, $data['added'] ); + $this->assertTrue( $data['truncated']['added'] ); + $this->assertFalse( $data['truncated']['missing'] ); + } + + // ------------------------------------------------------------------ /file + + public function test_file_endpoint_returns_base64_content() { + $dir = $this->make_clean_product(); + + $response = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( 'path' => 'inc/helper.php' ) + ); + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + + $this->assertSame( 'inc/helper.php', $data['path'] ); + $this->assertSame( 'base64', $data['encoding'] ); + $this->assertTrue( $data['eof'] ); + $this->assertSame( file_get_contents( $dir . '/inc/helper.php' ), base64_decode( $data['content'] ) ); + $this->assertSame( hash_file( 'sha256', $dir . '/inc/helper.php' ), $data['sha256'] ); + $this->assertSame( filesize( $dir . '/inc/helper.php' ), $data['size'] ); + } + + public function test_file_endpoint_chunks_large_files() { + $dir = $this->make_clean_product(); + $content = ''; + for ( $i = 0; $i < 19200; $i++ ) { + $content .= md5( (string) $i, true ); + } + file_put_contents( $dir . '/big.bin', $content ); + $size = strlen( $content ); // 307200 + + // default chunk size + $first = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( 'path' => 'big.bin' ) + )->get_data(); + $this->assertSame( TI_Parrot_Integrity::DEFAULT_CHUNK_BYTES, $first['length'] ); + $this->assertFalse( $first['eof'] ); + + // loop to EOF and reassemble + $assembled = ''; + $offset = 0; + do { + $chunk = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( + 'path' => 'big.bin', + 'offset' => $offset, + ) + )->get_data(); + $assembled .= base64_decode( $chunk['content'] ); + $offset += $chunk['length']; + } while ( ! $chunk['eof'] ); + $this->assertSame( $content, $assembled ); + + // oversized length clamps to the max and the JSON stays under the cap + $clamped = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( + 'path' => 'big.bin', + 'length' => 999999, + ) + )->get_data(); + $this->assertSame( TI_Parrot_Integrity::MAX_CHUNK_BYTES, $clamped['length'] ); + $this->assertLessThan( + TI_Parrot_Agent_API::MAX_SECTION_BYTES, + strlen( wp_json_encode( $clamped ) ), + 'A max-size chunk must fit inside the respond() byte cap.' + ); + + // offset at EOF -> empty chunk, eof true + $at_end = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( + 'path' => 'big.bin', + 'offset' => $size, + ) + )->get_data(); + $this->assertSame( 0, $at_end['length'] ); + $this->assertTrue( $at_end['eof'] ); + $this->assertSame( '', $at_end['content'] ); + + // offset past EOF -> 400 + $past = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( + 'path' => 'big.bin', + 'offset' => $size + 1, + ) + ); + $this->assertSame( 400, $past->get_status() ); + } + + public function test_file_endpoint_rejects_unsafe_paths() { + $this->make_clean_product(); + + $cases = array( + '../outside.php' => 400, + '/etc/passwd' => 400, + 'a/../b.php' => 400, + "a\0b.php" => 400, + 'a\\b.php' => 400, + '' => 400, + './helper.php' => 400, + 'C:/windows.php' => 400, + 'inc' => 404, + 'does-not-exist.php' => 404, + ); + foreach ( $cases as $path => $expected_status ) { + $response = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( 'path' => $path ) + ); + $this->assertSame( $expected_status, $response->get_status(), 'Path: ' . var_export( $path, true ) ); + $encoded = wp_json_encode( $response->get_data() ); + $this->assertStringNotContainsString( self::$root, (string) $encoded, 'Absolute paths must not leak.' ); + } + } + + public function test_file_endpoint_rejects_symlinks() { + if ( ! function_exists( 'symlink' ) ) { + $this->markTestSkipped( 'symlink() unavailable' ); + } + $dir = $this->make_clean_product(); + + $outside = self::$root . '/outside-secret.txt'; + file_put_contents( $outside, 'outside' ); + $inside_dir = self::$root . '/outside-dir'; + mkdir( $inside_dir ); + file_put_contents( $inside_dir . '/x.txt', 'x' ); + + if ( ! @symlink( $outside, $dir . '/link-outside.txt' ) ) { + $this->markTestSkipped( 'symlink() not permitted' ); + } + symlink( $dir . '/readme.txt', $dir . '/link-inside.txt' ); + symlink( $inside_dir, $dir . '/linked-dir' ); + + foreach ( array( 'link-outside.txt', 'link-inside.txt', 'linked-dir/x.txt' ) as $path ) { + $response = $this->request( + '/integrity/fake-product/file', + $this->agent_token, + array( 'path' => $path ) + ); + $this->assertSame( 400, $response->get_status(), 'Path: ' . $path ); + } + } + + // ------------------------------------------------------------------- auth + + public function test_integrity_routes_require_token() { + $this->make_clean_product(); + + foreach ( array( '/integrity', '/integrity/fake-product', '/integrity/fake-product/file' ) as $route ) { + $response = $this->request( $route ); + $this->assertSame( 401, $response->get_status(), 'Route: ' . $route ); + } + } + + public function test_unknown_slug_is_404() { + $this->assertSame( 404, $this->request( '/integrity/nope', $this->agent_token )->get_status() ); + $this->assertSame( + 404, + $this->request( '/integrity/nope/file', $this->agent_token, array( 'path' => 'x.php' ) )->get_status() + ); + } +}