PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / 1.4.0
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking v1.4.0
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 / admin / sync.php

sync.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking 1.4.0, at admin/sync.php

979 lines 36.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin Sync
4 *
5 * Handles processing and storage of cookie scan results from SaaS API.
6 *
7 * @since 0.0.1
8 * @package SureCookie
9 */
10
11 namespace SureCookie\Admin;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit; // Exit if accessed directly.
15 }
16
17 use SureCookie\Inc\Functions\Cookie_Identity;
18 use SureCookie\Inc\Functions\Get;
19 use SureCookie\Inc\Functions\Update;
20 use SureCookie\Inc\Modules\AssistedScan\Normalizer;
21 use SureCookie\Inc\Modules\Services\Declared_Cookies;
22 use SureCookie\Inc\Modules\SiteScanner\SaasClient;
23 use SureCookie\Inc\Services\CookieCategoryMemory;
24 use SureCookie\Inc\Traits\GetInstance;
25 use SureCookie\Inc\Utils\Logger;
26
27 /**
28 * Admin Sync
29 *
30 * @since 0.0.1
31 */
32 class Sync {
33 use GetInstance;
34
35 /**
36 * Category mapping from SaaS tracking categories to plugin categories.
37 *
38 * @since 0.0.0-alpha.2
39 */
40 private const CATEGORY_MAP = [
41 'analytics' => 'analytics',
42 'advertising' => 'marketing',
43 'social' => 'marketing',
44 'social_media' => 'marketing',
45 'video' => 'marketing',
46 'functional' => 'functional',
47 'payment' => 'functional',
48 'consent' => 'essential',
49 // Security and necessary are strictly necessary (captchas, CSRF tokens, bot protection).
50 // Without these the fallback made them `marketing`, so declining marketing broke logins and forms.
51 'security' => 'essential',
52 'necessary' => 'essential',
53 // `preferences` stays marketing by explicit choice, not by falling through.
54 'preferences' => 'marketing',
55 // Only categories the plugin defines.
56 'essential' => 'essential',
57 'marketing' => 'marketing',
58 ];
59
60 /**
61 * Maximum number of scan-detected resources to store.
62 *
63 * @since 0.0.0-alpha.2
64 */
65 private const MAX_RESOURCES = 500;
66
67 /**
68 * Constructor - Hook into SaaS API results.
69 *
70 * @since 0.0.1
71 * @return void
72 */
73 public function __construct() {
74 add_action( 'surecookie_saas_scan_results_received', [ $this, 'process_saas_results' ], 10, 1 );
75 }
76
77 /**
78 * Process results from a scan, storing cookies grouped by category.
79 *
80 * Two optional keys let a non-cloud scanner reuse this pipeline:
81 * - `source` names the scanner ('saas' default, 'assisted' for a browser walk).
82 * Becomes the recorded `scan_type` and suppresses cloud outcome classification,
83 * which keys off scanner reach - always zero for a browser walk, so it would
84 * misreport a good assisted scan as `blocked_by_host`.
85 * - `partial` marks one page of a multi-part walk. Cookies/resources are stored
86 * immediately (nothing lost if abandoned), but once-per-scan work (declared-cookie
87 * seeding, reconcile, first-scan flags, scan-completed diff) defers to the final
88 * non-partial call - else a five-page walk fires five diffs and five digest emails.
89 *
90 * @param array<string, mixed> $data The scan results.
91 * @since 0.0.1
92 * @since 1.3.0 Added the `source` and `partial` keys.
93 * @return void
94 */
95 public function process_saas_results( array $data ): void {
96 $source = isset( $data['source'] ) ? sanitize_key( (string) $data['source'] ) : 'saas';
97 $is_cloud = $source === 'saas';
98 $is_partial = ! empty( $data['partial'] );
99
100 Logger::get_instance()->save_log( 'Processing scanned results...' );
101
102 // Extract pages data from API response.
103 $pages = $data['pages'] ?? [];
104
105 if ( empty( $pages ) ) {
106 Logger::get_instance()->save_log( 'No pages found in scan results.' );
107 $this->store_all_cookies_from_agent_app( [], $source );
108 if ( $is_cloud ) {
109 // Classify so the UI can distinguish a host-blocked crawl from a
110 // genuinely empty result instead of silently reporting 0 cookies.
111 SaasClient::get_instance()->store_scan_outcome( 0 );
112 }
113 return;
114 }
115
116 // Group cookies by category and deduplicate.
117 $cookies_by_category = $this->group_cookies_by_category( $pages );
118
119 $cookies_count = $this->get_cookies_count( $cookies_by_category );
120
121 // Record the honest outcome (ok / blocked_by_host / empty) for the UI.
122 if ( $is_cloud ) {
123 SaasClient::get_instance()->store_scan_outcome( $cookies_count );
124 }
125 Logger::get_instance()->save_log( '' ); // Blank line.
126 Logger::get_instance()->save_log( sprintf( 'Found %d unique cookies in this scan.', $cookies_count ) );
127
128 // Deferred on a partial page: seeding needs the whole walk's resources, and
129 // running it per page would declare cookies a later page contradicts.
130 if ( ! $is_partial ) {
131 $declared_by_category = Declared_Cookies::get_instance()->build_from_pages( $pages );
132 if ( ! empty( $declared_by_category ) ) {
133 $cookies_by_category = $this->merge_declared_cookies( $cookies_by_category, $declared_by_category );
134 Logger::get_instance()->save_log(
135 sprintf( 'Declared %d cookie(s) for blocked third-party services.', $this->get_cookies_count( $declared_by_category ) )
136 );
137 }
138 }
139
140 // Store cookies (merges with existing).
141 $this->store_all_cookies_from_agent_app( $cookies_by_category, $source, ! $is_partial );
142
143 // Store scan-detected scripts and iframes. A non-cloud scan merges rather than
144 // replaces: browser collection is a strict subset (an ad blocker suppresses
145 // trackers outright), so replacing would drop previously-detected domains and
146 // unblock trackers the site had covered.
147 $this->process_scanned_resources( $pages, ! $is_cloud );
148
149 // Everything below happens once per scan, not once per collected page.
150 if ( $is_partial ) {
151 return;
152 }
153
154 // Prune previously-declared cookies whose service/definition left the catalog,
155 // so catalog removals propagate on a scan. Observed and custom cookies are kept.
156 Declared_Cookies::get_instance()->reconcile_declared_cookies();
157
158 // Analytics: flag first scan completed for state detection on next admin load.
159 if ( ! get_option( 'surecookie_first_scan_completed_flag', false ) ) {
160 update_option( 'surecookie_first_scan_completed_flag', true, false );
161 update_option( 'surecookie_first_scan_pages_scanned', count( $pages ), false );
162 }
163
164 /**
165 * Fires after a scan's results have been processed and persisted.
166 *
167 * Automatic Scanning subscribes to diff this scan's reported set against the
168 * previous scan. Cookies merge "sticky" into the option (one absent from this
169 * scan is never auto-removed), so the diff must use this reported set, not the
170 * accumulated option. Not fired on a no-pages scan (avoids mistaking empty for
171 * "everything removed"); fired exactly once per scan (a multi-page walk defers
172 * to its final call), so subscribers see one diff and send one digest.
173 *
174 * @since 1.2.0
175 *
176 * @param array<string, array<int, array<string, mixed>>> $cookies_by_category This scan's reported cookies, grouped by category and deduped by signature_id.
177 * @param array<string, mixed> $context Scan context: cookies_count, scan_type, scanned_at, pages_scanned, domains.
178 */
179 do_action(
180 'surecookie_scan_completed',
181 $cookies_by_category,
182 [
183 'cookies_count' => $cookies_count,
184 'scan_type' => $source,
185 'scanned_at' => current_time( 'mysql' ),
186 'pages_scanned' => count( $pages ),
187 'domains' => $this->extract_third_party_domains( $pages ),
188 ]
189 );
190 }
191
192 /**
193 * Extract the unique third-party script/iframe domains reported in this scan.
194 *
195 * Used by the scan-completed diff to surface newly-introduced tracker domains.
196 *
197 * @param array<int, array<string, mixed>> $pages Scan result pages.
198 * @since 1.2.0
199 * @return array<int, string> Unique third-party domains.
200 */
201 private function extract_third_party_domains( array $pages ): array {
202 $domains = [];
203
204 foreach ( $pages as $page ) {
205 foreach ( $page['scripts'] ?? [] as $script ) {
206 if ( ! empty( $script['is_third_party'] ) && ! empty( $script['domain'] ) ) {
207 $domains[ sanitize_text_field( $script['domain'] ) ] = true;
208 }
209 }
210
211 foreach ( $page['iframes'] ?? [] as $iframe ) {
212 if ( ! empty( $iframe['is_third_party'] ) && ! empty( $iframe['domain'] ) ) {
213 $domains[ sanitize_text_field( $iframe['domain'] ) ] = true;
214 }
215 }
216 }
217
218 return array_keys( $domains );
219 }
220
221 /**
222 * Process and store scan-detected scripts and iframes.
223 *
224 * Extracts third-party scripts/iframes from scan results, maps categories,
225 * deduplicates by domain, and stores for the blocking engine.
226 *
227 * @param array<int, array<string, mixed>> $pages Scan result pages.
228 * @param bool $merge Union into the stored snapshot instead of replacing it.
229 * @since 0.0.0-alpha.2
230 * @since 1.3.0 Added the `$merge` mode for scanners whose view is a strict subset.
231 * @return void
232 */
233 private function process_scanned_resources( array $pages, bool $merge = false ): void {
234 $scripts = [];
235 $iframes = [];
236 $seen_script_domains = [];
237 $seen_iframe_domains = [];
238 // Domains this call actually added, so merge mode can tell "found nothing
239 // new" apart from "found nothing at all".
240 $discovered = 0;
241
242 // In merge mode, stored domains seed the "seen" sets so an existing entry is never
243 // duplicated or overwritten by a thinner one (it may carry a vendor this scanner missed).
244 if ( $merge ) {
245 $stored = get_option( SURECOOKIE_SCANNED_RESOURCES_OPTION, [] );
246 $stored = is_array( $stored ) ? $stored : [];
247 $scripts = is_array( $stored['scripts'] ?? null ) ? array_values( $stored['scripts'] ) : [];
248 $iframes = is_array( $stored['iframes'] ?? null ) ? array_values( $stored['iframes'] ) : [];
249
250 foreach ( $scripts as $script ) {
251 if ( ! empty( $script['domain'] ) ) {
252 $seen_script_domains[ (string) $script['domain'] ] = true;
253 }
254 }
255
256 foreach ( $iframes as $iframe ) {
257 if ( ! empty( $iframe['domain'] ) ) {
258 $seen_iframe_domains[ (string) $iframe['domain'] ] = true;
259 }
260 }
261 }
262
263 foreach ( $pages as $page ) {
264 // Process scripts (graceful: skip if not present in response).
265 foreach ( $page['scripts'] ?? [] as $script ) {
266 if ( empty( $script['is_third_party'] ) ) {
267 continue;
268 }
269
270 $domain = sanitize_text_field( $script['domain'] ?? '' );
271 if ( empty( $domain ) || isset( $seen_script_domains[ $domain ] ) ) {
272 continue;
273 }
274
275 $seen_script_domains[ $domain ] = true;
276 $discovered++;
277
278 $scripts[] = [
279 'domain' => $domain,
280 'url' => esc_url_raw( $script['url'] ?? '' ),
281 'vendor' => sanitize_text_field( $script['vendor_name'] ?? '' ),
282 'category' => $this->map_saas_category( $script['tracking_category'] ?? '' ),
283 'scanned_at' => gmdate( 'c' ),
284 ];
285 }
286
287 // Process iframes (graceful: skip if not present in response).
288 foreach ( $page['iframes'] ?? [] as $iframe ) {
289 if ( empty( $iframe['is_third_party'] ) ) {
290 continue;
291 }
292
293 $domain = sanitize_text_field( $iframe['domain'] ?? '' );
294 if ( empty( $domain ) || isset( $seen_iframe_domains[ $domain ] ) ) {
295 continue;
296 }
297
298 $seen_iframe_domains[ $domain ] = true;
299 $discovered++;
300
301 $iframes[] = [
302 'domain' => $domain,
303 'url' => esc_url_raw( $iframe['src'] ?? '' ),
304 'vendor' => sanitize_text_field( $iframe['vendor'] ?? '' ),
305 'category' => $this->map_saas_category( $iframe['tracking_category'] ?? '' ),
306 'scanned_at' => gmdate( 'c' ),
307 ];
308 }
309 }
310
311 // Nothing new. For a cloud scan this means the SaaS reported no scripts/iframes
312 // (older version); in merge mode only already-stored domains surfaced. Either way
313 // rewriting the option would bust the blocking caches for nothing.
314 if ( $discovered === 0 ) {
315 return;
316 }
317
318 // A cloud scan replaces the stored snapshot (the table always reflects current
319 // third parties); merge mode only adds, since with its subset view removing a
320 // domain would unblock a covered tracker. Per-domain block choices
321 // (excluded_scan_resources) and installed Known Services live in separate stores
322 // and survive either way.
323
324 // Cap each type independently so iframes aren't silently dropped when scripts are large.
325 $half_cap = (int) floor( self::MAX_RESOURCES / 2 );
326 $scripts = array_slice( $scripts, 0, $half_cap );
327 $iframes = array_slice( $iframes, 0, $half_cap );
328
329 $resource_data = [
330 'scripts' => $scripts,
331 'iframes' => $iframes,
332 'metadata' => [
333 'last_scan_at' => gmdate( 'c' ),
334 'version' => 1,
335 ],
336 ];
337
338 update_option( SURECOOKIE_SCANNED_RESOURCES_OPTION, $resource_data, false );
339
340 // Bust the known-scripts cache so the merged dataset rebuilds on next page load.
341 delete_transient( 'surecookie_known_scripts' );
342
343 // Clear the Scan_Scripts static cache.
344 \SureCookie\Inc\Modules\ScriptBlocking\Scan_Scripts::clear_cache();
345
346 // Fire action so GCM service detector clears its cache and re-detects Google services.
347 do_action( 'surecookie_scanner_results_updated' );
348
349 Logger::get_instance()->save_log(
350 sprintf( 'Stored %d scripts and %d iframes from scan results.', count( $scripts ), count( $iframes ) )
351 );
352 }
353
354 /**
355 * Map a SaaS category onto a plugin category.
356 *
357 * Unknown values fall to `uncategorized`, not `marketing`: both are withheld
358 * until consent, but guessing `marketing` silently mislabels whatever the SaaS
359 * adds next, which is exactly how `security` ended up there.
360 *
361 * @param string $saas_category SaaS tracking category or cookie purpose.
362 * @since 0.0.0-alpha.2
363 * @return string Plugin category.
364 */
365 private function map_saas_category( string $saas_category ): string {
366 return self::CATEGORY_MAP[ $saas_category ] ?? 'uncategorized';
367 }
368
369 /**
370 * Group cookies by category from scan results.
371 *
372 * @param array<int, array<string, mixed>> $pages Scan results pages.
373 * @since 0.0.1
374 * @return array<string, array<int, array<string, mixed>>> Cookies grouped by category.
375 */
376 private function group_cookies_by_category( array $pages ): array {
377 $cookies_by_category = array_fill_keys( Get::default_cookie_categories_keys(), [] );
378 $seen_signatures = [];
379
380 foreach ( $pages as $page ) {
381 foreach ( $page['cookies'] ?? [] as $cookie ) {
382 $signature_id = $cookie['signature_id'] ?? '';
383
384 // Skip if no signature or already seen.
385 if ( empty( $signature_id ) || isset( $seen_signatures[ $signature_id ] ) ) {
386 continue;
387 }
388
389 $seen_signatures[ $signature_id ] = true;
390
391 // Project through the same map the scripts/embeds path uses, so a SaaS-only
392 // category resolves to its plugin equivalent instead of being flattened to
393 // uncategorized (security cookies such as captcha tokens are essential).
394 $category = $this->map_saas_category( (string) ( $cookie['category'] ?? '' ) );
395
396 if ( ! isset( $cookies_by_category[ $category ] ) ) {
397 $category = 'uncategorized';
398 }
399
400 // Add transformed cookie.
401 $cookies_by_category[ $category ][] = $this->transform_cookie_data( $cookie, $category );
402 }
403 }
404
405 return $cookies_by_category;
406 }
407
408 /**
409 * Merge declared (catalog-seeded) cookies into the observed set, curated
410 * classification winning on conflict.
411 *
412 * On a name+domain match the merged cookie keeps the observed runtime attributes
413 * (value, expires, httpOnly, secure, sameSite, signature_id, source) but takes the
414 * curated category / provider / purpose / description and is re-bucketed into the
415 * declared category. Cookies in only one set pass through unchanged; a purely-declared
416 * cookie (its service was blocked, never observed) is added as-is.
417 *
418 * A first-party declared cookie also matches on name alone: its domain is this host
419 * substituted for the catalog placeholder, but a tag may scope the cookie to a
420 * different label (Analytics writes `_ga` on the registrable domain, so
421 * `shop.example.com` observes `.example.com`). Without that fallback both rows store,
422 * duplicating `_ga` in the manager and on the public cookie policy.
423 *
424 * @param array<string, array<int, array<string, mixed>>> $observed Observed cookies grouped by category.
425 * @param array<string, array<int, array<string, mixed>>> $declared Declared cookies grouped by category.
426 * @since 1.2.5
427 * @return array<string, array<int, array<string, mixed>>> Merged cookies grouped by category.
428 */
429 private function merge_declared_cookies( array $observed, array $declared ): array {
430 // Index declared cookies by name+domain, remembering their curated
431 // category, so an observed match can adopt the curated classification.
432 $declared_by_key = [];
433 foreach ( $declared as $category => $cookies ) {
434 foreach ( $cookies as $cookie ) {
435 $entry = [
436 'category' => $category,
437 'cookie' => $cookie,
438 ];
439
440 $declared_by_key[ $this->cookie_dedupe_key( $cookie ) ] = $entry;
441
442 if ( Cookie_Identity::is_first_party( $cookie ) ) {
443 $declared_by_key[ Cookie_Identity::name_key( (string) ( $cookie['name'] ?? '' ) ) ] = $entry;
444 }
445 }
446 }
447
448 $merged = array_fill_keys( Get::default_cookie_categories_keys(), [] );
449 $applied_keys = [];
450
451 // Pass 1: observed cookies. A curated match re-classifies and re-buckets;
452 // everything else passes through in its observed category.
453 foreach ( $observed as $category => $cookies ) {
454 foreach ( $cookies as $cookie ) {
455 $key = $this->cookie_dedupe_key( $cookie );
456
457 if ( ! isset( $declared_by_key[ $key ] ) ) {
458 $key = Cookie_Identity::name_key( (string) ( $cookie['name'] ?? '' ) );
459 }
460
461 if ( ! isset( $declared_by_key[ $key ] ) ) {
462 $merged[ $category ][] = $cookie;
463 continue;
464 }
465
466 $curated = $declared_by_key[ $key ]['cookie'];
467 $target_category = $declared_by_key[ $key ]['category'];
468
469 // Keep runtime attributes; take the curated classification. The catalog always sets
470 // these keys (possibly ''), so test emptiness not null - a blank entry must not overwrite a scan-resolved value.
471 $cookie['category'] = $target_category;
472 $cookie['provider'] = ! empty( $curated['provider'] ) ? $curated['provider'] : ( $cookie['provider'] ?? '' );
473 $cookie['purpose'] = ! empty( $curated['purpose'] ) ? $curated['purpose'] : ( $cookie['purpose'] ?? '' );
474 $cookie['description'] = ! empty( $curated['description'] ) ? $curated['description'] : ( $cookie['description'] ?? '' );
475 // The catalog's day count is authored, so it beats deriving one from the observed expiry - which only yields the time REMAINING.
476 $cookie['duration'] = ! empty( $curated['duration'] ) ? $curated['duration'] : ( $cookie['duration'] ?? '' );
477
478 if ( ! isset( $merged[ $target_category ] ) ) {
479 $merged[ $target_category ] = [];
480 }
481
482 $merged[ $target_category ][] = $cookie;
483 $applied_keys[ $key ] = true;
484 }
485 }
486
487 // Pass 2: declared cookies with no observed counterpart.
488 foreach ( $declared as $category => $cookies ) {
489 foreach ( $cookies as $cookie ) {
490 $key = $this->cookie_dedupe_key( $cookie );
491 $name_only = Cookie_Identity::name_key( (string) ( $cookie['name'] ?? '' ) );
492
493 // Mirrors pass 1: a first-party row absorbed by an observed cookie
494 // on another label of this host was applied under its name-only key.
495 if ( isset( $applied_keys[ $key ] )
496 || ( Cookie_Identity::is_first_party( $cookie ) && isset( $applied_keys[ $name_only ] ) ) ) {
497 continue;
498 }
499
500 if ( ! isset( $merged[ $category ] ) ) {
501 $merged[ $category ] = [];
502 }
503
504 $merged[ $category ][] = $cookie;
505 $applied_keys[ $key ] = true;
506 }
507 }
508
509 return $merged;
510 }
511
512 /**
513 * Build a case-insensitive name+domain key for cookie de-duplication.
514 *
515 * @param array<string, mixed> $cookie Cookie data.
516 * @since 1.2.5
517 * @return string
518 */
519 private function cookie_dedupe_key( array $cookie ): string {
520 return Cookie_Identity::key_for( $cookie );
521 }
522
523 /**
524 * Transform cookie data to minimal required format.
525 *
526 * @param array<string, mixed> $cookie Raw cookie data from API.
527 * @param string $category Cookie category.
528 * @since 0.0.1
529 * @return array<string, mixed> Minimal cookie data.
530 */
531 private function transform_cookie_data( array $cookie, string $category ): array {
532 // Use signature_id as the unique identifier.
533 $signature_id = $cookie['signature_id'] ?? '';
534
535 $transformed = [
536 // Cookie properties.
537 'name' => $cookie['name'] ?? '',
538 'value' => $cookie['value'] ?? '',
539 'domain' => $cookie['domain'] ?? '',
540 'path' => $cookie['path'] ?? '/',
541 'expires' => $cookie['expires_at'] ?? null,
542 'httpOnly' => ! empty( $cookie['http_only'] ),
543 'secure' => ! empty( $cookie['secure'] ),
544 'sameSite' => $cookie['same_site'] ?? 'lax',
545 'category' => $category,
546
547 // Cookie policy display fields.
548 // Scan API exposes the vendor as 'owner' ('vendor' kept for back-compat), never
549 // 'provider'; read those first, fall back to the setter domain ('set_via').
550 'provider' => $this->resolve_cookie_provider( $cookie ),
551 'description' => $cookie['description'] ?? '',
552 'purpose' => $cookie['purpose'] ?? '',
553
554 // Unique identifier for deduplication.
555 'signature_id' => $signature_id,
556 ];
557
558 // Which scanner observed this cookie. Carried only when the scan declares it, so a
559 // cloud row keeps its exact stored shape. Mirrors Declared_Cookies::transform().
560 if ( ! empty( $cookie['source'] ) ) {
561 $transformed['source'] = sanitize_key( (string) $cookie['source'] );
562 }
563
564 // Carry the resolved-first-party marker so the declared-cookie merge and the
565 // assisted-scan index can still match by name alone when the domain is on a different label.
566 if ( Cookie_Identity::is_first_party( $cookie ) ) {
567 $transformed[ Cookie_Identity::FIRST_PARTY_FLAG ] = true;
568 }
569
570 return $transformed;
571 }
572
573 /**
574 * Resolve the display provider for a scanned cookie.
575 *
576 * The scan API reports the vendor under 'owner' (mirrored in 'vendor'); both are null
577 * when no approved canonical exists yet. 'set_via' (the setter script's domain) is a
578 * usable last resort so the column is not left blank.
579 *
580 * @param array<string, mixed> $cookie Raw cookie data from API.
581 * @since 1.3.0
582 * @return string Provider name, or an empty string when nothing is known.
583 */
584 private function resolve_cookie_provider( array $cookie ): string {
585 // Classified vendor wins, then the bundled catalog (a real name like "Google
586 // Analytics" where set_via only has a hostname; consulting it keeps a re-scan from
587 // downgrading a row the catalog backfill resolved). set_via is the last resort.
588 $candidates = [
589 $cookie['owner'] ?? null,
590 $cookie['vendor'] ?? null,
591 Declared_Cookies::get_instance()->catalog_provider_for( $cookie ),
592 $cookie['set_via'] ?? null,
593 ];
594
595 foreach ( $candidates as $candidate ) {
596 if ( ! is_string( $candidate ) ) {
597 continue;
598 }
599
600 $provider = sanitize_text_field( trim( $candidate ) );
601 if ( $provider !== '' ) {
602 return $provider;
603 }
604 }
605
606 return '';
607 }
608
609 /**
610 * Store cookies from scan, merging with existing ones (same signature_id replaces,
611 * otherwise add).
612 *
613 * A cookie is re-bucketed into the category this scan reports, which would discard a
614 * category the admin assigned by hand (even for a cookie gone for a few scans and now
615 * back). So remembered assignments are re-applied to the reported set before merging.
616 *
617 * @param array<string, array<int, array<string, mixed>>> $new_cookies New cookies by category.
618 * @param string $scan_type Scanner that produced this set.
619 * @param bool $record_history Whether to stamp the scan-history record.
620 * False for one page of a multi-part walk, whose
621 * history belongs to the walk, not to the page -
622 * otherwise `total_scans` would count every page
623 * as a separate scan.
624 * @since 0.0.1
625 * @since 1.3.0 Added `$scan_type` and `$record_history`.
626 * @return void
627 */
628 private function store_all_cookies_from_agent_app( array $new_cookies, string $scan_type = 'saas', bool $record_history = true ): void {
629 $new_cookies = CookieCategoryMemory::apply( $new_cookies );
630
631 // Get existing cookies.
632 $existing_cookies = get_option( SURECOOKIE_SCANNED_COOKIES_OPTION, [] );
633 if ( ! is_array( $existing_cookies ) ) {
634 $existing_cookies = [];
635 }
636
637 // Initialize all categories.
638 $default_categories = Get::default_cookie_categories_keys();
639 foreach ( $default_categories as $category ) {
640 if ( ! isset( $existing_cookies[ $category ] ) ) {
641 $existing_cookies[ $category ] = [];
642 }
643 }
644
645 // Track changes.
646 $new_count = 0;
647 $updated_count = 0;
648
649 // Process each new cookie.
650 foreach ( $new_cookies as $category => $cookies ) {
651 foreach ( $cookies as $new_cookie ) {
652 $signature_id = $new_cookie['signature_id'] ?? '';
653
654 if ( empty( $signature_id ) ) {
655 continue; // Skip cookies without signature.
656 }
657
658 // A catalog stand-in for a cookie already observed for real adds
659 // nothing and would sit beside it as a duplicate.
660 if ( $this->is_covered_by_observation( $existing_cookies, $new_cookie ) ) {
661 continue;
662 }
663
664 // A richer cloud scan absorbs the browser-collected row it supersedes. An
665 // assisted scan mints its own id for a cookie it saw first, and the cloud
666 // scanner derives ids server-side from data a browser can't see, so the ids
667 // never agree - without this the cookie stores under both and prints twice.
668 $incoming_identity = Cookie_Identity::key_for( $new_cookie );
669 $incoming_assisted = Normalizer::is_assisted_signature( (string) $signature_id );
670 $incoming_observed = self::is_observation_row( $new_cookie );
671 $absorb_assisted = $scan_type === 'saas' && ! $incoming_assisted;
672
673 // Remove the rows this cookie replaces, from ALL categories: the same
674 // signature_id (same cookie seen again); a catalog-declared row for the same
675 // cookie (stored under the catalog's `declared:<service>:<name>` id, so a
676 // signature match never finds it and it used to survive alongside the observed
677 // row, listing the cookie twice - merge_declared_cookies() absorbs it when both
678 // land in one scan); and a browser-collected `assisted:` row a cloud scan
679 // supersedes. Collected first, removed after: re-indexing mid-iteration would
680 // invalidate the indexes still to be visited.
681 $found_existing = false;
682 $to_remove = [];
683 $replaced = null;
684
685 foreach ( $existing_cookies as $existing_category => $existing_category_cookies ) {
686 foreach ( $existing_category_cookies as $index => $existing_cookie ) {
687 $existing_signature = (string) ( $existing_cookie['signature_id'] ?? '' );
688 $same_signature = $existing_signature === $signature_id;
689 $superseded = $absorb_assisted
690 && Normalizer::is_assisted_signature( $existing_signature )
691 && Cookie_Identity::key_for( $existing_cookie ) === $incoming_identity;
692
693 // Same cookie, new signature id: the scan API hashes a TTL bucket
694 // into that id, so an unchanged cookie can return a different one
695 // and evicting by id alone appended a second row every scan.
696 // Same-provenance only, so assisted never overwrites cloud.
697 $resurveyed = $incoming_observed
698 && self::is_observation_row( $existing_cookie )
699 && $incoming_assisted === Normalizer::is_assisted_signature( $existing_signature )
700 && Cookie_Identity::key_for( $existing_cookie ) === $incoming_identity;
701
702 if ( $same_signature || $superseded || $resurveyed || $this->supersedes_declared( $existing_cookie, $new_cookie ) ) {
703 $to_remove[ $existing_category ][] = $index;
704 $found_existing = true;
705 $replaced = $replaced ?? $existing_cookie;
706 }
707 }
708 }
709
710 foreach ( $to_remove as $existing_category => $indexes ) {
711 foreach ( $indexes as $index ) {
712 unset( $existing_cookies[ $existing_category ][ $index ] );
713 }
714 $existing_cookies[ $existing_category ] = array_values( $existing_cookies[ $existing_category ] );
715 }
716
717 if ( is_array( $replaced ) ) {
718 $new_cookie = self::inherit_from_replaced( $new_cookie, $replaced );
719 }
720
721 // Bucket by the possibly-inherited category, not the scan's.
722 $target_category = (string) ( $new_cookie['category'] ?? $category );
723
724 if ( ! isset( $existing_cookies[ $target_category ] ) ) {
725 $existing_cookies[ $target_category ] = [];
726 }
727
728 $existing_cookies[ $target_category ][] = $new_cookie;
729
730 // Track if new or updated.
731 if ( $found_existing ) {
732 $updated_count++;
733 } else {
734 $new_count++;
735 }
736 }
737 }
738
739 // Save to database.
740 Update::option( SURECOOKIE_SCANNED_COOKIES_OPTION, $existing_cookies );
741
742 // Update scan history.
743 $total_cookies = $this->get_cookies_count( $existing_cookies );
744 if ( $record_history ) {
745 $this->update_scan_history( $total_cookies, $scan_type );
746 }
747
748 // Log results.
749 if ( $new_count > 0 && $updated_count > 0 ) {
750 Logger::get_instance()->save_log( sprintf( '%d new, %d updated (total: %d).', $new_count, $updated_count, $total_cookies ) );
751 } elseif ( $new_count > 0 ) {
752 Logger::get_instance()->save_log( sprintf( '%d new (total: %d).', $new_count, $total_cookies ) );
753 } elseif ( $updated_count > 0 ) {
754 Logger::get_instance()->save_log( sprintf( '%d updated (total: %d).', $updated_count, $total_cookies ) );
755 } else {
756 Logger::get_instance()->save_log( sprintf( 'No changes (total: %d).', $total_cookies ) );
757 }
758 }
759
760 /**
761 * Whether one of two rows for the same cookie supersedes the other, so only one is kept.
762 *
763 * Exactly one of the pair may be catalog-declared, and the observed row always wins:
764 * it carries the runtime attributes, and merge_declared_cookies() already folded the
765 * curated classification onto it when both landed in one scan. Checked BOTH directions
766 * since either can be the stored one - declared first then observed (embed blocked, then
767 * loaded), or observed first then declared (script seen but cookie unset: consent-gated
768 * tags, delay-until-interaction optimisers, challenge pages).
769 *
770 * Identity is the exact name+domain key only. An earlier revision also matched name
771 * alone for a first-party declared row (to catch shop.example.com scoping `_ga` to
772 * .example.com), but absorbing it deletes a row the admin may have categorised while
773 * CookieCategoryMemory still keys the pin to the old domain, orphaning the pin so the
774 * category silently reverts next scan. Losing a compliance decision beats a duplicate
775 * row, so that case waits on a change that moves the memory key too - see issue #876.
776 *
777 * @param array<string, mixed> $existing Stored cookie row.
778 * @param array<string, mixed> $incoming Cookie about to be stored.
779 * @since 1.3.0
780 * @return bool
781 */
782 private function supersedes_declared( array $existing, array $incoming ): bool {
783 // Stored row is the catalog stand-in and incoming is a real observation, so the
784 // observation replaces it. Two declared rows dedupe by deterministic signature;
785 // two observed rows pair up on identity via `$resurveyed` in the caller.
786 if ( ! self::is_declared_row( $existing ) || self::is_declared_row( $incoming ) ) {
787 return false;
788 }
789
790 return Cookie_Identity::key_for( $existing ) === Cookie_Identity::key_for( $incoming );
791 }
792
793 /**
794 * Whether an incoming catalog-declared cookie is already covered by a stored
795 * observation, and so should not be stored at all.
796 *
797 * Mirror image of {@see self::supersedes_declared()}. A scan can detect a service's
798 * script without the cookie being set (consent-gated tag, delay-until-interaction
799 * optimiser, challenge page), then the catalog declares a cookie a previous scan
800 * already observed - the same duplicate seen from the other side.
801 *
802 * @param array<string, array<int, array<string, mixed>>> $existing_cookies Stored cookies by category.
803 * @param array<string, mixed> $incoming Cookie about to be stored.
804 * @since 1.3.0
805 * @return bool
806 */
807 private function is_covered_by_observation( array $existing_cookies, array $incoming ): bool {
808 if ( ! self::is_declared_row( $incoming ) ) {
809 return false;
810 }
811
812 $identity = Cookie_Identity::key_for( $incoming );
813
814 foreach ( $existing_cookies as $rows ) {
815 foreach ( $rows as $existing ) {
816 if ( self::is_declared_row( $existing ) ) {
817 continue;
818 }
819
820 if ( Cookie_Identity::key_for( $existing ) === $identity ) {
821 return true;
822 }
823 }
824 }
825
826 return false;
827 }
828
829 /**
830 * Whether a stored row came from the Known Services catalog rather than from an
831 * observation or the administrator.
832 *
833 * Checks the signature prefix as well as `source`, so rows written before the
834 * `source` marker existed are still recognised.
835 *
836 * @param array<string, mixed> $row Stored cookie row.
837 * @since 1.3.0
838 * @return bool
839 */
840 private static function is_declared_row( array $row ): bool {
841 if ( ( $row['source'] ?? '' ) === 'declared' ) {
842 return true;
843 }
844
845 return strncmp( (string) ( $row['signature_id'] ?? '' ), 'declared:', 9 ) === 0;
846 }
847
848 /**
849 * Fill blanks in a row from the row it replaces, so a re-scan enriches rather
850 * than strips it.
851 *
852 * The scan API sends classification only when the SaaS resolved a canonical, so
853 * a re-scan can arrive with no provider/purpose/description and an
854 * `uncategorized` category. Cloud-path counterpart of
855 * {@see Normalizer::merge_without_downgrade()}, on the stored row shape.
856 *
857 * @param array<string, mixed> $incoming Row about to be stored.
858 * @param array<string, mixed> $existing Row it replaces.
859 * @since 1.3.1
860 * @return array<string, mixed>
861 */
862 private static function inherit_from_replaced( array $incoming, array $existing ): array {
863 foreach ( [ 'provider', 'purpose', 'description', 'expires' ] as $field ) {
864 if ( self::is_blank_field( $incoming[ $field ] ?? null ) && ! self::is_blank_field( $existing[ $field ] ?? null ) ) {
865 $incoming[ $field ] = $existing[ $field ];
866 }
867 }
868
869 // An omitted flag and an explicit false are indistinguishable after
870 // transform, so never turn a stored true into a false.
871 foreach ( [ 'httpOnly', 'secure' ] as $flag ) {
872 if ( ! empty( $existing[ $flag ] ) ) {
873 $incoming[ $flag ] = true;
874 }
875 }
876
877 // `uncategorized` means the scan had no classification, not a decision. An
878 // admin pin already sits on $incoming via CookieCategoryMemory and wins.
879 $existing_category = (string) ( $existing['category'] ?? '' );
880
881 if ( ( $incoming['category'] ?? '' ) === 'uncategorized'
882 && $existing_category !== ''
883 && $existing_category !== 'uncategorized'
884 && CookieCategoryMemory::remembered_category( $incoming ) === ''
885 ) {
886 $incoming['category'] = $existing_category;
887 }
888
889 return $incoming;
890 }
891
892 /**
893 * Whether a stored field carries no usable value.
894 *
895 * @param mixed $value Field value.
896 * @since 1.3.1
897 * @return bool
898 */
899 private static function is_blank_field( $value ): bool {
900 return $value === null || ( is_string( $value ) && trim( $value ) === '' );
901 }
902
903 /**
904 * Whether a row is a scan observation, and so may be replaced when a later scan
905 * re-observes the same cookie.
906 *
907 * Excludes catalog-declared rows (paired by {@see self::supersedes_declared()})
908 * and hand-added `custom_` rows, which a scan must never drop.
909 *
910 * @param array<string, mixed> $row Cookie row.
911 * @since 1.3.1
912 * @return bool
913 */
914 private static function is_observation_row( array $row ): bool {
915 if ( self::is_declared_row( $row ) ) {
916 return false;
917 }
918
919 return strncmp( (string) ( $row['signature_id'] ?? '' ), 'custom_', 7 ) !== 0;
920 }
921
922 /**
923 * Get cookies count.
924 *
925 * @param array<string, array<int, array<string, mixed>>> $all_cookies All cookies array.
926 * @since 0.0.1
927 * @return int
928 */
929 private function get_cookies_count( array $all_cookies ): int {
930 if ( empty( $all_cookies ) ) {
931 return 0;
932 }
933
934 $count = 0;
935 foreach ( $all_cookies as $cookies ) {
936 $count += count( $cookies );
937 }
938
939 return $count;
940 }
941
942 /**
943 * Update scan history option.
944 *
945 * Stores only the latest scan record.
946 *
947 * @param int $cookies_count Number of cookies found.
948 * @param string $scan_type Scanner that produced this set.
949 * @since 0.0.1
950 * @since 1.3.0 Added `$scan_type`.
951 * @return void
952 */
953 private function update_scan_history( int $cookies_count, string $scan_type = 'saas' ): void {
954 $option = get_option( SURECOOKIE_SCANNED_DETAILS_OPTION, [] );
955
956 if ( ! is_array( $option ) ) {
957 $option = [];
958 }
959
960 $total_scans = isset( $option['total_scans'] ) ? (int) $option['total_scans'] : 0;
961
962 // Merge (don't replace) so the change-detection keys (reported_snapshot/changes)
963 // written by the Automatic Scanning recorder survive this basic-field update and
964 // stay available as the next scan's diff baseline.
965 $history = array_merge(
966 $option,
967 [
968 'date' => current_time( 'mysql' ),
969 'cookies_count' => $cookies_count,
970 'scan_type' => $scan_type,
971 'total_scans' => $total_scans + 1, // Required for future analytics.
972 'success' => true,
973 ]
974 );
975
976 Update::option( SURECOOKIE_SCANNED_DETAILS_OPTION, $history );
977 }
978 }
979