PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / modules / assisted-scan / session.php

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

601 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Assisted Scan session state.
4 *
5 * Owns the page queue for one assisted walk; the server holds it and the browser
6 * only asks "what next?", so the existing scan-status/scan-log UI reports an
7 * assisted run with no new polling endpoints.
8 *
9 * Stored in a non-autoloaded option, not a transient: an object cache may evict a
10 * transient and abandon the walk, so the 30-minute TTL is enforced in code
11 * (matching `surecookie_active_scan` in the site-scanner module).
12 *
13 * Findings are stored already-normalized and deduplicated, so the option is
14 * bounded by unique cookies/resources, not pages times payload size.
15 *
16 * @package SureCookie\Inc\Modules\AssistedScan
17 * @since 1.3.0
18 */
19
20 namespace SureCookie\Inc\Modules\AssistedScan;
21
22 use SureCookie\Inc\Functions\Cookie_Identity;
23 use SureCookie\Inc\Functions\Update;
24 use SureCookie\Inc\Modules\SiteScanner\SaasClient;
25 use SureCookie\Inc\Traits\GetInstance;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit; // Exit if accessed directly.
29 }
30
31 /**
32 * Session
33 *
34 * @since 1.3.0
35 */
36 class Session {
37 use GetInstance;
38
39 /**
40 * Option holding the active session. Non-autoloaded; deleted when the walk ends.
41 *
42 * @since 1.3.0
43 */
44 public const STATE_OPTION = 'surecookie_assisted_scan';
45
46 /**
47 * How long a session may stay open, in seconds.
48 *
49 * @since 1.3.0
50 */
51 public const TTL = 1800;
52
53 /**
54 * Cron hook that finalizes a session the browser never finished.
55 *
56 * @since 1.3.0
57 */
58 public const FINALIZE_HOOK = 'surecookie_assisted_scan_finalize';
59
60 /**
61 * Query argument carrying the session token on a scan page request.
62 *
63 * @since 1.3.0
64 */
65 public const TOKEN_ARG = 'surecookie_scan';
66
67 /**
68 * Query argument carrying the index of the page being collected.
69 *
70 * @since 1.3.0
71 */
72 public const PAGE_ARG = 'surecookie_scan_page';
73
74 /**
75 * Query argument marking the post-cookie-reset load of the first page.
76 *
77 * @since 1.3.0
78 */
79 public const RESET_ARG = 'surecookie_scan_reset';
80
81 /**
82 * Per-page ceilings on what the browser may submit.
83 *
84 * A page carrying more is pathological; the caps stop a compromised or looping
85 * collector from growing the option without bound.
86 *
87 * @since 1.3.0
88 */
89 public const MAX_COOKIES_PER_PAGE = 150;
90 public const MAX_RESOURCES_PER_PAGE = 300;
91
92 /**
93 * Marks a walk that has just ended, so the screen reports a terminal state for a
94 * moment instead of jumping straight to idle. The UI runs its completion path
95 * (final log fetch, toast, cache invalidation) off that transition, and an
96 * assisted walk deletes its session the instant it finishes.
97 *
98 * @since 1.3.0
99 */
100 public const JUST_FINISHED_TRANSIENT = 'surecookie_assisted_scan_just_finished';
101
102 /**
103 * How long the marker lives. It only has to outlast one status poll (3-6s), so
104 * this is generous already; the screen ignores it unless it saw the walk running.
105 *
106 * @since 1.3.0
107 */
108 public const JUST_FINISHED_TTL = 45;
109
110 /**
111 * Read the stored session.
112 *
113 * @since 1.3.0
114 * @return array<string, mixed> Session state, or an empty array when none is stored.
115 */
116 public function get(): array {
117 $state = get_option( self::STATE_OPTION, [] );
118
119 return is_array( $state ) ? $state : [];
120 }
121
122 /**
123 * Whether a usable session is open.
124 *
125 * @since 1.3.0
126 * @return bool
127 */
128 public function is_active(): bool {
129 $state = $this->get();
130
131 return ! empty( $state['token'] ) && ! $this->is_stale( $state );
132 }
133
134 /**
135 * Whether a session has outlived {@see self::TTL}.
136 *
137 * Measured from last activity, not start, so a slow but progressing walk is
138 * never cut off mid-way.
139 *
140 * @param array<string, mixed>|null $state Session state, read when omitted.
141 * @since 1.3.0
142 * @return bool
143 */
144 public function is_stale( ?array $state = null ): bool {
145 $state = $state ?? $this->get();
146
147 if ( empty( $state['token'] ) ) {
148 return false;
149 }
150
151 $updated = (int) ( $state['updated_at'] ?? $state['started_at'] ?? 0 );
152
153 return $updated > 0 && ( time() - $updated ) > self::TTL;
154 }
155
156 /**
157 * Open a session for the given pages.
158 *
159 * Callers must reject a start while {@see self::is_active()} (so the admin sees
160 * "already running"); this method does not adopt an in-flight walk's state.
161 *
162 * @param array<int, array<string, mixed>> $pages Pages to walk: `url`, `post_id`, `title`.
163 * @since 1.3.0
164 * @return array<string, mixed> The new session state, or an empty array when there is nothing to walk.
165 */
166 public function start( array $pages ): array {
167 $queue = [];
168
169 foreach ( $pages as $page ) {
170 if ( ! is_array( $page ) || empty( $page['url'] ) ) {
171 continue;
172 }
173
174 $queue[] = [
175 'url' => esc_url_raw( (string) $page['url'] ),
176 'post_id' => absint( $page['post_id'] ?? 0 ),
177 'title' => sanitize_text_field( (string) ( $page['title'] ?? '' ) ),
178 'status' => 'pending',
179 'cookies' => 0,
180 'scripts' => 0,
181 'iframes' => 0,
182 'error' => '',
183 ];
184 }
185
186 // Nothing walkable: persist nothing. A token with an empty queue keeps
187 // is_active() true for the whole TTL, locking the admin out of scanning.
188 if ( $queue === [] ) {
189 return [];
190 }
191
192 $now = time();
193
194 $state = [
195 'token' => bin2hex( random_bytes( 32 ) ),
196 // Bound to the administrator who started it; enforced in verify_token().
197 'user_id' => get_current_user_id(),
198 'started_at' => $now,
199 'updated_at' => $now,
200 'current' => 0,
201 'pages' => $queue,
202 'cookies' => [],
203 'scripts' => [],
204 'iframes' => [],
205 'services' => [],
206 ];
207
208 Update::option( self::STATE_OPTION, $state );
209 $this->arm_safety_net( $now );
210
211 return $state;
212 }
213
214 /**
215 * Whether the request may drive the current walk.
216 *
217 * Constant-time token comparison plus an owner check. The owner check matters
218 * because the token travels in the scan page's query string, so it leaks into
219 * server access logs and the `Referer` of every third-party request that page
220 * makes; it narrows a leaked token from any administrator to the one who
221 * started the walk.
222 *
223 * @param string $token Token from the request.
224 * @since 1.3.0
225 * @return bool
226 */
227 public function verify_token( string $token ): bool {
228 if ( preg_match( '/^[0-9a-f]{64}$/', $token ) !== 1 ) {
229 return false;
230 }
231
232 $state = $this->get();
233 $known = (string) ( $state['token'] ?? '' );
234
235 if ( $known === '' || $this->is_stale( $state ) ) {
236 return false;
237 }
238
239 if ( ! hash_equals( $known, $token ) ) {
240 return false;
241 }
242
243 $owner = (int) ( $state['user_id'] ?? 0 );
244
245 return $owner > 0 && $owner === get_current_user_id();
246 }
247
248 /**
249 * The index of the page the walk expects next.
250 *
251 * @since 1.3.0
252 * @return int
253 */
254 public function current_index(): int {
255 return (int) ( $this->get()['current'] ?? 0 );
256 }
257
258 /**
259 * The public URL a given page must be collected from.
260 *
261 * The token doubles as a cache buster: unique per session, so a page cache
262 * cannot serve a copy that lacks the collector.
263 *
264 * @param int $index Page index.
265 * @param array<string, mixed>|null $state Session state, read when omitted.
266 * @since 1.3.0
267 * @return string Scan URL, or an empty string when the index is out of range.
268 */
269 public function scan_url( int $index, ?array $state = null ): string {
270 $state = $state ?? $this->get();
271 $page = $state['pages'][ $index ] ?? null;
272
273 if ( ! is_array( $page ) || empty( $page['url'] ) ) {
274 return '';
275 }
276
277 return add_query_arg(
278 [
279 self::TOKEN_ARG => (string) ( $state['token'] ?? '' ),
280 self::PAGE_ARG => $index,
281 ],
282 (string) $page['url']
283 );
284 }
285
286 /**
287 * The URL of the next uncollected page, if the walk continues.
288 *
289 * @param array<string, mixed>|null $state Session state, read when omitted.
290 * @since 1.3.0
291 * @return string Next scan URL, or an empty string when the walk is done.
292 */
293 public function next_url( ?array $state = null ): string {
294 $state = $state ?? $this->get();
295 $index = (int) ( $state['current'] ?? 0 );
296
297 if ( ! isset( $state['pages'][ $index ] ) ) {
298 return '';
299 }
300
301 return $this->scan_url( $index, $state );
302 }
303
304 /**
305 * Record one page's findings and advance the queue.
306 *
307 * Rejects any index other than the one expected, which blocks a replayed or
308 * out-of-order submission from corrupting the walk.
309 *
310 * @param int $index Page index being reported.
311 * @param array<int, array<string, mixed>> $cookies Normalized cookies for this page.
312 * @param array<string, mixed> $resources Normalized `scripts` and `iframes` for this page.
313 * @param string $error Failure reason, when the page could not be collected.
314 * @since 1.3.0
315 * @return bool True when the page was recorded.
316 */
317 public function record_page( int $index, array $cookies, array $resources, string $error = '' ): bool {
318 $state = $this->get();
319
320 if ( empty( $state['token'] ) || ! isset( $state['pages'][ $index ] ) ) {
321 return false;
322 }
323
324 if ( (int) ( $state['current'] ?? 0 ) !== $index ) {
325 return false;
326 }
327
328 $scripts = is_array( $resources['scripts'] ?? null ) ? $resources['scripts'] : [];
329 $iframes = is_array( $resources['iframes'] ?? null ) ? $resources['iframes'] : [];
330
331 // Accumulate deduplicated, so the option tracks unique findings rather
332 // than growing with every page that repeats the same tags.
333 foreach ( $cookies as $cookie ) {
334 if ( is_array( $cookie ) && ! empty( $cookie['name'] ) ) {
335 $state['cookies'][ Cookie_Identity::key_for( $cookie ) ] = $cookie;
336 }
337 }
338
339 foreach ( [ 'scripts', 'iframes' ] as $kind ) {
340 $entries = $kind === 'scripts' ? $scripts : $iframes;
341 foreach ( $entries as $entry ) {
342 if ( is_array( $entry ) && ! empty( $entry['domain'] ) ) {
343 $domain = (string) $entry['domain'];
344 if ( ! isset( $state[ $kind ][ $domain ] ) ) {
345 $state[ $kind ][ $domain ] = $entry;
346 }
347 }
348 }
349 }
350
351 $state['pages'][ $index ]['status'] = $error === '' ? 'done' : 'failed';
352 $state['pages'][ $index ]['error'] = sanitize_text_field( $error );
353 $state['pages'][ $index ]['cookies'] = count( $cookies );
354 $state['pages'][ $index ]['scripts'] = count( $scripts );
355 $state['pages'][ $index ]['iframes'] = count( $iframes );
356
357 $now = time();
358
359 $state['current'] = $index + 1;
360 $state['updated_at'] = $now;
361
362 Update::option( self::STATE_OPTION, $state );
363
364 // Push the rescue event past the new deadline; staleness is measured
365 // from this write, so a stale one-shot would fire while still live.
366 $this->arm_safety_net( $now );
367
368 return true;
369 }
370
371 /**
372 * Whether every page in the queue has been attempted.
373 *
374 * @param array<string, mixed>|null $state Session state, read when omitted.
375 * @since 1.3.0
376 * @return bool
377 */
378 public function is_complete( ?array $state = null ): bool {
379 $state = $state ?? $this->get();
380
381 if ( empty( $state['token'] ) ) {
382 return false;
383 }
384
385 return (int) ( $state['current'] ?? 0 ) >= count( $state['pages'] ?? [] );
386 }
387
388 /**
389 * Progress in the shape the existing scan-status payload uses.
390 *
391 * Mirrors the SaaS status contract so `useScanStatus` and the scanning-logs
392 * drawer render an assisted walk without changes.
393 *
394 * @param array<string, mixed>|null $state Session state, read when omitted.
395 * @since 1.3.0
396 * @return array<string, mixed>
397 */
398 public function progress( ?array $state = null ): array {
399 $state = $state ?? $this->get();
400 $pages = is_array( $state['pages'] ?? null ) ? $state['pages'] : [];
401
402 $completed = 0;
403 $failed = 0;
404
405 foreach ( $pages as $page ) {
406 $status = (string) ( $page['status'] ?? 'pending' );
407 if ( $status === 'done' ) {
408 $completed++;
409 } elseif ( $status === 'failed' ) {
410 $failed++;
411 }
412 }
413
414 return [
415 'total_pages' => count( $pages ),
416 'completed_pages' => $completed,
417 'failed_pages' => $failed,
418 'current_phase' => $this->is_complete( $state ) ? 'finalizing' : 'capturing',
419 ];
420 }
421
422 /**
423 * The walk's status, in the shape the scanner status endpoint already returns.
424 *
425 * Mirrors the cloud contract key for key, so the existing progress bar, stall
426 * detection, scanning-logs drawer and completion toast report an assisted walk
427 * with no new hooks or React state; `scan_mode` and `pages` are additive.
428 *
429 * @param array<string, mixed>|null $state Session state, read when omitted.
430 * @since 1.3.0
431 * @return array<string, mixed>
432 */
433 public function status_payload( ?array $state = null ): array {
434 $state = $state ?? $this->get();
435 $pages = is_array( $state['pages'] ?? null ) ? $state['pages'] : [];
436
437 $errors = [];
438 foreach ( $pages as $page ) {
439 if ( ( $page['status'] ?? '' ) === 'failed' ) {
440 $errors[] = [
441 'url' => (string) ( $page['url'] ?? '' ),
442 'error' => (string) ( $page['error'] ?? '' ),
443 ];
444 }
445 }
446
447 $started = (int) ( $state['started_at'] ?? time() );
448 $updated = (int) ( $state['updated_at'] ?? $started );
449
450 return [
451 'in_progress' => true,
452 'status' => 'running',
453 // Lets the UI label the run honestly and keep the scan-window handle
454 // alive, rather than presenting a browser walk as a cloud scan.
455 'scan_mode' => 'assisted',
456 // The page the walk is waiting on. Stated, not derived, because the stall
457 // watchdog must submit exactly this index (anything else is rejected).
458 'current_index' => (int) ( $state['current'] ?? 0 ),
459 'pages_count' => count( $pages ),
460 'elapsed' => max( 0, time() - $started ),
461 'progress' => $this->progress( $state ),
462 'error_details' => $errors,
463 'seconds_since_last_progress' => max( 0, time() - $updated ),
464 // The per-page list the admin screen shows beneath the controls.
465 'pages' => array_values( $pages ),
466 'message' => __( 'Assisted scan in progress...', 'surecookie' ),
467 ];
468 }
469
470 /**
471 * The deduplicated cookies collected so far.
472 *
473 * @param array<string, mixed>|null $state Session state, read when omitted.
474 * @since 1.3.0
475 * @return array<int, array<string, mixed>>
476 */
477 public function collected_cookies( ?array $state = null ): array {
478 $state = $state ?? $this->get();
479
480 return array_values( is_array( $state['cookies'] ?? null ) ? $state['cookies'] : [] );
481 }
482
483 /**
484 * The deduplicated resources collected so far.
485 *
486 * @param array<string, mixed>|null $state Session state, read when omitted.
487 * @since 1.3.0
488 * @return array{scripts: array<int, array<string, mixed>>, iframes: array<int, array<string, mixed>>}
489 */
490 public function collected_resources( ?array $state = null ): array {
491 $state = $state ?? $this->get();
492
493 return [
494 'scripts' => array_values( is_array( $state['scripts'] ?? null ) ? $state['scripts'] : [] ),
495 'iframes' => array_values( is_array( $state['iframes'] ?? null ) ? $state['iframes'] : [] ),
496 ];
497 }
498
499 /**
500 * Close the session and drop the safety-net event.
501 *
502 * @since 1.3.0
503 * @return void
504 */
505 public function clear(): void {
506 delete_option( self::STATE_OPTION );
507 wp_clear_scheduled_hook( self::FINALIZE_HOOK );
508 }
509
510 /**
511 * Record that a walk just ended.
512 *
513 * @since 1.3.0
514 * @param array<string, mixed> $progress Final progress payload.
515 * @param array<int, array<string, string>> $errors Per-page failures.
516 * @return void
517 */
518 public function mark_just_finished( array $progress, array $errors = [] ): void {
519 set_transient(
520 self::JUST_FINISHED_TRANSIENT,
521 [
522 'progress' => $progress,
523 'errors' => $errors,
524 'at' => time(),
525 ],
526 self::JUST_FINISHED_TTL
527 );
528 }
529
530 /**
531 * Drop the terminal marker, so a cancelled walk cannot report itself finished.
532 *
533 * @since 1.3.0
534 * @return void
535 */
536 public function clear_just_finished(): void {
537 delete_transient( self::JUST_FINISHED_TRANSIENT );
538 }
539
540 /**
541 * Terminal status for a walk that just ended, or null when there isn't one.
542 *
543 * Same shape as a finished cloud scan, so the screen's completion handling works
544 * for both. Deliberately not consumed on read: clearing it here would make the
545 * status non-idempotent, and two polls landing together would report `completed`
546 * then `idle` fast enough for the screen to miss the transition entirely, which
547 * is the failure this exists to prevent. It expires on its own, and the screen
548 * already guards against running its completion path twice.
549 *
550 * @since 1.3.0
551 * @return array<string, mixed>|null
552 */
553 public function peek_just_finished(): ?array {
554 $marker = get_transient( self::JUST_FINISHED_TRANSIENT );
555
556 if ( ! is_array( $marker ) ) {
557 return null;
558 }
559
560 $progress = is_array( $marker['progress'] ?? null ) ? $marker['progress'] : [];
561 $errors = is_array( $marker['errors'] ?? null ) ? $marker['errors'] : [];
562 $outcome = get_transient( SaasClient::LAST_OUTCOME_TRANSIENT );
563
564 return [
565 'in_progress' => false,
566 // Deliberately still 'idle'. A terminal status keeps the progress section
567 // on screen showing a finished bar for as long as the marker lives, which
568 // reads as a scan that never clears. The dedicated flag below is what the
569 // completion handling keys on.
570 'status' => 'idle',
571 'just_finished' => true,
572 'scan_mode' => 'assisted',
573 'pages_count' => (int) ( $progress['total_pages'] ?? 0 ),
574 'progress' => $progress,
575 'error_details' => $errors,
576 'message' => __( 'Assisted scan finished.', 'surecookie' ),
577 'last_outcome' => is_array( $outcome ) ? $outcome : null,
578 ];
579 }
580
581 /**
582 * (Re)schedule the finalize event that rescues an abandoned walk.
583 *
584 * Cleared first because WordPress silently refuses to schedule a duplicate hook
585 * within ten minutes, so a stale event would otherwise fire against the session
586 * that replaced it.
587 *
588 * Re-armed on every recorded page so it sits just past the staleness deadline,
589 * which is measured from last activity; anchoring to the start would let this
590 * one-shot fire while the session is still live and then never fire again.
591 *
592 * @param int $from Timestamp the deadline is measured from.
593 * @since 1.3.0
594 * @return void
595 */
596 private function arm_safety_net( int $from ): void {
597 wp_clear_scheduled_hook( self::FINALIZE_HOOK );
598 wp_schedule_single_event( $from + self::TTL + 60, self::FINALIZE_HOOK );
599 }
600 }
601