PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / modules / script-blocking / scan-scripts.php

scan-scripts.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/script-blocking/scan-scripts.php

412 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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