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 / script-blocking / matched-resources.php

matched-resources.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/script-blocking/matched-resources.php

346 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Resources the blocker actually matched on this site.
4 *
5 * The catalog is a blocking-only input: nothing on Scripts and Embeds reads it.
6 * So a resource gated purely by a catalog pattern - a YouTube embed, a Presto
7 * video - has no row anywhere, and an admin cannot recategorise it, exclude it,
8 * or even see that SureCookie is acting on it. The scanner cannot fill the gap
9 * either, because by the time it looks the blocker has already replaced the
10 * resource with a placeholder that carries no URL.
11 *
12 * Record what the blocker matched while it runs, and surface those as rows. The
13 * list is per-site and stays honest: it only ever contains resources this site
14 * actually served.
15 *
16 * @package SureCookie\Inc\Modules\ScriptBlocking
17 * @since 1.5.0
18 */
19
20 namespace SureCookie\Inc\Modules\ScriptBlocking;
21
22 use SureCookie\Inc\Functions\Cookie_Identity;
23 use SureCookie\Inc\Functions\Get;
24 use SureCookie\Inc\Modules\Services\Declared_Cookies;
25 use SureCookie\Inc\Traits\GetInstance;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit; // Exit if accessed directly.
29 }
30
31 /**
32 * Matched_Resources
33 *
34 * @since 1.5.0
35 */
36 class Matched_Resources {
37 use GetInstance;
38
39 /**
40 * Option holding the per-site matched set.
41 */
42 public const OPTION = 'surecookie_matched_resources';
43
44 /**
45 * Upper bound on stored patterns per kind, so a site that generates unique
46 * hostnames cannot grow this without limit.
47 */
48 private const MAX_PER_KIND = 200;
49
50 /**
51 * Patterns matched during this request, as [ kind => [ pattern => entry ] ].
52 *
53 * @var array<string, array<string, array<string, string>>>
54 */
55 private array $seen = [];
56
57 /**
58 * Whether this request saw something the stored set does not have.
59 *
60 * @var bool
61 */
62 private bool $dirty = false;
63
64 /**
65 * Constructor.
66 *
67 * @since 1.5.0
68 */
69 private function __construct() {
70 add_filter( 'surecookie_scanned_resources', [ $this, 'merge_into_payload' ] );
71 add_filter( 'surecookie_scanned_cookies', [ $this, 'merge_declared_cookies' ] );
72 add_action( 'shutdown', [ $this, 'flush' ] );
73 }
74
75 /**
76 * Note that the blocker matched a resource.
77 *
78 * Called on every match, not only on the ones that end up parked, so a
79 * resource an admin has already excluded still has a row to switch back.
80 *
81 * @since 1.5.0
82 * @param string $kind Resource kind ('script'|'iframe').
83 * @param string $subject Resource URL, or the pattern that matched.
84 * @param string $service Matched service key.
85 * @param string $category Category the pattern resolved to.
86 * @return void
87 */
88 public function record( string $kind, string $subject, string $service, string $category ): void {
89 $kind = $kind === 'iframe' ? 'iframe' : 'script';
90
91 // A custom rule already has a row of its own, so it is not news here.
92 if ( strncmp( $service, 'custom-', 7 ) === 0 ) {
93 return;
94 }
95
96 // Catalog patterns are not all hosts: `pintrk` and `firebase-settings`
97 // ship as inline-code keywords, and giving those a scheme invents a
98 // domain that then shows up as a resource row.
99 if ( strpos( $subject, '.' ) === false ) {
100 return;
101 }
102
103 $pattern = $this->host_of( $subject );
104 if ( $pattern === '' || $this->is_first_party( $pattern ) ) {
105 return;
106 }
107
108 if ( isset( $this->seen[ $kind ][ $pattern ] ) ) {
109 return;
110 }
111
112 $this->seen[ $kind ][ $pattern ] = [
113 'service' => $service,
114 'category' => $category,
115 ];
116
117 $this->dirty = true;
118 }
119
120 /**
121 * Add a row for every matched pattern the scan does not already cover.
122 *
123 * @since 1.5.0
124 * @param mixed $resources Scanned-resources payload.
125 * @return mixed
126 */
127 public function merge_into_payload( $resources = null ) {
128 if ( ! is_array( $resources ) ) {
129 return $resources;
130 }
131
132 $stored = $this->stored();
133
134 foreach ( [
135 'script' => 'scripts',
136 'iframe' => 'iframes',
137 ] as $kind => $bucket ) {
138 $rows = isset( $resources[ $bucket ] ) && is_array( $resources[ $bucket ] ) ? $resources[ $bucket ] : [];
139 $known = [];
140
141 foreach ( $rows as $row ) {
142 if ( is_array( $row ) && isset( $row['domain'] ) ) {
143 $known[ strtolower( (string) $row['domain'] ) ] = true;
144 }
145 }
146
147 foreach ( (array) ( $stored[ $kind ] ?? [] ) as $pattern => $entry ) {
148 $pattern = (string) $pattern;
149 if ( isset( $known[ strtolower( $pattern ) ] ) ) {
150 continue;
151 }
152
153 $rows[] = [
154 'domain' => $pattern,
155 'vendor' => '',
156 'url' => '',
157 'category' => (string) ( $entry['category'] ?? 'uncategorized' ),
158 'source' => 'catalog',
159 'service_slug' => (string) ( $entry['service'] ?? '' ),
160 ];
161 }
162
163 $resources[ $bucket ] = $rows;
164 }
165
166 return $resources;
167 }
168
169 /**
170 * Add the declared cookies of every service we matched, for services no scan
171 * reported.
172 *
173 * A gated embed is replaced before any scan sees it, so its cookies were
174 * missing from All Cookies and from the public cookie policy even though the
175 * site demonstrably loads that service once a visitor consents.
176 *
177 * @since 1.5.0
178 * @param mixed $cookies Cookies grouped by category id.
179 * @return mixed
180 */
181 public function merge_declared_cookies( $cookies = null ) {
182 if ( ! is_array( $cookies ) ) {
183 return $cookies;
184 }
185
186 $services = $this->matched_services();
187 if ( empty( $services ) ) {
188 return $cookies;
189 }
190
191 $seen = [];
192 foreach ( $cookies as $rows ) {
193 foreach ( (array) $rows as $row ) {
194 if ( is_array( $row ) && isset( $row['name'] ) ) {
195 $seen[ Cookie_Identity::key_for( $row ) ] = true;
196 }
197 }
198 }
199
200 foreach ( Declared_Cookies::get_instance()->build_for_services( $services ) as $category => $rows ) {
201 foreach ( (array) $rows as $row ) {
202 if ( ! is_array( $row ) || empty( $row['name'] ) ) {
203 continue;
204 }
205
206 // Cookie_Identity strips the leading dot, so a declared
207 // `.youtube.com` row dedupes against an observed `youtube.com`
208 // one instead of both reaching the public cookie policy.
209 $key = Cookie_Identity::key_for( $row );
210 if ( isset( $seen[ $key ] ) ) {
211 continue;
212 }
213
214 $seen[ $key ] = true;
215 $cookies[ $category ][] = $row;
216 }
217 }
218
219 return $cookies;
220 }
221
222 /**
223 * Persist anything new this request saw.
224 *
225 * Writes only when a pattern is genuinely new, so a settled site stops
226 * touching the option entirely after the first few page views.
227 *
228 * @since 1.5.0
229 * @return void
230 */
231 public function flush(): void {
232 if ( ! $this->dirty ) {
233 return;
234 }
235
236 $this->dirty = false;
237 $stored = $this->stored();
238 $changed = false;
239
240 foreach ( $this->seen as $kind => $patterns ) {
241 foreach ( $patterns as $pattern => $entry ) {
242 if ( isset( $stored[ $kind ][ $pattern ] ) ) {
243 continue;
244 }
245 if ( count( $stored[ $kind ] ?? [] ) >= self::MAX_PER_KIND ) {
246 break;
247 }
248
249 $stored[ $kind ][ $pattern ] = $entry;
250 $changed = true;
251 }
252 }
253
254 if ( $changed ) {
255 update_option( self::OPTION, $stored, false );
256 }
257 }
258
259 /**
260 * Distinct catalog service keys recorded on this site.
261 *
262 * @since 1.5.0
263 * @return array<int, string>
264 */
265 private function matched_services(): array {
266 $services = [];
267
268 foreach ( $this->stored() as $patterns ) {
269 foreach ( $patterns as $entry ) {
270 $service = (string) ( $entry['service'] ?? '' );
271 // Scan-detected rows carry their own cookies already.
272 if ( $service !== '' && strncmp( $service, 'scan_', 5 ) !== 0 ) {
273 $services[ $service ] = true;
274 }
275 }
276 }
277
278 return array_keys( $services );
279 }
280
281 /**
282 * Whether a host belongs to this site.
283 *
284 * Presto's self-hosted and audio providers resolve to a local file, and a
285 * first-party host recorded as a resource can be always-loaded by an admin,
286 * which would exempt every same-host resource on the site.
287 *
288 * @since 1.5.0
289 * @param string $host Lowercased host.
290 * @return bool
291 */
292 private function is_first_party( string $host ): bool {
293 $site = Blocker::without_www( strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) );
294 $host = Blocker::without_www( $host );
295
296 if ( $site === '' ) {
297 return false;
298 }
299
300 return $host === $site || substr( $host, - ( strlen( $site ) + 1 ) ) === '.' . $site;
301 }
302
303 /**
304 * Host of a resource URL, or of a bare pattern.
305 *
306 * Recording the host rather than the catalog pattern keeps the row honest:
307 * it names what this site actually contacted, which is also the value the
308 * admin UI writes an exclusion or override against.
309 *
310 * @since 1.5.0
311 * @param string $subject Resource URL, or a bare blocking pattern.
312 * @return string Lowercased host, or '' when there is none to record.
313 */
314 private function host_of( string $subject ): string {
315 $subject = trim( $subject );
316
317 // A data: URI names no host, and a root-relative path is first-party.
318 if ( $subject === '' || stripos( $subject, 'data:' ) === 0 || strpos( $subject, '/' ) === 0 ) {
319 return strpos( $subject, '//' ) === 0 ? $this->host_of( 'https:' . $subject ) : '';
320 }
321
322 if ( strpos( $subject, '://' ) === false ) {
323 $subject = 'https://' . $subject;
324 }
325
326 $host = wp_parse_url( $subject, PHP_URL_HOST );
327
328 return is_string( $host ) ? strtolower( $host ) : '';
329 }
330
331 /**
332 * The stored set, normalised to both kinds.
333 *
334 * @since 1.5.0
335 * @return array<string, array<string, array<string, string>>>
336 */
337 private function stored(): array {
338 $stored = Get::option( self::OPTION, [], 'array' );
339
340 return [
341 'script' => is_array( $stored['script'] ?? null ) ? $stored['script'] : [],
342 'iframe' => is_array( $stored['iframe'] ?? null ) ? $stored['iframe'] : [],
343 ];
344 }
345 }
346