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 / resource-categories.php

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

415 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Scoped resource exclusion and category-override resolution.
4 *
5 * Single source of truth for the two admin settings that decide which category a
6 * scan-detected resource is actually gated under: `excluded_scan_resources`
7 * ("Do not block") and `resource_category_overrides` (recategorize a domain).
8 *
9 * Extracted from Blocker and Scan_Scripts so the preferences-modal "is this
10 * category in use?" predicate resolves categories the same way the blocker does.
11 * A second copy of the scoped-key precedence would drift, and the UI would then
12 * hide a category whose resources are still being gated.
13 *
14 * @package SureCookie\Inc\Modules\ScriptBlocking
15 * @since 1.4.0
16 */
17
18 namespace SureCookie\Inc\Modules\ScriptBlocking;
19
20 use SureCookie\Inc\Functions\Sanitize;
21 use SureCookie\Inc\Functions\Settings;
22 use SureCookie\Inc\Modules\Services\Pattern_Kinds;
23 use SureCookie\Inc\Modules\Services\Services_Source;
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit; // Exit if accessed directly.
27 }
28
29 /**
30 * Resource_Categories
31 *
32 * @since 1.4.0
33 */
34 final class Resource_Categories {
35 /**
36 * Cached exclusion entries.
37 *
38 * @var array<int, string>|null
39 */
40 private static ?array $excluded = null;
41
42 /**
43 * Cached category overrides ({ scoped key => category }).
44 *
45 * @var array<string, string>|null
46 */
47 private static ?array $overrides = null;
48
49 /**
50 * Cached catalog pattern index ([ kind => [ pattern => category ] ]).
51 *
52 * @var array<string, array<string, string>>|null
53 */
54 private static ?array $catalog_categories = null;
55
56 /**
57 * Cached pattern => [ catalog bucket => true ] index.
58 *
59 * @var array<string, array<string, bool>>|null
60 */
61 private static ?array $catalog_buckets = null;
62
63 /**
64 * Split a scoped key into [ kind, domain ].
65 *
66 * Keys are stored as "script::host" or "iframe::host" so a script and an
67 * iframe on the same host stay independent; a bare "host" (no "::") is a
68 * legacy key that applies to any kind. Shared by both settings.
69 *
70 * @since 1.4.0
71 * @param string $key Stored key.
72 * @return array{0: string, 1: string} [ kind ('script'|'iframe'|'any'), domain ].
73 */
74 public static function parse_scoped_key( string $key ): array {
75 $key = trim( $key );
76 $pos = strpos( $key, '::' );
77 if ( $pos === false ) {
78 return [ 'any', $key ];
79 }
80 return [ substr( $key, 0, $pos ), substr( $key, $pos + 2 ) ];
81 }
82
83 /**
84 * Exclusion entries from the `excluded_scan_resources` setting.
85 *
86 * @since 1.4.0
87 * @return array<int, string>
88 */
89 public static function excluded_entries(): array {
90 if ( self::$excluded !== null ) {
91 return self::$excluded;
92 }
93
94 $stored = Settings::get( 'excluded_scan_resources' );
95 self::$excluded = is_array( $stored ) ? array_values( $stored ) : [];
96
97 return self::$excluded;
98 }
99
100 /**
101 * Whether a scan-detected domain of a given kind is excluded from blocking.
102 *
103 * Exact domain comparison: the caller already holds the stored domain, not a
104 * full URL. Used when merging scan results and when deciding category usage.
105 *
106 * @since 1.4.0
107 * @param string $domain Scan-detected resource domain.
108 * @param string $kind Resource kind ('script'|'iframe').
109 * @return bool
110 */
111 public static function is_excluded_domain( string $domain, string $kind ): bool {
112 if ( $domain === '' ) {
113 return false;
114 }
115
116 foreach ( self::excluded_entries() as $entry ) {
117 [ $entry_kind, $entry_domain ] = self::parse_scoped_key( Sanitize::scalar( $entry ) );
118 if ( $entry_domain === '' || ( $entry_kind !== 'any' && $entry_kind !== $kind ) ) {
119 continue;
120 }
121 if ( strcasecmp( $entry_domain, $domain ) === 0 ) {
122 return true;
123 }
124 }
125
126 return false;
127 }
128
129 /**
130 * Whether a resource matches an exclusion entry of the same kind.
131 *
132 * Case-insensitive to agree with Blocker::match_pattern(), which is where the
133 * resource was matched in the first place, and host-boundary aware so
134 * excluding `google.com` cannot also release `evilgoogle.com`.
135 *
136 * @since 1.4.0
137 * @param string $src Resource URL, or a bare blocking pattern.
138 * @param string $kind Resource kind ('script'|'iframe').
139 * @return bool
140 */
141 public static function matches_excluded_src( string $src, string $kind ): bool {
142 if ( $src === '' ) {
143 return false;
144 }
145
146 foreach ( self::excluded_entries() as $entry ) {
147 [ $entry_kind, $domain ] = self::parse_scoped_key( Sanitize::scalar( $entry ) );
148 if ( $domain === '' || ( $entry_kind !== 'any' && $entry_kind !== $kind ) ) {
149 continue;
150 }
151 if ( self::entry_matches( $domain, $src ) ) {
152 return true;
153 }
154 }
155
156 return false;
157 }
158
159 /**
160 * Whether any candidate key matches an exclusion entry of the same kind.
161 *
162 * @since 1.5.0
163 * @param array<int, string> $keys Candidate keys, most specific first.
164 * @param string $kind Resource kind ('script'|'iframe').
165 * @return bool
166 */
167 public static function matches_excluded_any( array $keys, string $kind ): bool {
168 foreach ( $keys as $key ) {
169 if ( self::matches_excluded_src( (string) $key, $kind ) ) {
170 return true;
171 }
172 }
173
174 return false;
175 }
176
177 /**
178 * Whether one stored entry matches a resource.
179 *
180 * The rules live in {@see Entry_Match}, which Pro's whitelist calls too, so
181 * the two settings answer identically by construction rather than by a
182 * docblock asking two copies to stay in step.
183 *
184 * @since 1.5.0
185 * @param string $entry Stored exclusion value.
186 * @param string $subject Resource URL, or a bare blocking pattern.
187 * @return bool
188 */
189 public static function entry_matches( string $entry, string $subject ): bool {
190 return Entry_Match::matches( $entry, $subject );
191 }
192
193 /**
194 * Admin per-domain category overrides, read once per request. Invalid rows
195 * are skipped.
196 *
197 * @since 1.4.0
198 * @return array<string, string>
199 */
200 public static function overrides(): array {
201 if ( self::$overrides !== null ) {
202 return self::$overrides;
203 }
204
205 self::$overrides = [];
206 $stored = Settings::get( 'resource_category_overrides' );
207 if ( is_array( $stored ) ) {
208 foreach ( $stored as $domain => $category ) {
209 // Integer keys mean a list-shaped store; find_override() matches keys as
210 // substrings, so '0' would recategorize every URL containing a zero.
211 if ( ! is_string( $domain ) ) {
212 continue;
213 }
214
215 $domain = trim( $domain );
216 $category = trim( Sanitize::scalar( $category ) );
217 if ( $domain !== '' && $category !== '' ) {
218 self::$overrides[ $domain ] = $category;
219 }
220 }
221 }
222
223 return self::$overrides;
224 }
225
226 /**
227 * Apply an admin category override when the resource src contains an
228 * overridden domain, so consent gating follows the admin choice.
229 *
230 * An exact-kind override wins; a legacy bare-domain override applies to any
231 * kind. `$kind` is 'script' for scripts and 'iframe' for iframe/embed/object
232 * resources.
233 *
234 * @since 1.4.0
235 * @param string $src Resource URL, or a bare domain.
236 * @param string $category Category resolved from the pattern match.
237 * @param string $kind Resource kind ('script'|'iframe').
238 * @return string Overridden category, or the original when no override matches.
239 */
240 public static function resolve( string $src, string $category, string $kind = 'any' ): string {
241 return self::find_override( $src, $kind ) ?? $category;
242 }
243
244 /**
245 * The override target for one key, or null when no entry matches.
246 *
247 * Separate from resolve() so a caller can tell "no entry matched" from "an
248 * entry matched and named the category we already had".
249 *
250 * @since 1.5.0
251 * @param string $key Resource URL, or a bare domain.
252 * @param string $kind Resource kind ('script'|'iframe').
253 * @return string|null
254 */
255 public static function find_override( string $key, string $kind = 'any' ): ?string {
256 if ( $key === '' ) {
257 return null;
258 }
259
260 $legacy = null;
261 foreach ( self::overrides() as $entry_key => $target ) {
262 [ $entry_kind, $domain ] = self::parse_scoped_key( (string) $entry_key );
263 if ( $domain === '' || stripos( $key, $domain ) === false ) {
264 continue;
265 }
266 if ( $entry_kind === $kind ) {
267 return $target; // Exact-kind override wins.
268 }
269 if ( $entry_kind === 'any' && $legacy === null ) {
270 $legacy = $target; // Legacy bare-domain key applies to any kind.
271 }
272 }
273
274 return $legacy;
275 }
276
277 /**
278 * First override matching any candidate key.
279 *
280 * Ordered so a src-keyed override still beats the broader pattern that matched
281 * it: `google.com/recaptcha` must win over a bare `google.com` rule.
282 *
283 * @since 1.5.0
284 * @param array<int, string> $keys Candidate keys, most specific first.
285 * @param string $category Category resolved from the pattern match.
286 * @param string $kind Resource kind ('script'|'iframe').
287 * @return string
288 */
289 public static function resolve_first( array $keys, string $category, string $kind = 'any' ): string {
290 foreach ( $keys as $key ) {
291 $override = self::find_override( (string) $key, $kind );
292 if ( $override !== null ) {
293 return $override;
294 }
295 }
296
297 return $category;
298 }
299
300 /**
301 * The category a scan-detected resource is actually GATED under.
302 *
303 * `Scan_Scripts::merge_resource()` drops a scan row whose domain is already a
304 * catalog pattern, so for those domains the blocker uses the catalog's category
305 * and the scanner's own guess is never applied. Reading the stored category
306 * would then credit the wrong bucket, and a category still gating a resource
307 * could be reported unused.
308 *
309 * @since 1.4.0
310 * @param string $domain Scan-detected resource domain.
311 * @param string $stored Category recorded on the scan row.
312 * @param string $kind Resource kind ('script'|'iframe').
313 * @return string
314 */
315 public static function gated_category( string $domain, string $stored, string $kind ): string {
316 return self::resolve( $domain, self::catalog_category( $domain, $stored, $kind ), $kind );
317 }
318
319 /**
320 * The same resolution WITHOUT the admin override applied.
321 *
322 * The admin screen layers overrides on top itself, from the settings context,
323 * so the table reflects an edit before it is saved. Handing it a value that
324 * already had the saved override folded in would make an unsaved change
325 * invisible, so the two halves stay separate.
326 *
327 * @since 1.5.0
328 * @param string $domain Scan-detected resource domain.
329 * @param string $stored Category recorded on the scan row.
330 * @param string $kind Resource kind ('script'|'iframe').
331 * @return string
332 */
333 public static function catalog_category( string $domain, string $stored, string $kind ): string {
334 $catalog = self::catalog_categories();
335
336 return $catalog[ $kind ][ $domain ] ?? $catalog['any'][ $domain ] ?? $stored;
337 }
338
339 /**
340 * Drop the per-request caches. Called after scan results are stored.
341 *
342 * @since 1.4.0
343 * @return void
344 */
345 public static function clear_cache(): void {
346 self::$excluded = null;
347 self::$overrides = null;
348 self::$catalog_categories = null;
349 self::$catalog_buckets = null;
350 }
351
352 /**
353 * The catalog buckets a pattern is declared in, as [ bucket => true ].
354 *
355 * Empty when the catalog does not know the pattern. Lets a caller tell a
356 * pattern the blocker can act on from one it only ever sees on a `<link>`
357 * or an `<img>`, which no pass rewrites.
358 *
359 * @since 1.5.0
360 * @param string $pattern Catalog pattern, normally a scan row's domain.
361 * @return array<string, bool>
362 */
363 public static function catalog_buckets( string $pattern ): array {
364 self::catalog_categories();
365
366 return self::$catalog_buckets[ trim( $pattern ) ] ?? [];
367 }
368
369 /**
370 * Catalog blocking patterns indexed as [ kind => [ pattern => category ] ],
371 * plus an 'any' bucket mirroring the kind-blind duplicate check in
372 * `Scan_Scripts::build_existing_pattern_index()`. Builds the pattern =>
373 * bucket index in the same walk, so both share one cache.
374 *
375 * @since 1.4.0
376 * @return array<string, array<string, string>>
377 */
378 private static function catalog_categories(): array {
379 if ( self::$catalog_categories !== null ) {
380 return self::$catalog_categories;
381 }
382
383 $index = [
384 'script' => [],
385 'iframe' => [],
386 'any' => [],
387 ];
388 $buckets = [];
389
390 foreach ( Services_Source::get_instance()->get_blocking_view() as $category => $services ) {
391 if ( ! is_array( $services ) ) {
392 continue;
393 }
394 foreach ( $services as $service ) {
395 foreach ( Pattern_Kinds::buckets() as $bucket ) {
396 $kind = Pattern_Kinds::resource_kind( $bucket );
397 foreach ( (array) ( $service[ $bucket ] ?? [] ) as $pattern ) {
398 $pattern = trim( (string) $pattern );
399 if ( $pattern !== '' ) {
400 $index[ $kind ][ $pattern ] = (string) $category;
401 $index['any'][ $pattern ] = (string) $category;
402 $buckets[ $pattern ][ $bucket ] = true;
403 }
404 }
405 }
406 }
407 }
408
409 self::$catalog_categories = $index;
410 self::$catalog_buckets = $buckets;
411
412 return $index;
413 }
414 }
415