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 / services / installed-services.php

installed-services.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/services/installed-services.php

668 lines 21.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Installed Services registry.
4 *
5 * Tracks which known services the admin explicitly ADDED (declared cookies + a
6 * policy entry). Blocking is independent - the catalog blocks every service
7 * regardless - so "installed" is purely the manage/declared state the Known
8 * Services library surfaces.
9 *
10 * Stored in the `surecookie_installed_services` option as:
11 * [ 'installed' => [ slug => {slug,label,category,added_at,cookie_ids[],version} ],
12 * 'suppressed' => [ slug, ... ] ]
13 * `suppressed` records removed services, so scan-time auto-seeding does not
14 * re-declare them until they are re-added.
15 *
16 * @package SureCookie\Inc\Modules\Services
17 * @since 1.3.0
18 */
19
20 namespace SureCookie\Inc\Modules\Services;
21
22 use SureCookie\Inc\Functions\Cookie_Identity;
23 use SureCookie\Inc\Functions\Get;
24 use SureCookie\Inc\Functions\Settings;
25 use SureCookie\Inc\Services\CookieService;
26 use SureCookie\Inc\Traits\GetInstance;
27
28 if ( ! defined( 'ABSPATH' ) ) {
29 exit; // Exit if accessed directly.
30 }
31
32 /**
33 * Installed_Services
34 *
35 * @since 1.3.0
36 */
37 class Installed_Services {
38 use GetInstance;
39
40 /**
41 * Constructor: wire the read-time Detected Resources overlay.
42 *
43 * @since 1.3.0
44 */
45 private function __construct() {
46 add_filter( 'surecookie_scanned_resources', [ $this, 'annotate_scanned_resources' ] );
47 }
48
49 /**
50 * Normalised registry: { installed: slug => entry, suppressed: slug[] }.
51 *
52 * @since 1.3.0
53 * @return array{installed: array<string, array<string, mixed>>, suppressed: array<int, string>}
54 */
55 public function get_data(): array {
56 $raw = get_option( SURECOOKIE_INSTALLED_SERVICES_OPTION, [] );
57 $raw = is_array( $raw ) ? $raw : [];
58
59 return [
60 'installed' => isset( $raw['installed'] ) && is_array( $raw['installed'] ) ? $raw['installed'] : [],
61 'suppressed' => isset( $raw['suppressed'] ) && is_array( $raw['suppressed'] ) ? array_values( $raw['suppressed'] ) : [],
62 ];
63 }
64
65 /**
66 * Sanitize an untrusted registry shape (settings import boundary): every
67 * entry field validated by type, unknown fields dropped, malformed
68 * entries removed. Mirrors the stored shape documented on this class.
69 *
70 * @param mixed $raw Untrusted registry value.
71 * @since 1.4.0
72 * @return array{installed: array<string, array<string, mixed>>, suppressed: array<int, string>}
73 */
74 public static function sanitize_registry( $raw ): array {
75 $raw = is_array( $raw ) ? $raw : [];
76 $installed = [];
77
78 $entries = isset( $raw['installed'] ) && is_array( $raw['installed'] ) ? $raw['installed'] : [];
79 foreach ( $entries as $slug => $entry ) {
80 $slug = sanitize_key( (string) $slug );
81 if ( $slug === '' || ! is_array( $entry ) ) {
82 continue;
83 }
84
85 $cookie_ids = isset( $entry['cookie_ids'] ) && is_array( $entry['cookie_ids'] ) ? $entry['cookie_ids'] : [];
86 $cookie_ids = array_values(
87 array_filter(
88 array_map(
89 static function ( $id ): string {
90 return is_string( $id ) ? sanitize_text_field( $id ) : '';
91 },
92 $cookie_ids
93 )
94 )
95 );
96
97 $installed[ $slug ] = [
98 'slug' => $slug,
99 'label' => isset( $entry['label'] ) && is_string( $entry['label'] ) ? sanitize_text_field( $entry['label'] ) : $slug,
100 'category' => isset( $entry['category'] ) && is_string( $entry['category'] ) ? sanitize_key( $entry['category'] ) : '',
101 'added_at' => isset( $entry['added_at'] ) ? absint( $entry['added_at'] ) : 0,
102 'version' => isset( $entry['version'] ) && is_string( $entry['version'] ) ? sanitize_text_field( $entry['version'] ) : '',
103 'cookie_ids' => $cookie_ids,
104 ];
105 }
106
107 $suppressed = isset( $raw['suppressed'] ) && is_array( $raw['suppressed'] ) ? $raw['suppressed'] : [];
108 $suppressed = array_values(
109 array_unique(
110 array_filter(
111 array_map(
112 static function ( $slug ): string {
113 return is_string( $slug ) ? sanitize_key( $slug ) : '';
114 },
115 $suppressed
116 )
117 )
118 )
119 );
120
121 return [
122 'installed' => $installed,
123 'suppressed' => $suppressed,
124 ];
125 }
126
127 /**
128 * The installed-service entries, keyed by slug.
129 *
130 * @since 1.3.0
131 * @return array<string, array<string, mixed>>
132 */
133 public function get_installed(): array {
134 return $this->get_data()['installed'];
135 }
136
137 /**
138 * Whether a service is currently installed (manually added).
139 *
140 * @param string $slug Catalog service slug.
141 * @since 1.3.0
142 * @return bool
143 */
144 public function is_installed( string $slug ): bool {
145 return isset( $this->get_data()['installed'][ $slug ] );
146 }
147
148 /**
149 * Whether a service was removed and should not be auto-re-declared by scans.
150 *
151 * @param string $slug Catalog service slug.
152 * @since 1.3.0
153 * @return bool
154 */
155 public function is_suppressed( string $slug ): bool {
156 return in_array( $slug, $this->get_data()['suppressed'], true );
157 }
158
159 /**
160 * One-time backfill: mark a catalog service installed when EVERY one of its
161 * declared cookies is already present (custom + scan-declared), so services a
162 * user declared by hand before this feature show as installed.
163 *
164 * Conservative - a service with no declared cookies, already installed, or
165 * suppressed is skipped. install() applies the same presence test, so a
166 * fully-present service records its entry without minting a duplicate cookie.
167 *
168 * @since 1.3.0
169 * @return array<int, string> Slugs that were backfilled.
170 */
171 public function backfill_from_existing_cookies(): array {
172 $catalog = Services_Source::get_instance()->get_catalog();
173 $existing_keys = $this->existing_cookie_keys();
174 $backfilled = [];
175
176 foreach ( $catalog as $slug => $service ) {
177 if ( ! is_string( $slug ) || ! is_array( $service ) ) {
178 continue;
179 }
180 if ( $this->is_installed( $slug ) || $this->is_suppressed( $slug ) ) {
181 continue;
182 }
183
184 $cookies = array_values( (array) ( $service['cookies'] ?? [] ) );
185 if ( $cookies === [] ) {
186 continue; // No declared cookies - nothing to match against.
187 }
188
189 $all_present = true;
190 foreach ( $cookies as $cookie ) {
191 if ( ! is_array( $cookie ) || (string) ( $cookie['name'] ?? '' ) === '' ) {
192 $all_present = false;
193 break;
194 }
195 if ( ! $this->cookie_present( $existing_keys, $cookie ) ) {
196 $all_present = false;
197 break;
198 }
199 }
200
201 if ( $all_present ) {
202 $result = $this->install( $slug );
203 if ( ! empty( $result['success'] ) ) {
204 $backfilled[] = $slug;
205 }
206 }
207 }
208
209 return $backfilled;
210 }
211
212 /**
213 * Add a known service: declare its cookies (deduped against cookies already
214 * present, see {@see self::cookie_present()}) and record the registry entry.
215 * The Pro gate is enforced by the REST layer, which knows the site's Pro status.
216 *
217 * @param string $slug Catalog service slug.
218 * @since 1.3.0
219 * @return array<string, mixed> { success, added[], skipped[], cookie_count } or { success:false, code }
220 */
221 public function install( string $slug ): array {
222 $catalog = Services_Source::get_instance()->get_catalog();
223 if ( ! isset( $catalog[ $slug ] ) || ! is_array( $catalog[ $slug ] ) ) {
224 return [
225 'success' => false,
226 'code' => 'unknown_service',
227 ];
228 }
229
230 $service = $catalog[ $slug ];
231 $data = $this->get_data();
232 $entry = $data['installed'][ $slug ] ?? [
233 'slug' => $slug,
234 'label' => (string) ( $service['label'] ?? $slug ),
235 'category' => (string) ( $service['category'] ?? 'uncategorized' ),
236 'added_at' => time(),
237 'cookie_ids' => [],
238 'version' => 1,
239 ];
240
241 $existing_keys = $this->existing_cookie_keys();
242 $service_obj = new CookieService();
243 $added = [];
244 $skipped = [];
245
246 foreach ( (array) ( $service['cookies'] ?? [] ) as $cookie ) {
247 if ( ! is_array( $cookie ) ) {
248 continue;
249 }
250
251 $name = (string) ( $cookie['name'] ?? '' );
252 // A catalog pattern is a matcher, not a cookie: minting one publishes a
253 // cookie the site does not set, on a legal disclosure page. Not recorded
254 // as skipped - `skipped` means "already on this site", and a pattern was
255 // never a candidate.
256 if ( $name === '' || Cookie_Identity::is_pattern( $name ) ) {
257 continue;
258 }
259
260 $domain = (string) ( $cookie['domain'] ?? '' );
261 // Dedup across everything already declared (custom + scan-declared) so
262 // a service cookie never doubles a visitor's list.
263 if ( $this->cookie_present( $existing_keys, $cookie ) ) {
264 $skipped[] = $name;
265 continue;
266 }
267
268 $result = $service_obj->create_custom_cookie(
269 [
270 'name' => $name,
271 'domain' => $domain,
272 'category' => $this->resolve_category( (string) ( $cookie['category'] ?? 'uncategorized' ) ),
273 'duration' => (string) ( $cookie['duration_days'] ?? 0 ),
274 'provider' => (string) ( $cookie['provider'] ?? '' ),
275 'purpose' => (string) ( $cookie['purpose'] ?? '' ),
276 'description' => (string) ( $cookie['description'] ?? '' ),
277 'type' => 'custom',
278 'service_slug' => $slug,
279 ]
280 );
281
282 if ( ! empty( $result['success'] ) && isset( $result['cookie']['id'] ) ) {
283 $added[] = $name;
284 $entry['cookie_ids'][] = $result['cookie']['id'];
285 $this->index_cookie( $existing_keys, $name, $domain );
286 } else {
287 $skipped[] = $name;
288 }
289 }
290
291 $entry['cookie_ids'] = array_values( array_unique( $entry['cookie_ids'] ) );
292 $data['installed'][ $slug ] = $entry;
293 $data['suppressed'] = array_values( array_diff( $data['suppressed'], [ $slug ] ) );
294 $this->save( $data );
295
296 return [
297 'success' => true,
298 'installed' => true,
299 'added' => $added,
300 'skipped' => $skipped,
301 'cookie_count' => count( $entry['cookie_ids'] ),
302 ];
303 }
304
305 /**
306 * Delete pattern rows a previous install() published as cookies.
307 *
308 * Nothing else converges them: the rows are `custom`, so no scan reconciles
309 * them away, and they stay on the public cookie policy indefinitely. Scoped to
310 * rows a service owns (`service_slug`), so a cookie an admin added by hand is
311 * never touched even if its name happens to hold a wildcard.
312 *
313 * @since 1.5.0
314 * @return int Rows removed.
315 */
316 public function prune_pattern_cookies(): int {
317 $custom = (array) Settings::get( 'custom_cookies' );
318 $removed = 0;
319
320 foreach ( $custom as $id => $cookie ) {
321 if ( ! is_array( $cookie ) ) {
322 continue;
323 }
324
325 if ( (string) ( $cookie['service_slug'] ?? '' ) === '' ) {
326 continue;
327 }
328
329 if ( ! Cookie_Identity::is_pattern( (string) ( $cookie['name'] ?? '' ) ) ) {
330 continue;
331 }
332
333 unset( $custom[ $id ] );
334 ++$removed;
335 }
336
337 if ( $removed > 0 ) {
338 Settings::update( 'custom_cookies', $custom );
339 }
340
341 return $removed;
342 }
343
344 /**
345 * Remove a known service: delete only the cookies it added (still carrying its
346 * service_slug and not declared by another installed service), drop the
347 * registry entry, and suppress future scan-time re-seeding until re-added.
348 *
349 * @param string $slug Catalog service slug.
350 * @since 1.3.0
351 * @return array<string, mixed> { success, removed }
352 */
353 public function uninstall( string $slug ): array {
354 $data = $this->get_data();
355
356 // Names still owned by OTHER installed services must never be deleted.
357 $protected = $this->names_owned_by_other_services( $slug, $data['installed'] );
358
359 // Names this service declares in the catalog. install() dedups a shared
360 // cookie by name+domain and never re-tags it, so a cookie two services
361 // declare stays tagged with whichever installed FIRST; the last one to
362 // uninstall must still remove it or it orphans (KS-018). So match managed
363 // cookies by declared-name too, not only by service_slug.
364 $declared = [];
365 foreach ( (array) ( Services_Source::get_instance()->get_catalog()[ $slug ]['cookies'] ?? [] ) as $cookie ) {
366 if ( is_array( $cookie ) && ! empty( $cookie['name'] ) ) {
367 $declared[ strtolower( (string) $cookie['name'] ) ] = true;
368 }
369 }
370
371 $custom = (array) Settings::get( 'custom_cookies' );
372 $removed = 0;
373 foreach ( $custom as $id => $cookie ) {
374 if ( ! is_array( $cookie ) ) {
375 continue;
376 }
377 $name = strtolower( (string) ( $cookie['name'] ?? '' ) );
378 $tagged_slug = (string) ( $cookie['service_slug'] ?? '' );
379 // Owned by this service: tagged with its slug, or a managed cookie
380 // (tagged by some service) whose name this service declares. A
381 // hand-added cookie (no service_slug) sharing a name is never touched.
382 $owned = $tagged_slug === $slug || ( $tagged_slug !== '' && isset( $declared[ $name ] ) );
383 if ( ! $owned ) {
384 continue;
385 }
386 if ( isset( $protected[ $name ] ) ) {
387 continue;
388 }
389 unset( $custom[ $id ] );
390 ++$removed;
391 }
392 Settings::update( 'custom_cookies', $custom );
393
394 unset( $data['installed'][ $slug ] );
395 if ( ! in_array( $slug, $data['suppressed'], true ) ) {
396 $data['suppressed'][] = $slug;
397 }
398 $this->save( $data );
399
400 return [
401 'success' => true,
402 'removed' => $removed,
403 ];
404 }
405
406 /**
407 * Read-time overlay: inject an installed service's script/iframe patterns as
408 * non-interactive info rows, and tag scanner-detected rows with the matching
409 * catalog slug (for the "Add as Service" CTA). Never persisted - runs on the
410 * `surecookie_scanned_resources` filter, so it survives rescans by construction.
411 *
412 * @param array<string, mixed> $resources { scripts:[], iframes:[], metadata:{} }.
413 * @since 1.3.0
414 * @return array<string, mixed>
415 */
416 public function annotate_scanned_resources( $resources = [] ) {
417 if ( ! is_array( $resources ) ) {
418 return $resources;
419 }
420
421 $matcher = Service_Matcher::get_instance();
422 $installed = $this->get_installed();
423
424 // Tag existing scanner rows with their catalog slug + installed state.
425 foreach ( [ 'scripts', 'iframes' ] as $kind ) {
426 if ( empty( $resources[ $kind ] ) || ! is_array( $resources[ $kind ] ) ) {
427 continue;
428 }
429 foreach ( $resources[ $kind ] as $i => $row ) {
430 if ( ! is_array( $row ) ) {
431 continue;
432 }
433 $match = $matcher->match_url( (string) ( $row['url'] ?? $row['domain'] ?? '' ) );
434 if ( $match !== '' ) {
435 $resources[ $kind ][ $i ]['service_slug'] = $match;
436 $resources[ $kind ][ $i ]['service_installed'] = isset( $installed[ $match ] );
437 }
438 }
439 }
440
441 // Inject one info row per installed service pattern not already listed,
442 // using the catalog's authoritative kind (a script host like
443 // `cdnjs.cloudflare.com` must not be guessed into the iframe bucket).
444 // Dedup is per (kind, host): a service can declare both a script and an
445 // iframe on the SAME host (e.g. Google Sign-In under accounts.google.com),
446 // so a host seen as a script must not suppress the iframe row.
447 $existing_hosts = $this->hosts_index( $resources );
448 $patterns_by_kind = $matcher->get_service_patterns_by_kind( array_keys( $installed ) );
449 foreach ( $installed as $slug => $entry ) {
450 $service_patterns = $patterns_by_kind[ $slug ] ?? [];
451 foreach ( Pattern_Kinds::buckets() as $bucket ) {
452 // A stylesheet or media pattern still deserves a row - the admin
453 // needs to see it, and the row reports that it is not blocked.
454 // Scanners file those under scripts, so use the same bucket.
455 $kind = Pattern_Kinds::resource_kind( $bucket ) === 'iframe' ? 'iframes' : 'scripts';
456 // Grouped by host first, because one row stands for the whole
457 // service on that host. Emitting per pattern and deduping by host
458 // kept only the first, so an always-allow rule written from the row
459 // covered `instagram.com/p/` and left `/reel/` and `/embed` blocked.
460 $by_host = [];
461 foreach ( (array) ( $service_patterns[ $bucket ] ?? [] ) as $pattern ) {
462 $host = $this->pattern_host( (string) $pattern );
463 if ( $host === '' ) {
464 continue;
465 }
466
467 $by_host[ $host ][] = (string) $pattern;
468 }
469
470 foreach ( $by_host as $host => $patterns ) {
471 if ( isset( $existing_hosts[ $kind ][ $host ] ) ) {
472 continue;
473 }
474
475 $patterns = array_values( array_unique( $patterns ) );
476 $resources[ $kind ][] = [
477 'domain' => $host,
478 'url' => $patterns[0],
479 'patterns' => $patterns,
480 'vendor' => (string) ( $entry['label'] ?? $slug ),
481 'category' => (string) ( $entry['category'] ?? 'uncategorized' ),
482 'source' => 'service',
483 'service_slug' => $slug,
484 'service_installed' => true,
485 ];
486 $existing_hosts[ $kind ][ $host ] = true;
487 }
488 }
489 }
490
491 return $resources;
492 }
493
494 /**
495 * Map a catalog cookie category onto the site's categories, falling back to
496 * `uncategorized` when it is absent (renamed/removed). PHP mirror of the JS
497 * resolveCategoryId.
498 *
499 * @param string $preset Catalog cookie category key.
500 * @since 1.3.0
501 * @return string A category id present on the site, or 'uncategorized'.
502 */
503 private function resolve_category( string $preset ): string {
504 $ids = [];
505 $categories = (array) Settings::get( 'cookie_categories' );
506 foreach ( $categories as $category ) {
507 if ( is_array( $category ) && isset( $category['id'] ) ) {
508 $ids[] = (string) $category['id'];
509 }
510 }
511 if ( $ids === [] ) {
512 $ids = Get::default_cookie_categories_keys();
513 }
514
515 return in_array( $preset, $ids, true ) ? $preset : 'uncategorized';
516 }
517
518 /**
519 * Index every cookie already declared (custom + scan-declared) for dedup.
520 *
521 * @since 1.3.0
522 * @return array<string, true>
523 */
524 private function existing_cookie_keys(): array {
525 $keys = [];
526
527 foreach ( (array) Settings::get( 'custom_cookies' ) as $cookie ) {
528 if ( is_array( $cookie ) && ! empty( $cookie['name'] ) ) {
529 $this->index_cookie( $keys, (string) $cookie['name'], (string) ( $cookie['domain'] ?? '' ) );
530 }
531 }
532
533 foreach ( (array) get_option( SURECOOKIE_SCANNED_COOKIES_OPTION, [] ) as $cookies ) {
534 foreach ( (array) $cookies as $cookie ) {
535 if ( is_array( $cookie ) && ! empty( $cookie['name'] ) ) {
536 $this->index_cookie( $keys, (string) $cookie['name'], (string) ( $cookie['domain'] ?? '' ) );
537 }
538 }
539 }
540
541 return $keys;
542 }
543
544 /**
545 * Record one stored cookie under both its name+domain and name-only keys. Only
546 * a first-party catalog row ever LOOKS UP the name-only key, so third-party
547 * cookies still need a matching domain to count as present.
548 *
549 * @param array<string, true> $keys Index being built, by reference.
550 * @param string $name Cookie name.
551 * @param string $domain Cookie domain.
552 * @since 1.3.0
553 * @return void
554 */
555 private function index_cookie( array &$keys, string $name, string $domain ): void {
556 $keys[ Cookie_Identity::key( $name, $domain ) ] = true;
557 $keys[ Cookie_Identity::name_key( $name ) ] = true;
558 }
559
560 /**
561 * Whether a catalog cookie is already present on the site.
562 *
563 * A first-party row's domain is this site's host (substituted for the
564 * placeholder), still not necessarily where the tag wrote the cookie -
565 * Analytics and friends scope to the registrable domain, so `shop.example.com`
566 * observes `.example.com`. A site has one `_ga`, so name alone is the identity
567 * for those rows. Third-party rows keep the strict name+domain test: `PREF` on
568 * `.google.com` is not YouTube's `PREF`.
569 *
570 * @param array<string, true> $keys Index from {@see self::existing_cookie_keys()}.
571 * @param array<string, mixed> $cookie Catalog cookie row.
572 * @since 1.3.0
573 * @return bool
574 */
575 private function cookie_present( array $keys, array $cookie ): bool {
576 $name = (string) ( $cookie['name'] ?? '' );
577
578 if ( isset( $keys[ Cookie_Identity::key_for( $cookie ) ] ) ) {
579 return true;
580 }
581
582 return Cookie_Identity::is_first_party( $cookie )
583 && isset( $keys[ Cookie_Identity::name_key( $name ) ] );
584 }
585
586 /**
587 * Lower-cased cookie names declared by installed services OTHER than $slug.
588 *
589 * @param string $slug Service being uninstalled (excluded).
590 * @param array<string, array<string, mixed>> $installed Installed registry entries.
591 * @since 1.3.0
592 * @return array<string, true>
593 */
594 private function names_owned_by_other_services( string $slug, array $installed ): array {
595 $catalog = Services_Source::get_instance()->get_catalog();
596 $names = [];
597
598 foreach ( $installed as $other => $entry ) {
599 if ( $other === $slug || ! isset( $catalog[ $other ]['cookies'] ) ) {
600 continue;
601 }
602 foreach ( (array) $catalog[ $other ]['cookies'] as $cookie ) {
603 if ( is_array( $cookie ) && ! empty( $cookie['name'] ) ) {
604 $names[ strtolower( (string) $cookie['name'] ) ] = true;
605 }
606 }
607 }
608
609 return $names;
610 }
611
612 /**
613 * Derive a display host from a path-y catalog pattern ("youtube.com/embed/"
614 * -> "youtube.com").
615 *
616 * @param string $pattern Catalog blocking pattern.
617 * @since 1.3.0
618 * @return string
619 */
620 private function pattern_host( string $pattern ): string {
621 $pattern = strtolower( trim( $pattern ) );
622
623 // Strip a leading scheme ("https://host/..." -> "host/...") without regex.
624 $scheme = strpos( $pattern, '://' );
625 if ( $scheme !== false && $scheme <= 6 ) {
626 $pattern = substr( $pattern, $scheme + 3 );
627 }
628
629 $pattern = ltrim( $pattern, '.*' );
630 $slash = strpos( $pattern, '/' );
631
632 return $slash === false ? $pattern : substr( $pattern, 0, $slash );
633 }
634
635 /**
636 * Index of hosts already present in the resource list, keyed by kind, so the
637 * injection dedup can treat a script and an iframe on the same host as
638 * distinct rows.
639 *
640 * @param array<string, mixed> $resources Resource payload.
641 * @since 1.3.0
642 * @return array<string, array<string, true>> [ scripts => [host=>true], iframes => [host=>true] ]
643 */
644 private function hosts_index( array $resources ): array {
645 $hosts = [
646 'scripts' => [],
647 'iframes' => [],
648 ];
649 foreach ( [ 'scripts', 'iframes' ] as $kind ) {
650 foreach ( (array) ( $resources[ $kind ] ?? [] ) as $row ) {
651 if ( is_array( $row ) && ! empty( $row['domain'] ) ) {
652 $hosts[ $kind ][ strtolower( (string) $row['domain'] ) ] = true;
653 }
654 }
655 }
656
657 return $hosts;
658 }
659
660 /**
661 * @param array{installed: array<string, mixed>, suppressed: array<int, string>} $data Registry.
662 * @since 1.3.0
663 */
664 private function save( array $data ): void {
665 update_option( SURECOOKIE_INSTALLED_SERVICES_OPTION, $data, false );
666 }
667 }
668