| 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 |
|