| 1 |
<?php |
| 2 |
/** |
| 3 |
* Scan-Detected Scripts Merger. |
| 4 |
* |
| 5 |
* Merges scan-detected third-party resources into the known scripts |
| 6 |
* database via the surecookie_known_scripts filter. |
| 7 |
* |
| 8 |
* @package SureCookie\Inc\Modules\ScriptBlocking |
| 9 |
* @since 0.0.0-alpha.2 |
| 10 |
*/ |
| 11 |
|
| 12 |
namespace SureCookie\Inc\Modules\ScriptBlocking; |
| 13 |
|
| 14 |
use SureCookie\Inc\Modules\Services\Pattern_Kinds; |
| 15 |
use SureCookie\Inc\Traits\GetInstance; |
| 16 |
|
| 17 |
if ( ! defined( 'ABSPATH' ) ) { |
| 18 |
exit; // Exit if accessed directly. |
| 19 |
} |
| 20 |
|
| 21 |
/** |
| 22 |
* Scan_Scripts |
| 23 |
* |
| 24 |
* Hooks into the known_scripts filter to merge scan-detected resources. |
| 25 |
* |
| 26 |
* @since 0.0.0-alpha.2 |
| 27 |
*/ |
| 28 |
class Scan_Scripts { |
| 29 |
use GetInstance; |
| 30 |
|
| 31 |
/** |
| 32 |
* Cached scanned resources data. |
| 33 |
* |
| 34 |
* @var array<string, mixed>|null |
| 35 |
*/ |
| 36 |
private static ?array $cached_resources = null; |
| 37 |
|
| 38 |
/** |
| 39 |
* Host-grouped pattern index, keyed by a hash of the pattern set. |
| 40 |
* |
| 41 |
* @var array<string, array<string, array<int, string>>> |
| 42 |
*/ |
| 43 |
private static array $host_index_cache = []; |
| 44 |
|
| 45 |
/** |
| 46 |
* Constructor. |
| 47 |
* |
| 48 |
* @since 0.0.0-alpha.2 |
| 49 |
*/ |
| 50 |
private function __construct() { |
| 51 |
add_filter( 'surecookie_known_scripts', [ $this, 'merge_scan_detected_resources' ], 20 ); |
| 52 |
|
| 53 |
// Skip blocking for scripts/iframes whose src matches an excluded domain. |
| 54 |
// Kind-specific callbacks so a "script"-scoped exclusion never skips an |
| 55 |
// iframe on the same host, and vice versa. |
| 56 |
add_filter( 'surecookie_skip_script', [ $this, 'should_skip_excluded_script' ], 10, 5 ); |
| 57 |
add_filter( 'surecookie_skip_iframe', [ $this, 'should_skip_excluded_iframe' ], 10, 5 ); |
| 58 |
} |
| 59 |
|
| 60 |
/** |
| 61 |
* Merge scan-detected resources into the known scripts dataset. |
| 62 |
* |
| 63 |
* @param mixed $scripts Expected array<string, array<string, mixed>> of known scripts by category. |
| 64 |
* @since 0.0.0-alpha.2 |
| 65 |
* @return mixed Merged scripts, or $scripts untouched. |
| 66 |
*/ |
| 67 |
public function merge_scan_detected_resources( $scripts = [] ) { |
| 68 |
if ( ! is_array( $scripts ) ) { |
| 69 |
return $scripts; |
| 70 |
} |
| 71 |
|
| 72 |
$resources = $this->get_scanned_resources(); |
| 73 |
|
| 74 |
if ( empty( $resources ) ) { |
| 75 |
return $scripts; |
| 76 |
} |
| 77 |
|
| 78 |
// Build a flat list of all existing patterns to avoid duplicates. |
| 79 |
$existing_patterns = $this->build_existing_pattern_index( $scripts ); |
| 80 |
|
| 81 |
// Merge scan-detected scripts. |
| 82 |
foreach ( $resources['scripts'] ?? [] as $resource ) { |
| 83 |
$this->merge_resource( $scripts, $resource, 'scripts', $existing_patterns ); |
| 84 |
} |
| 85 |
|
| 86 |
// Merge scan-detected iframes. |
| 87 |
foreach ( $resources['iframes'] ?? [] as $resource ) { |
| 88 |
$this->merge_resource( $scripts, $resource, 'iframes', $existing_patterns ); |
| 89 |
} |
| 90 |
|
| 91 |
return $scripts; |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* Clear the static cache (useful after scan results are updated). |
| 96 |
* |
| 97 |
* @since 0.0.0-alpha.2 |
| 98 |
* @return void |
| 99 |
*/ |
| 100 |
public static function clear_cache(): void { |
| 101 |
self::$cached_resources = null; |
| 102 |
self::$host_index_cache = []; |
| 103 |
Resource_Categories::clear_cache(); |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* `surecookie_skip_script` callback: skip a script whose src matches a |
| 108 |
* script-scoped (or legacy bare-domain) exclusion. |
| 109 |
* |
| 110 |
* @since 1.3.0 |
| 111 |
* Untyped by design: this is a public filter, so the incoming values are |
| 112 |
* whatever the previous callback returned. A boolean filter answers yes or |
| 113 |
* no, so unusable input is coerced rather than passed through. |
| 114 |
* |
| 115 |
* @param mixed $skip Expected bool, whether the resource is already marked to skip. |
| 116 |
* @param mixed $src Expected string, the script src. |
| 117 |
* @param mixed $name Expected string, matched service key. |
| 118 |
* @param mixed $category Expected string, matched service category. |
| 119 |
* @param mixed $pattern Expected string, pattern that matched, for resources with no src. |
| 120 |
* @return bool |
| 121 |
*/ |
| 122 |
public function should_skip_excluded_script( $skip = false, $src = '', $name = '', $category = '', $pattern = '' ): bool { |
| 123 |
return $this->should_skip_excluded_resource( |
| 124 |
(bool) $skip, |
| 125 |
is_string( $src ) ? $src : '', |
| 126 |
'script', |
| 127 |
is_string( $pattern ) ? $pattern : '' |
| 128 |
); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* `surecookie_skip_iframe` callback: skip an iframe whose src matches an |
| 133 |
* iframe-scoped (or legacy bare-domain) exclusion. |
| 134 |
* |
| 135 |
* @since 1.3.0 |
| 136 |
* Untyped by design: see should_skip_excluded_script(). |
| 137 |
* |
| 138 |
* @param mixed $skip Expected bool, whether the resource is already marked to skip. |
| 139 |
* @param mixed $src Expected string, the iframe src. |
| 140 |
* @param mixed $name Expected string, matched service key. |
| 141 |
* @param mixed $category Expected string, matched service category. |
| 142 |
* @param mixed $pattern Expected string, pattern that matched, for resources with no src. |
| 143 |
* @return bool |
| 144 |
*/ |
| 145 |
public function should_skip_excluded_iframe( $skip = false, $src = '', $name = '', $category = '', $pattern = '' ): bool { |
| 146 |
return $this->should_skip_excluded_resource( |
| 147 |
(bool) $skip, |
| 148 |
is_string( $src ) ? $src : '', |
| 149 |
'iframe', |
| 150 |
is_string( $pattern ) ? $pattern : '' |
| 151 |
); |
| 152 |
} |
| 153 |
|
| 154 |
/** |
| 155 |
* Skip blocking when the resource src matches an excluded entry of the same |
| 156 |
* kind (or a legacy bare-domain entry, which applies to any kind). |
| 157 |
* |
| 158 |
* Exclusions are keyed per (kind, domain) so the per-resource "Do not block" |
| 159 |
* toggle on a script does not also unblock the iframe on the same host. |
| 160 |
* |
| 161 |
* @since 0.0.0-alpha.2 |
| 162 |
* @param bool $skip Whether the resource is already marked to skip. |
| 163 |
* @param string $src The resource URL (script src or iframe src). |
| 164 |
* @param string $kind Resource kind ('script'|'iframe'). |
| 165 |
* @param string $pattern Pattern that matched, for resources with no src. |
| 166 |
* @return bool |
| 167 |
*/ |
| 168 |
public function should_skip_excluded_resource( bool $skip, string $src, string $kind = 'any', string $pattern = '' ): bool { |
| 169 |
if ( $skip ) { |
| 170 |
return $skip; |
| 171 |
} |
| 172 |
|
| 173 |
return Resource_Categories::matches_excluded_any( [ $src, $pattern ], $kind ); |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* Merge a single resource into the scripts array. |
| 178 |
* |
| 179 |
* @param array<mixed> $scripts Known scripts (by reference). |
| 180 |
* @param array<string, mixed> $resource Scan-detected resource. |
| 181 |
* @param string $type Resource type ('scripts' or 'iframes'). |
| 182 |
* @param array<string, bool> $existing_patterns Index of existing patterns. |
| 183 |
* @since 0.0.0-alpha.2 |
| 184 |
* @return void |
| 185 |
*/ |
| 186 |
private function merge_resource( array &$scripts, array $resource, string $type, array $existing_patterns ): void { |
| 187 |
$domain = $resource['domain'] ?? ''; |
| 188 |
$category = $resource['category'] ?? 'marketing'; |
| 189 |
|
| 190 |
if ( empty( $domain ) ) { |
| 191 |
return; |
| 192 |
} |
| 193 |
|
| 194 |
// Skip if excluded by admin. Kind-scoped: a script exclusion does not |
| 195 |
// stop the iframe on the same host from being blocked, and vice versa. |
| 196 |
$kind = $type === 'iframes' ? 'iframe' : 'script'; |
| 197 |
if ( Resource_Categories::is_excluded_domain( (string) $domain, $kind ) ) { |
| 198 |
return; |
| 199 |
} |
| 200 |
|
| 201 |
// Skip if this domain already exists in known-scripts patterns. |
| 202 |
if ( isset( $existing_patterns[ $domain ] ) ) { |
| 203 |
return; |
| 204 |
} |
| 205 |
|
| 206 |
// Or if the catalog already covers the URL this row was recorded from. |
| 207 |
// A row is keyed on the bare host, which is broader than a pattern like |
| 208 |
// `google.com/recaptcha`, so the exact-key check above misses and the |
| 209 |
// host row then shadows the specific pattern it duplicates: the browser |
| 210 |
// guard has no way to let the narrower rule win (issue #1116). |
| 211 |
if ( self::catalog_covers_observed_url( $resource, $existing_patterns ) ) { |
| 212 |
return; |
| 213 |
} |
| 214 |
|
| 215 |
// Ensure the category exists. |
| 216 |
if ( ! isset( $scripts[ $category ] ) ) { |
| 217 |
$scripts[ $category ] = []; |
| 218 |
} |
| 219 |
|
| 220 |
// Build a unique service key from the domain. |
| 221 |
$service_key = 'scan_' . str_replace( [ '.', '-' ], '_', $domain ); |
| 222 |
|
| 223 |
// Add to the appropriate type array. |
| 224 |
$entry = [ |
| 225 |
'label' => $resource['vendor'] ?? $domain, |
| 226 |
]; |
| 227 |
|
| 228 |
if ( $type === 'iframes' ) { |
| 229 |
$entry['iframes'] = [ $domain ]; |
| 230 |
} else { |
| 231 |
$entry['scripts'] = [ $domain ]; |
| 232 |
} |
| 233 |
|
| 234 |
$scripts[ $category ][ $service_key ] = $entry; |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* Whether a catalog pattern already covers the URL this row was seen at. |
| 239 |
* |
| 240 |
* Matched against the observed URL, never the bare host. Skipping every row |
| 241 |
* whose host the catalog merely knows would stop blocking the paths it does |
| 242 |
* not name - `facebook.com/<anything else>` while the catalog names only |
| 243 |
* `facebook.com/tr` - and that is pre-consent tracking, the one direction |
| 244 |
* this feature must not fail in. |
| 245 |
* |
| 246 |
* Patterns are indexed by host so a row tests two or three candidates |
| 247 |
* instead of the whole catalog. Measured on the shipped catalog, the naive |
| 248 |
* form cost 21.8ms per page because the filter runs several times per |
| 249 |
* request; this is 0.7ms. |
| 250 |
* |
| 251 |
* @since 1.5.0 |
| 252 |
* @param array<string, mixed> $resource Scanned resource row. |
| 253 |
* @param array<string, bool> $existing_patterns Known-scripts pattern index. |
| 254 |
* @return bool |
| 255 |
*/ |
| 256 |
private static function catalog_covers_observed_url( array $resource, array $existing_patterns ): bool { |
| 257 |
// Iframe rows store the observed src, script rows the url. |
| 258 |
$url = trim( (string) ( $resource['url'] ?? $resource['src'] ?? '' ) ); |
| 259 |
|
| 260 |
// Nothing observed to compare against, so the row still carries meaning. |
| 261 |
if ( $url === '' ) { |
| 262 |
return false; |
| 263 |
} |
| 264 |
|
| 265 |
foreach ( self::candidate_patterns( $existing_patterns, $url ) as $pattern ) { |
| 266 |
if ( Entry_Match::matches( $pattern, $url ) ) { |
| 267 |
return true; |
| 268 |
} |
| 269 |
} |
| 270 |
|
| 271 |
return false; |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* Catalog patterns worth testing against one URL: those claiming its host or |
| 276 |
* a parent of it, plus the host-less ones, which can match any path. |
| 277 |
* |
| 278 |
* @since 1.5.0 |
| 279 |
* @param array<string, bool> $existing_patterns Known-scripts pattern index. |
| 280 |
* @param string $url Observed resource URL. |
| 281 |
* @return array<int, string> |
| 282 |
*/ |
| 283 |
private static function candidate_patterns( array $existing_patterns, string $url ): array { |
| 284 |
$index = self::patterns_by_host( $existing_patterns ); |
| 285 |
$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); |
| 286 |
|
| 287 |
$candidates = $index[''] ?? []; |
| 288 |
|
| 289 |
while ( $host !== '' ) { |
| 290 |
if ( ! empty( $index[ $host ] ) ) { |
| 291 |
$candidates = array_merge( $candidates, $index[ $host ] ); |
| 292 |
} |
| 293 |
|
| 294 |
$dot = strpos( $host, '.' ); |
| 295 |
if ( $dot === false ) { |
| 296 |
break; |
| 297 |
} |
| 298 |
|
| 299 |
$host = substr( $host, $dot + 1 ); |
| 300 |
} |
| 301 |
|
| 302 |
return $candidates; |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Group patterns by the host they claim, memoized for the request. |
| 307 |
* |
| 308 |
* @since 1.5.0 |
| 309 |
* @param array<string, bool> $existing_patterns Known-scripts pattern index. |
| 310 |
* @return array<string, array<int, string>> |
| 311 |
*/ |
| 312 |
private static function patterns_by_host( array $existing_patterns ): array { |
| 313 |
$key = md5( (string) wp_json_encode( array_keys( $existing_patterns ) ) ); |
| 314 |
|
| 315 |
if ( isset( self::$host_index_cache[ $key ] ) ) { |
| 316 |
return self::$host_index_cache[ $key ]; |
| 317 |
} |
| 318 |
|
| 319 |
$index = [ '' => [] ]; |
| 320 |
|
| 321 |
foreach ( array_keys( $existing_patterns ) as $pattern ) { |
| 322 |
$pattern = (string) $pattern; |
| 323 |
$index[ self::pattern_host( $pattern ) ][] = $pattern; |
| 324 |
} |
| 325 |
|
| 326 |
self::$host_index_cache[ $key ] = $index; |
| 327 |
|
| 328 |
return $index; |
| 329 |
} |
| 330 |
|
| 331 |
/** |
| 332 |
* The host a pattern claims, or an empty string when it claims none. |
| 333 |
* |
| 334 |
* Mirrors how {@see Entry_Match} decides the same thing: a query or fragment |
| 335 |
* is an exact-URL claim, and a first segment without a dot is a path, not a |
| 336 |
* host. |
| 337 |
* |
| 338 |
* @since 1.5.0 |
| 339 |
* @param string $pattern Catalog blocking pattern. |
| 340 |
* @return string |
| 341 |
*/ |
| 342 |
private static function pattern_host( string $pattern ): string { |
| 343 |
if ( strpbrk( $pattern, '?#' ) !== false || strpos( $pattern, '.' ) === false ) { |
| 344 |
return ''; |
| 345 |
} |
| 346 |
|
| 347 |
$first = strstr( $pattern, '/', true ); |
| 348 |
$first = $first === false ? $pattern : $first; |
| 349 |
|
| 350 |
if ( strpos( $first, '.' ) === false ) { |
| 351 |
return ''; |
| 352 |
} |
| 353 |
|
| 354 |
$url = strpos( $pattern, '//' ) === 0 ? 'https:' . $pattern : 'https://' . ltrim( $pattern, '/' ); |
| 355 |
|
| 356 |
return strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); |
| 357 |
} |
| 358 |
|
| 359 |
/** |
| 360 |
* Build an index of existing patterns for fast duplicate checks. |
| 361 |
* |
| 362 |
* @param array<string, array<string, mixed>> $scripts Known scripts by category. |
| 363 |
* @since 0.0.0-alpha.2 |
| 364 |
* @return array<string, bool> |
| 365 |
*/ |
| 366 |
private function build_existing_pattern_index( array $scripts ): array { |
| 367 |
$index = []; |
| 368 |
|
| 369 |
foreach ( $scripts as $services ) { |
| 370 |
if ( ! is_array( $services ) ) { |
| 371 |
continue; |
| 372 |
} |
| 373 |
|
| 374 |
foreach ( $services as $service ) { |
| 375 |
// Every bucket, including the ones no pass reads: re-adding a |
| 376 |
// known stylesheet host as a scan-detected script pattern would |
| 377 |
// put it back in front of the passes that cannot match it. |
| 378 |
foreach ( Pattern_Kinds::buckets() as $bucket ) { |
| 379 |
foreach ( $service[ $bucket ] ?? [] as $pattern ) { |
| 380 |
$index[ $pattern ] = true; |
| 381 |
} |
| 382 |
} |
| 383 |
} |
| 384 |
} |
| 385 |
|
| 386 |
return $index; |
| 387 |
} |
| 388 |
|
| 389 |
/** |
| 390 |
* Get scanned resources from the database (with static cache). |
| 391 |
* |
| 392 |
* @since 0.0.0-alpha.2 |
| 393 |
* @return array<string, mixed> |
| 394 |
*/ |
| 395 |
private function get_scanned_resources(): array { |
| 396 |
if ( self::$cached_resources !== null ) { |
| 397 |
return self::$cached_resources; |
| 398 |
} |
| 399 |
|
| 400 |
$resources = get_option( SURECOOKIE_SCANNED_RESOURCES_OPTION, [] ); |
| 401 |
|
| 402 |
if ( ! is_array( $resources ) ) { |
| 403 |
$resources = []; |
| 404 |
} |
| 405 |
|
| 406 |
self::$cached_resources = $resources; |
| 407 |
|
| 408 |
return self::$cached_resources; |
| 409 |
} |
| 410 |
|
| 411 |
} |
| 412 |
|