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

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