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 / normalizer.php

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

697 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Turn raw browser findings into the shape the scan-results pipeline expects.
4 *
5 * Pure and static: no HTTP or session state, only one option read for the
6 * existing-cookie index. It holds the rules that decide whether an assisted scan
7 * improves the cookie inventory or quietly corrupts it.
8 *
9 * Two rules are load-bearing:
10 *
11 * 1. Signature reuse. {@see Sync::store_all_cookies_from_agent_app()} replaces
12 * strictly by `signature_id`, so a fresh id for a cookie the cloud scanner
13 * already recorded would store BOTH rows and print the cookie twice on the
14 * public policy.
15 * 2. No-downgrade merge. Reusing the cloud row's id means the assisted row
16 * replaces it, so anything the browser cannot see (e.g. HttpOnly, invisible
17 * to `document.cookie`) must be inherited, not overwritten.
18 *
19 * @package SureCookie\Inc\Modules\AssistedScan
20 * @since 1.3.0
21 */
22
23 namespace SureCookie\Inc\Modules\AssistedScan;
24
25 use SureCookie\Inc\Functions\Cookie_Identity;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit; // Exit if accessed directly.
29 }
30
31 /**
32 * Normalizer
33 *
34 * @since 1.3.0
35 */
36 class Normalizer {
37 /**
38 * Marks a cookie row as collected by a browser rather than the cloud scanner.
39 *
40 * @since 1.3.0
41 */
42 public const SOURCE = 'assisted';
43
44 /**
45 * Prefix for the synthetic signature ids minted for genuinely new cookies.
46 *
47 * A stored id carrying this prefix is what lets a later cloud scan recognise
48 * the row as browser-collected and absorb it instead of duplicating it.
49 *
50 * @since 1.3.0
51 */
52 public const SIGNATURE_PREFIX = 'assisted:';
53
54 /**
55 * Cookie name prefixes that are never collected.
56 *
57 * WordPress auth and admin-preference cookies. An assisted scan runs as a
58 * logged-in admin, so these are always present but never part of a visitor's
59 * consent. Dropped at both layers (here and in the browser) so they cannot
60 * reach the database even if the collector is bypassed.
61 *
62 * @since 1.3.0
63 */
64 private const IGNORED_PREFIXES = [
65 'wordpress_logged_in_',
66 'wordpress_sec_',
67 'wordpress_test_cookie',
68 'wp-settings-',
69 'wp-settings-time-',
70 ];
71
72 /**
73 * Cookie name prefixes that are never collected, plus any added by filter.
74 *
75 * @since 1.3.0
76 * @return array<int, string> Lower-cased prefixes.
77 */
78 public static function ignored_prefixes(): array {
79 /**
80 * Filter the cookie-name prefixes an assisted scan refuses to collect.
81 *
82 * @since 1.3.0
83 * @param array<int, string> $prefixes Lower-cased cookie name prefixes.
84 */
85 $prefixes = apply_filters( 'surecookie_assisted_scan_ignored_cookies', self::IGNORED_PREFIXES );
86
87 if ( ! is_array( $prefixes ) ) {
88 return self::IGNORED_PREFIXES;
89 }
90
91 $clean = [];
92 foreach ( $prefixes as $prefix ) {
93 if ( is_string( $prefix ) && trim( $prefix ) !== '' ) {
94 $clean[] = strtolower( trim( $prefix ) );
95 }
96 }
97
98 return $clean === [] ? self::IGNORED_PREFIXES : $clean;
99 }
100
101 /**
102 * Whether a cookie name must never be collected.
103 *
104 * @param string $name Cookie name.
105 * @since 1.3.0
106 * @return bool
107 */
108 public static function is_ignored( string $name ): bool {
109 $name = strtolower( trim( $name ) );
110
111 if ( $name === '' ) {
112 return true;
113 }
114
115 foreach ( self::ignored_prefixes() as $prefix ) {
116 if ( strncmp( $name, $prefix, strlen( $prefix ) ) === 0 ) {
117 return true;
118 }
119 }
120
121 return false;
122 }
123
124 /**
125 * Parse the attribute half of a `document.cookie` assignment.
126 *
127 * The value is already stripped by the browser, so this never sees cookie
128 * contents. Per RFC 6265 `Max-Age` wins over `Expires`.
129 *
130 * `same_site` stays an empty string when no SameSite attribute was present,
131 * rather than the browser's `lax` default: `document.cookie` never reveals the
132 * effective SameSite, so defaulting would make an unobserved value look
133 * observed and let the no-downgrade merge overwrite a cloud `none` with `lax`.
134 *
135 * @param string $attributes Attribute string, e.g. `path=/; Max-Age=63072000; Secure`.
136 * @param int $now Reference timestamp, for deterministic tests.
137 * @since 1.3.0
138 * @return array{expires_at: string|null, is_deletion: bool, domain: string, path: string, secure: bool, same_site: string}
139 */
140 public static function parse_attributes( string $attributes, int $now = 0 ): array {
141 $now = $now > 0 ? $now : time();
142
143 $parsed = [
144 'expires_at' => null,
145 'is_deletion' => false,
146 'domain' => '',
147 'path' => '/',
148 'secure' => false,
149 'same_site' => '',
150 ];
151
152 $max_age = null;
153 $expires = null;
154
155 foreach ( explode( ';', $attributes ) as $part ) {
156 $part = trim( $part );
157
158 if ( $part === '' ) {
159 continue;
160 }
161
162 $split = explode( '=', $part, 2 );
163 $key = strtolower( trim( $split[0] ) );
164 $value = isset( $split[1] ) ? trim( $split[1] ) : '';
165
166 switch ( $key ) {
167 case 'max-age':
168 // Only a well-formed integer counts; anything else is ignored
169 // so a malformed Max-Age falls through to Expires.
170 if ( preg_match( '/^-?\d+$/', $value ) === 1 ) {
171 $max_age = (int) $value;
172 }
173 break;
174 case 'expires':
175 $timestamp = strtotime( $value );
176 if ( $timestamp !== false ) {
177 $expires = $timestamp;
178 }
179 break;
180 case 'domain':
181 $parsed['domain'] = strtolower( $value );
182 break;
183 case 'path':
184 $parsed['path'] = $value !== '' ? $value : '/';
185 break;
186 case 'secure':
187 $parsed['secure'] = true;
188 break;
189 case 'samesite':
190 $same_site = strtolower( $value );
191 if ( in_array( $same_site, [ 'lax', 'strict', 'none' ], true ) ) {
192 $parsed['same_site'] = $same_site;
193 }
194 break;
195 }
196 }
197
198 // Max-Age takes precedence over Expires (RFC 6265 section 4.1.2.2).
199 $absolute = null;
200 if ( $max_age !== null ) {
201 $absolute = $now + $max_age;
202 } elseif ( $expires !== null ) {
203 $absolute = $expires;
204 }
205
206 if ( $absolute === null ) {
207 // Neither attribute: a session cookie. Real, but with no lifetime.
208 return $parsed;
209 }
210
211 if ( $absolute <= $now ) {
212 // A past expiry is how JavaScript deletes a cookie. Recording it
213 // would invent a cookie the visitor never actually carries.
214 $parsed['is_deletion'] = true;
215 return $parsed;
216 }
217
218 $parsed['expires_at'] = gmdate( 'c', $absolute );
219
220 return $parsed;
221 }
222
223 /**
224 * Index the currently stored scanned cookies by their identity key.
225 *
226 * @param array<string, array<int, array<string, mixed>>> $stored Stored cookies grouped by category.
227 * @since 1.3.0
228 * @return array<string, array<string, mixed>> Identity key => stored row.
229 */
230 public static function build_existing_index( array $stored ): array {
231 $index = [];
232
233 foreach ( $stored as $cookies ) {
234 if ( ! is_array( $cookies ) ) {
235 continue;
236 }
237
238 foreach ( $cookies as $cookie ) {
239 if ( ! is_array( $cookie ) || empty( $cookie['name'] ) ) {
240 continue;
241 }
242
243 $key = Cookie_Identity::key_for( $cookie );
244
245 // First write wins, so a duplicate left by an older build cannot
246 // shadow the row that is actually being matched against.
247 if ( ! isset( $index[ $key ] ) ) {
248 $index[ $key ] = $cookie;
249 }
250
251 // A first-party cookie can be recorded against more than one label
252 // of this host, so it also gets a domain-less key that
253 // {@see self::find_existing()} falls back to. Two cases, both of
254 // which would otherwise store the cookie twice: a JS tag deriving
255 // the registrable domain (`_ga` on `.example.com` from
256 // shop.example.com), and a host-only cookie the cloud scanner
257 // records as `www.example.com` while an assisted observation
258 // resolves it to `example.com`. FIRST_PARTY_FLAG alone misses both:
259 // it is set only by the catalog paths, never by
260 // Sync::transform_cookie_data() on a scanned row, so match on the
261 // domain actually being first party instead.
262 if ( Cookie_Identity::is_first_party( $cookie )
263 || self::is_first_party_host( Cookie_Identity::normalize_domain( (string) ( $cookie['domain'] ?? '' ) ) ) ) {
264 $name_key = Cookie_Identity::name_key( (string) $cookie['name'] );
265 if ( ! isset( $index[ $name_key ] ) ) {
266 $index[ $name_key ] = $cookie;
267 }
268 }
269 }
270 }
271
272 return $index;
273 }
274
275 /**
276 * Find the stored row an incoming cookie corresponds to, if any.
277 *
278 * @param array<string, mixed> $cookie Normalized incoming cookie.
279 * @param array<string, array<string, mixed>> $index Output of {@see self::build_existing_index()}.
280 * @since 1.3.0
281 * @return array<string, mixed>|null
282 */
283 public static function find_existing( array $cookie, array $index ): ?array {
284 $key = Cookie_Identity::key_for( $cookie );
285
286 if ( isset( $index[ $key ] ) ) {
287 return $index[ $key ];
288 }
289
290 // Fall back to the domain-less key so a first-party row recorded on
291 // another label of this host is still recognised as the same cookie.
292 $name_key = Cookie_Identity::name_key( (string) ( $cookie['name'] ?? '' ) );
293
294 return $index[ $name_key ] ?? null;
295 }
296
297 /**
298 * The signature id an assisted cookie must be stored under.
299 *
300 * Reuses the id of the row this cookie already occupies so a re-scan updates it
301 * instead of adding a row. A genuinely new cookie gets a deterministic
302 * synthetic id, so repeated assisted scans stay idempotent.
303 *
304 * @param array<string, mixed> $cookie Normalized incoming cookie.
305 * @param array<string, mixed>|null $existing Matching stored row, if any.
306 * @since 1.3.0
307 * @return string
308 */
309 public static function signature_id( array $cookie, ?array $existing ): string {
310 if ( is_array( $existing ) && ! empty( $existing['signature_id'] ) ) {
311 return (string) $existing['signature_id'];
312 }
313
314 return self::SIGNATURE_PREFIX . md5( Cookie_Identity::key_for( $cookie ) );
315 }
316
317 /**
318 * Whether a signature id was minted by an assisted scan.
319 *
320 * @param string $signature_id Stored signature id.
321 * @since 1.3.0
322 * @return bool
323 */
324 public static function is_assisted_signature( string $signature_id ): bool {
325 return strncmp( $signature_id, self::SIGNATURE_PREFIX, strlen( self::SIGNATURE_PREFIX ) ) === 0;
326 }
327
328 /**
329 * Fill gaps in an assisted cookie from the row it is about to replace.
330 *
331 * Assisted collection is a strict subset of what Playwright sees, so it must
332 * never overwrite a populated field with a weaker value; only name, domain,
333 * path and an intercepted expiry are authoritative here.
334 *
335 * The `http_only` case bites hardest: `document.cookie` cannot see HttpOnly
336 * cookies, so the collector always reports false and a naive replace would
337 * clear the flag on a cookie the cloud scanner correctly marked HttpOnly.
338 *
339 * @param array<string, mixed> $cookie Normalized incoming cookie (SaaS input shape).
340 * @param array<string, mixed>|null $existing Matching stored row (stored shape), if any.
341 * @since 1.3.0
342 * @return array<string, mixed>
343 */
344 public static function merge_without_downgrade( array $cookie, ?array $existing ): array {
345 if ( ! is_array( $existing ) ) {
346 return $cookie;
347 }
348
349 // Classification the browser cannot determine. The stored row may carry a
350 // vendor/purpose from the SaaS canonical or catalog backfill; keep them.
351 $inherit_text = [
352 // Incoming key => stored key.
353 'owner' => 'provider',
354 'purpose' => 'purpose',
355 'description' => 'description',
356 'category' => 'category',
357 ];
358
359 foreach ( $inherit_text as $incoming_key => $stored_key ) {
360 if ( self::is_blank( $cookie[ $incoming_key ] ?? null ) && ! self::is_blank( $existing[ $stored_key ] ?? null ) ) {
361 $cookie[ $incoming_key ] = $existing[ $stored_key ];
362 }
363 }
364
365 // A duration the browser did not observe. Keep whatever is already known
366 // rather than downgrading a two-year cookie to "session".
367 if ( self::is_blank( $cookie['expires_at'] ?? null ) && ! self::is_blank( $existing['expires'] ?? null ) ) {
368 $cookie['expires_at'] = $existing['expires'];
369 }
370
371 // Flags the browser cannot disprove. Never turn a true into a false.
372 if ( ! empty( $existing['httpOnly'] ) ) {
373 $cookie['http_only'] = true;
374 }
375
376 if ( ! empty( $existing['secure'] ) ) {
377 $cookie['secure'] = true;
378 }
379
380 if ( empty( $cookie['same_site'] ) && ! self::is_blank( $existing['sameSite'] ?? null ) ) {
381 $cookie['same_site'] = $existing['sameSite'];
382 }
383
384 // Preserve the resolved-first-party marker so the declared-cookie merge
385 // keeps matching this row by name.
386 if ( Cookie_Identity::is_first_party( $existing ) ) {
387 $cookie[ Cookie_Identity::FIRST_PARTY_FLAG ] = true;
388 }
389
390 return $cookie;
391 }
392
393 /**
394 * Whether a string is a syntactically valid cookie name.
395 *
396 * The browser is untrusted here, so a name carrying an `=` (a whole
397 * `name=value` pair that escaped the value strip), a `;`, or whitespace is
398 * rejected rather than stored as a cookie whose name would leak a value onto
399 * the public cookie policy. RFC 6265 restricts a cookie name to an HTTP token.
400 *
401 * @param string $name Candidate cookie name.
402 * @since 1.3.0
403 * @return bool
404 */
405 public static function is_valid_name( string $name ): bool {
406 if ( $name === '' || strlen( $name ) > 256 ) {
407 return false;
408 }
409
410 // HTTP token: visible ASCII minus the RFC 7230 separators.
411 return preg_match( '/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name ) === 1;
412 }
413
414 /**
415 * The host a cookie with no Domain attribute belongs to.
416 *
417 * Deliberately NOT {@see Cookie_Identity::first_party_domain()}, which strips a
418 * leading `www.`: a host-only cookie set on www.example.com is scoped to that
419 * host and the cloud scanner records it as such, so the stripped form would
420 * misstate the scope and miss the stored row, producing a duplicate.
421 *
422 * @since 1.3.0
423 * @return string Lower-cased host, or an empty string when it cannot be resolved.
424 */
425 public static function request_host(): string {
426 $host = wp_parse_url( home_url(), PHP_URL_HOST );
427
428 if ( ! is_string( $host ) ) {
429 return '';
430 }
431
432 // An IPv6 literal arrives bracketed; the brackets are URL syntax, not host.
433 return strtolower( trim( $host, " \t\n\r\0\x0B[]" ) );
434 }
435
436 /**
437 * Normalize a page's raw browser cookie findings.
438 *
439 * @param array<int, array<string, mixed>> $raw Raw entries: `name`, `attributes`, `set_via`.
440 * @param array<string, array<string, mixed>> $index Output of {@see self::build_existing_index()}.
441 * @param int $now Reference timestamp, for deterministic tests.
442 * @param string $default_domain Host for cookies with no Domain attribute.
443 * The collector passes the scanned page's own host;
444 * falls back to {@see self::request_host()}.
445 * @since 1.3.0
446 * @return array<int, array<string, mixed>> Normalized cookies in the SaaS input shape.
447 */
448 public static function cookies( array $raw, array $index = [], int $now = 0, string $default_domain = '' ): array {
449 $normalized = [];
450 $seen = [];
451
452 $default_domain = self::host_only( $default_domain );
453 if ( $default_domain === '' ) {
454 $default_domain = self::request_host();
455 }
456
457 foreach ( $raw as $entry ) {
458 if ( ! is_array( $entry ) ) {
459 continue;
460 }
461
462 $name = sanitize_text_field( (string) ( $entry['name'] ?? '' ) );
463 $name = trim( $name );
464
465 if ( ! self::is_valid_name( $name ) || self::is_ignored( $name ) ) {
466 continue;
467 }
468
469 $attributes = self::parse_attributes( (string) ( $entry['attributes'] ?? '' ), $now );
470
471 if ( $attributes['is_deletion'] ) {
472 continue;
473 }
474
475 $domain = $attributes['domain'] !== '' ? $attributes['domain'] : $default_domain;
476
477 $cookie = [
478 'name' => $name,
479 // Values are never collected. The key is kept because the stored
480 // shape has it, and an empty string is the honest answer.
481 'value' => '',
482 'domain' => $domain,
483 'path' => $attributes['path'],
484 'expires_at' => $attributes['expires_at'],
485 'secure' => $attributes['secure'],
486 // A browser can never observe this. Left false and only ever
487 // raised by merge_without_downgrade().
488 'http_only' => false,
489 'same_site' => $attributes['same_site'],
490 'set_via' => self::host_only( (string) ( $entry['set_via'] ?? '' ) ),
491 'category' => '',
492 'owner' => '',
493 'purpose' => '',
494 'description' => '',
495 'source' => self::SOURCE,
496 ];
497
498 // Deduplicate within the page: the same cookie is often re-set on one
499 // load, and the first observation carries the real attributes.
500 $key = Cookie_Identity::key_for( $cookie );
501 if ( isset( $seen[ $key ] ) ) {
502 continue;
503 }
504 $seen[ $key ] = true;
505
506 $existing = self::find_existing( $cookie, $index );
507 $cookie = self::merge_without_downgrade( $cookie, $existing );
508 $cookie['signature_id'] = self::signature_id( $cookie, $existing );
509
510 // The browser's own default, applied last: only once neither the
511 // observation nor the stored row could tell us anything.
512 if ( self::is_blank( $cookie['same_site'] ) ) {
513 $cookie['same_site'] = 'lax';
514 }
515
516 $normalized[] = $cookie;
517 }
518
519 return $normalized;
520 }
521
522 /**
523 * Normalize a page's raw browser resource findings.
524 *
525 * @param array<int, array<string, mixed>> $raw Raw entries: `url`, `kind`.
526 * @since 1.3.0
527 * @return array{scripts: array<int, array<string, mixed>>, iframes: array<int, array<string, mixed>>}
528 */
529 public static function resources( array $raw ): array {
530 $scripts = [];
531 $iframes = [];
532
533 foreach ( $raw as $entry ) {
534 if ( ! is_array( $entry ) ) {
535 continue;
536 }
537
538 $url = self::clean_url( (string) ( $entry['url'] ?? '' ) );
539
540 if ( $url === '' ) {
541 continue;
542 }
543
544 $host = self::host_only( $url );
545
546 if ( $host === '' ) {
547 continue;
548 }
549
550 $is_iframe = (string) ( $entry['kind'] ?? 'script' ) === 'iframe';
551 $bucket = $is_iframe ? 'iframes' : 'scripts';
552
553 if ( $bucket === 'iframes' ) {
554 if ( isset( $iframes[ $host ] ) ) {
555 continue;
556 }
557 $iframes[ $host ] = [
558 'src' => $url,
559 'domain' => $host,
560 'is_third_party' => ! self::is_first_party_host( $host ),
561 'vendor' => '',
562 'tracking_category' => '',
563 ];
564 continue;
565 }
566
567 if ( isset( $scripts[ $host ] ) ) {
568 continue;
569 }
570
571 $scripts[ $host ] = [
572 'url' => $url,
573 'domain' => $host,
574 'is_third_party' => ! self::is_first_party_host( $host ),
575 'vendor_name' => '',
576 'tracking_category' => '',
577 ];
578 }
579
580 return [
581 'scripts' => array_values( $scripts ),
582 'iframes' => array_values( $iframes ),
583 ];
584 }
585
586 /**
587 * Strip the query string and fragment from a resource URL.
588 *
589 * Tracker query strings routinely carry page URLs, click ids and sometimes
590 * personal data, none of it needed to identify the service. Protocol-relative
591 * URLs are normalized to https, matching
592 * {@see \SureCookie\Inc\Modules\SiteScanner\LoopbackScanner::extract_resources()}.
593 *
594 * @param string $url Raw resource URL.
595 * @since 1.3.0
596 * @return string Scheme, host and path only, or an empty string when unusable.
597 */
598 public static function clean_url( string $url ): string {
599 $url = trim( $url );
600
601 if ( $url === '' ) {
602 return '';
603 }
604
605 if ( strpos( $url, '//' ) === 0 ) {
606 $url = 'https:' . $url;
607 }
608
609 $parts = wp_parse_url( $url );
610
611 if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
612 return '';
613 }
614
615 $scheme = ! empty( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : 'https';
616
617 // Only ever describe web resources; data:, blob: and javascript: URLs are
618 // not services and must not reach the blocking dataset.
619 if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
620 return '';
621 }
622
623 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
624
625 return $scheme . '://' . strtolower( (string) $parts['host'] ) . $path;
626 }
627
628 /**
629 * The host of a URL, or the input when it is already a bare host.
630 *
631 * @param string $value URL or hostname.
632 * @since 1.3.0
633 * @return string Lower-cased host, or an empty string.
634 */
635 public static function host_only( string $value ): string {
636 $value = strtolower( trim( $value ) );
637
638 if ( $value === '' ) {
639 return '';
640 }
641
642 if ( strpos( $value, '//' ) === 0 ) {
643 $value = 'https:' . $value;
644 }
645
646 if ( strpos( $value, '://' ) !== false ) {
647 $host = wp_parse_url( $value, PHP_URL_HOST );
648 return is_string( $host ) ? strtolower( $host ) : '';
649 }
650
651 // A bare host may still arrive with a port or a stray path.
652 $value = explode( '/', $value )[0];
653 $value = explode( ':', $value )[0];
654
655 return preg_match( '/^[a-z0-9.-]+$/', $value ) === 1 ? $value : '';
656 }
657
658 /**
659 * Whether a host belongs to this site.
660 *
661 * Compares against the same host {@see Cookie_Identity::first_party_domain()}
662 * resolves, so a resource on a subdomain of the site counts as first party.
663 *
664 * @param string $host Lower-cased host.
665 * @since 1.3.0
666 * @return bool
667 */
668 public static function is_first_party_host( string $host ): bool {
669 $site = Cookie_Identity::first_party_domain();
670
671 if ( $site === '' || $host === '' ) {
672 return false;
673 }
674
675 if ( strncmp( $host, 'www.', 4 ) === 0 ) {
676 $host = substr( $host, 4 );
677 }
678
679 return $host === $site || substr( $host, -strlen( '.' . $site ) ) === '.' . $site;
680 }
681
682 /**
683 * Whether a value carries no usable content.
684 *
685 * @param mixed $value Value to test.
686 * @since 1.3.0
687 * @return bool
688 */
689 private static function is_blank( $value ): bool {
690 if ( $value === null || $value === false ) {
691 return true;
692 }
693
694 return is_string( $value ) && trim( $value ) === '';
695 }
696 }
697