|null, inventory: array|null, check: array|null}> $pages Session::read(). * @param int|null $now Unix time (default: now). * @return array */ public static function classify( array $pages, ?int $now = null ): array { $now = null === $now ? time() : $now; $registry = Registry::all(); $manifest = Manifest::get(); $cookies = array(); $scripts = array(); $others = array(); $leaks = array(); $storage = array(); $summary = array(); // What stands in a page's own markup as an image or a hint: there, a rule reaches it. $static = array(); // The published rules of the categories that wait for an answer: what // they name was to be held back, so its being here needs another word. $waiting = self::waitingRules( $manifest ); foreach ( $pages as $index => $page ) { $server = is_array( $page['server'] ) ? $page['server'] : null; $inventory = is_array( $page['inventory'] ) ? $page['inventory'] : null; $check = is_array( $page['check'] ) ? $page['check'] : null; $summary[] = array( 'url' => (string) $page['url'], 'status' => null === $server ? 0 : (int) ( $server['status'] ?? 0 ), 'server' => null !== $server, 'browser' => null !== $inventory && empty( $inventory['failed'] ), 'check' => null !== $check && empty( $check['failed'] ), // Why the browser saw nothing of it: "timeout", or "moved:" and where a visitor is sent on to. 'failed' => null === $inventory ? '' : Text::cut( (string) ( $inventory['failed'] ?? '' ), 200 ), ); foreach ( null === $server ? array() : (array) ( $server['cookies'] ?? array() ) as $cookie ) { if ( is_array( $cookie ) && isset( $cookie['name'] ) ) { $expires = (int) ( $cookie['expires'] ?? 0 ); self::cookie( $cookies, $registry, (string) $cookie['name'], (string) ( $cookie['domain'] ?? '' ), 0 === $expires ? 0 : max( 1, $expires - $now ), ! empty( $cookie['http_only'] ), $index ); } } foreach ( null === $inventory ? array() : (array) ( $inventory['cookies'] ?? array() ) as $cookie ) { if ( is_array( $cookie ) && isset( $cookie['name'] ) ) { // The browser tells the end in milliseconds, 0 for a session cookie, -1 when it cannot tell. $expires = (float) ( $cookie['expires'] ?? -1 ); self::cookie( $cookies, $registry, (string) $cookie['name'], (string) ( $cookie['domain'] ?? '' ), $expires < 0 ? null : ( 0.0 === $expires ? 0 : max( 1, (int) round( $expires / 1000 ) - $now ) ), false, $index ); } } $addresses = array_merge( null === $server ? array() : (array) ( $server['tags'] ?? array() ), null === $inventory ? array() : (array) ( $inventory['resources'] ?? array() ) ); foreach ( $addresses as $seen ) { if ( is_array( $seen ) && isset( $seen[0], $seen[1] ) && is_string( $seen[0] ) && is_string( $seen[1] ) ) { self::address( $scripts, $others, $registry, self::kind( $seen[0] ), $seen[1], $index ); } } foreach ( null === $server ? array() : (array) ( $server['tags'] ?? array() ) as $seen ) { if ( is_array( $seen ) && isset( $seen[0], $seen[1] ) && is_string( $seen[1] ) && in_array( $seen[0], array( 'img', 'link' ), true ) ) { $static[ self::key( $registry, $seen[1] ) ] = true; } } foreach ( null === $server ? array() : (array) ( $server['inline'] ?? array() ) as $signature ) { if ( is_string( $signature ) ) { self::inline( $scripts, $registry, $signature, $index ); } } foreach ( null === $check ? array() : (array) ( $check['resources'] ?? array() ) as $seen ) { if ( is_array( $seen ) && isset( $seen[0], $seen[1] ) && is_string( $seen[0] ) && is_string( $seen[1] ) ) { self::leak( $leaks, $registry, self::kind( $seen[0] ), $seen[1], $index, $waiting ); } } foreach ( null === $inventory ? array() : (array) ( $inventory['storage'] ?? array() ) as $key ) { if ( is_string( $key ) && 1 !== preg_match( '/^(local|session):(wp-|wmcc|WP_)/', $key ) ) { $storage[ $key ] = Text::cut( $key, 120 ); } } }//end foreach self::expected( $cookies, $scripts, $registry ); foreach ( $leaks as $key => $leak ) { $leaks[ $key ]['holdable'] = isset( $leak['kinds']['script'] ) || isset( $leak['kinds']['iframe'] ) || isset( $static[ $key ] ); // "A rule names it and it loaded all the same" is news only of what a rule // holds: a style sheet from a host a rule names was never the rule's to hold. $leaks[ $key ]['named'] = $leak['named'] && $leaks[ $key ]['holdable']; } return array( 'time' => $now, 'version' => null === $manifest ? 0 : (int) $manifest['v'], 'mode' => null === $manifest ? '' : (string) $manifest['mode'], 'checked' => array() !== array_filter( array_column( $summary, 'check' ) ), 'pages' => $summary, 'cookies' => self::listed( $cookies ), 'scripts' => self::listed( $scripts ), 'others' => self::listed( $others ), 'leaks' => self::listed( $leaks ), 'storage' => array_slice( array_values( $storage ), 0, 50 ), ); } /** * Note a cookie that was seen. * * @param array> $cookies The list so far, by key. * @param array> $registry Known services. * @param string $name The cookie's name. * @param string $domain Its Domain attribute ('' when none was given). * @param int|null $seconds How long it lasts from now (0: the browser session; null: unknown). * @param bool $http_only Whether scripts cannot read it. * @param int $index The page it was seen on. * @return void */ private static function cookie( array &$cookies, array $registry, string $name, string $domain, ?int $seconds, bool $http_only, int $index ): void { $name = trim( $name ); // NOT_A_VISITORS covers this plugin's own cookie under every name it takes (PublicConfig::cookie()). if ( '' === $name || 1 === preg_match( self::NOT_A_VISITORS, $name ) ) { return; } $known = self::knownCookie( $registry, $name ); $key = null === $known ? $name : $known['service'] . '|' . $known['cookie']['name']; if ( ! isset( $cookies[ $key ] ) ) { $service = null === $known ? array() : $registry[ $known['service'] ]; $cookies[ $key ] = array( // A family is listed by its pattern (`_ga_*`), not by the one member seen. 'name' => Text::cut( null === $known ? $name : (string) $known['cookie']['name'], 100 ), 'service' => null === $known ? '' : $known['service'], 'provider' => (string) ( $service['provider'] ?? '' ), 'category' => (string) ( $service['category'] ?? '' ), 'domain' => '', 'duration' => null === $known || ! isset( $known['cookie']['duration'] ) ? null : self::registryDuration( $known['cookie']['duration'] ), 'http_only' => false, 'party' => 'first', 'seen' => 'page', 'pages' => array(), ); } $cookies[ $key ]['pages'][ $index ] = true; $cookies[ $key ]['http_only'] = $cookies[ $key ]['http_only'] || $http_only; if ( '' === $cookies[ $key ]['domain'] && '' !== $domain ) { $cookies[ $key ]['domain'] = Text::cut( $domain, 120 ); } // What a known service says of its cookie stands; for the rest, what was seen. if ( null === $cookies[ $key ]['duration'] && null !== $seconds ) { $cookies[ $key ]['duration'] = self::duration( $seconds ); } } /** * Note an address a page loads. * * @param array> $scripts What a rule can hold back, by key. * @param array> $others Other sites contacted, by host. * @param array> $registry Known services. * @param string $kind script, iframe, img, link or request. * @param string $address The address. * @param int $index The page it was seen on. * @return void */ private static function address( array &$scripts, array &$others, array $registry, string $kind, string $address, int $index ): void { $known = self::knownAddress( $registry, $address ); $host = self::host( $address ); if ( null !== $known ) { $key = $known['service'] . '|u|' . $known['script']['match']; if ( ! isset( $scripts[ $key ] ) ) { $service = $registry[ $known['service'] ]; $scripts[ $key ] = array( 'name' => (string) ( $known['script']['name'] ?? $known['script']['match'] ), 'service' => $known['service'], 'provider' => (string) ( $service['provider'] ?? '' ), 'category' => (string) ( $service['category'] ?? '' ), 'match' => (string) $known['script']['match'], 'target' => 'url', 'host' => $host, 'kinds' => array(), 'sample' => Text::cut( $address, 300 ), 'pages' => array(), ); } $scripts[ $key ]['kinds'][ $kind ] = true; $scripts[ $key ]['pages'][ $index ] = true; return; }//end if if ( '' === $host || self::own( $host ) ) { return; } // A script or a frame from another site is something a rule can hold // back; an image, a style sheet or a request a script sends is not. if ( 'script' === $kind || 'iframe' === $kind ) { $key = '|u|' . $host; if ( ! isset( $scripts[ $key ] ) ) { $scripts[ $key ] = array( 'name' => $host, 'service' => '', 'provider' => '', 'category' => '', 'match' => $host, 'target' => 'url', 'host' => $host, 'kinds' => array(), 'sample' => Text::cut( $address, 300 ), 'pages' => array(), ); } $scripts[ $key ]['kinds'][ $kind ] = true; $scripts[ $key ]['pages'][ $index ] = true; return; }//end if if ( ! isset( $others[ $host ] ) ) { $others[ $host ] = array( 'host' => $host, 'kinds' => array(), 'sample' => Text::cut( $address, 300 ), 'pages' => array(), ); } $others[ $host ]['kinds'][ $kind ] = true; $others[ $host ]['pages'][ $index ] = true; } /** * Note a known service's code written into a page. * * @param array> $scripts What a rule can hold back, by key. * @param array> $registry Known services. * @param string $signature The text that was found. * @param int $index The page it was seen on. * @return void */ private static function inline( array &$scripts, array $registry, string $signature, int $index ): void { foreach ( $registry as $id => $service ) { foreach ( (array) ( $service['scripts'] ?? array() ) as $script ) { if ( ! is_array( $script ) || 'inline' !== ( $script['target'] ?? 'url' ) || ( $script['match'] ?? null ) !== $signature ) { continue; } $key = $id . '|i|' . $signature; if ( ! isset( $scripts[ $key ] ) ) { $scripts[ $key ] = array( 'name' => (string) ( $script['name'] ?? $signature ), 'service' => (string) $id, 'provider' => (string) ( $service['provider'] ?? '' ), 'category' => (string) ( $service['category'] ?? '' ), 'match' => $signature, 'target' => 'inline', 'host' => '', 'kinds' => array( 'inline' => true ), 'sample' => '', 'pages' => array(), ); } $scripts[ $key ]['pages'][ $index ] = true; return; }//end foreach }//end foreach } /** * Note something that reached another site although the visitor had * not answered. * * @param array> $leaks The list so far, by key. * @param array> $registry Known services. * @param string $kind script, iframe, img, link or request. * @param string $address The address. * @param int $index The page it was seen on. * @param Rules $waiting The published rules that were to hold addresses back. * @return void */ private static function leak( array &$leaks, array $registry, string $kind, string $address, int $index, Rules $waiting ): void { $known = self::knownAddress( $registry, $address ); $host = self::host( $address ); if ( null === $known && ( '' === $host || self::own( $host ) ) ) { return; } $key = self::key( $registry, $address ); if ( ! isset( $leaks[ $key ] ) ) { $service = null === $known ? array() : $registry[ $known['service'] ]; $leaks[ $key ] = array( 'name' => null === $known ? $host : (string) ( $known['script']['name'] ?? $known['script']['match'] ), 'service' => null === $known ? '' : $known['service'], 'provider' => (string) ( $service['provider'] ?? '' ), 'category' => (string) ( $service['category'] ?? '' ), 'match' => null === $known ? $host : (string) $known['script']['match'], 'host' => $host, 'kinds' => array(), 'sample' => Text::cut( $address, 300 ), 'pages' => array(), 'named' => false, ); } $leaks[ $key ]['kinds'][ $kind ] = true; $leaks[ $key ]['pages'][ $index ] = true; $leaks[ $key ]['named'] = $leaks[ $key ]['named'] || '' !== $waiting->forUrl( $address ); } /** * The published address rules of the categories a visitor has to allow. * * @param array|null $manifest Manifest (null: nothing is published). * @return Rules */ private static function waitingRules( ?array $manifest ): Rules { if ( null === $manifest ) { return new Rules( array() ); } $waits = array(); foreach ( (array) ( $manifest['cats'] ?? array() ) as $category ) { if ( is_array( $category ) && isset( $category['id'] ) && empty( $category['req'] ) ) { $waits[ (string) $category['id'] ] = true; } } return new Rules( array_filter( (array) ( $manifest['rules'] ?? array() ), static function ( $rule ) use ( $waits ): bool { return is_array( $rule ) && isset( $rule[0], $rule[1] ) && 'u' === $rule[1] && isset( $waits[ (string) $rule[0] ] ); } ) ); } /** * What an address is listed under among the leaks: the known script it * is, or its host. * * @param array> $registry Known services. * @param string $address Address. * @return string */ private static function key( array $registry, string $address ): string { $known = self::knownAddress( $registry, $address ); return null === $known ? self::host( $address ) : $known['service'] . '|' . $known['script']['match']; } /** * Add the cookies a service is known to set that the scan did not see: * of every service one of whose scripts was found, and of what is * active on the site (Registry::present()). * * @param array> $cookies The list so far, by key. * @param array> $scripts What was found. * @param array> $registry Known services. * @return void */ private static function expected( array &$cookies, array $scripts, array $registry ): void { $found = array_merge( array_filter( array_column( $scripts, 'service' ) ), Registry::present() ); // A service whose cookie was seen is there, whatever loaded it. foreach ( $cookies as $cookie ) { if ( '' !== $cookie['service'] ) { $found[] = $cookie['service']; } } foreach ( array_unique( $found ) as $id ) { if ( ! isset( $registry[ $id ] ) ) { continue; } foreach ( (array) ( $registry[ $id ]['cookies'] ?? array() ) as $cookie ) { if ( ! is_array( $cookie ) || ! isset( $cookie['name'] ) || isset( $cookies[ $id . '|' . $cookie['name'] ] ) ) { continue; } $domain = (string) ( $cookie['domain'] ?? '' ); $cookies[ $id . '|' . $cookie['name'] ] = array( 'name' => (string) $cookie['name'], 'service' => (string) $id, 'provider' => (string) ( $registry[ $id ]['provider'] ?? '' ), 'category' => (string) ( $registry[ $id ]['category'] ?? '' ), 'domain' => $domain, 'duration' => isset( $cookie['duration'] ) ? self::registryDuration( $cookie['duration'] ) : null, 'http_only' => false, // A cookie kept on the service's own site is out of this site's sight. 'party' => '' !== $domain && ! self::own( ltrim( $domain, '.' ) ) ? 'third' : 'first', 'seen' => 'expected', 'pages' => array(), ); }//end foreach }//end foreach } /** * The known cookie a name is, or belongs to the family of. * * @param array> $registry Known services. * @param string $name Cookie name. * @return array{service: string, cookie: array}|null */ private static function knownCookie( array $registry, string $name ): ?array { $family = null; foreach ( $registry as $id => $service ) { foreach ( (array) ( $service['cookies'] ?? array() ) as $cookie ) { if ( ! is_array( $cookie ) || ! isset( $cookie['name'] ) || ! is_string( $cookie['name'] ) ) { continue; } if ( $cookie['name'] === $name ) { return array( 'service' => (string) $id, 'cookie' => $cookie, ); } // `_ga_*` covers `_ga_ABC123`: an exact name elsewhere still wins. if ( null === $family && '*' === substr( $cookie['name'], -1 ) && 0 === strpos( $name, substr( $cookie['name'], 0, -1 ) ) ) { $family = array( 'service' => (string) $id, 'cookie' => $cookie, ); } } }//end foreach return $family; } /** * The known script an address is: the first whose text the address * contains, whatever the case — as the rules themselves match. * * @param array> $registry Known services. * @param string $address Address. * @return array{service: string, script: array}|null */ private static function knownAddress( array $registry, string $address ): ?array { $address = strtolower( $address ); foreach ( $registry as $id => $service ) { foreach ( (array) ( $service['scripts'] ?? array() ) as $script ) { if ( is_array( $script ) && 'url' === ( $script['target'] ?? 'url' ) && isset( $script['match'] ) && is_string( $script['match'] ) && '' !== $script['match'] && false !== strpos( $address, strtolower( $script['match'] ) ) ) { return array( 'service' => (string) $id, 'script' => $script, ); } } } return null; } /** * What kind of thing loaded an address, in the few kinds the screen * tells apart. The browser names what started a request; anything that * is not a tag of the page is a request some code made. * * @param string $kind A tag's name or the browser's initiator type. * @return string script, iframe, img, link or request. */ private static function kind( string $kind ): string { $kind = strtolower( $kind ); if ( in_array( $kind, array( 'script', 'iframe', 'img' ), true ) ) { return $kind; } if ( in_array( $kind, array( 'image', 'input' ), true ) ) { return 'img'; } if ( 'frame' === $kind ) { return 'iframe'; } return in_array( $kind, array( 'link', 'css', 'style' ), true ) ? 'link' : 'request'; } /** * The host of an address ('' for an address without one: a page of the * site itself, or not an address at all). * * @param string $address Address. * @return string Lower case. */ private static function host( string $address ): string { if ( 0 === strpos( $address, '//' ) ) { $address = 'https:' . $address; } if ( 1 !== preg_match( '#^https?://#i', $address ) ) { return ''; } $host = wp_parse_url( $address, PHP_URL_HOST ); return is_string( $host ) ? strtolower( $host ) : ''; } /** * Whether a host is this site's own: the site's host, or one under it * (`cdn.example.com` for `example.com` or `www.example.com`). * * @param string $host Host, lower case. * @return bool */ private static function own( string $host ): bool { foreach ( array( home_url(), site_url() ) as $address ) { $site = strtolower( (string) preg_replace( '/^www\./', '', (string) wp_parse_url( $address, PHP_URL_HOST ) ) ); if ( '' !== $site && ( $host === $site || 'www.' . $site === $host || substr( $host, -strlen( '.' . $site ) ) === '.' . $site ) ) { return true; } } return false; } /** * A length of time in the unit it is a round number of. * * @param int $seconds Seconds (0: the browser session). * @return array{n: int, u: string} */ public static function duration( int $seconds ): array { if ( $seconds <= 0 ) { return array( 'n' => 0, 'u' => 'session', ); } $units = array( 'year' => YEAR_IN_SECONDS, 'month' => MONTH_IN_SECONDS, 'day' => DAY_IN_SECONDS, 'hour' => HOUR_IN_SECONDS, 'minute' => MINUTE_IN_SECONDS, ); foreach ( $units as $unit => $size ) { $count = (int) round( $seconds / $size ); // The largest unit it is a whole number of, give or take a fiftieth: // a cookie set a moment ago for a year is a year, not 364 days. if ( $count >= 1 && abs( $seconds - $count * $size ) <= $seconds * 0.02 ) { return array( 'n' => $count, 'u' => $unit, ); } } // Nothing round: days when it is days, else hours, else minutes. foreach ( array( 'day', 'hour', 'minute' ) as $unit ) { if ( $seconds >= $units[ $unit ] ) { return array( 'n' => (int) round( $seconds / $units[ $unit ] ), 'u' => $unit, ); } } return array( 'n' => 1, 'u' => 'minute', ); } /** * A registry duration ([ number, unit ]) in the document's shape. * * @param mixed $duration [ number, unit ]. * @return array{n: int, u: string} */ private static function registryDuration( $duration ): array { $duration = is_array( $duration ) ? array_values( $duration ) : array(); return array( 'n' => (int) ( $duration[0] ?? 0 ), 'u' => (string) ( $duration[1] ?? 'session' ), ); } /** * A list by key as the screen takes it: what was seen on a page first, * what belongs to a known service before what does not, the sets of * kinds and pages as a list and a count. * * @param array> $entries Entries by key. * @return array> */ private static function listed( array $entries ): array { $list = array(); foreach ( $entries as $entry ) { if ( isset( $entry['kinds'] ) ) { $entry['kinds'] = array_keys( $entry['kinds'] ); } $entry['pages'] = count( $entry['pages'] ); $list[] = $entry; } usort( $list, static function ( array $a, array $b ): int { $rank = static function ( array $entry ): string { return ( 'expected' === ( $entry['seen'] ?? 'page' ) ? '1' : '0' ) . ( '' === ( $entry['service'] ?? 'x' ) ? '1' : '0' ) . strtolower( (string) ( $entry['provider'] ?? '' ) . ' ' . (string) ( $entry['name'] ?? $entry['host'] ?? '' ) ); }; return strcmp( $rank( $a ), $rank( $b ) ); } ); return array_slice( $list, 0, self::MAX ); } }