kjeks-scanner 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Per Søderlind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # Kjeks discovery scanner
2
+
3
+ A standalone Playwright scanner that discovers cookies and similar technologies
4
+ across a WordPress Multisite, in each consent state. It runs **separately from
5
+ the WordPress runtime** — nothing here is loaded by the plugin.
6
+
7
+ Part of the **kjeks family**. It integrates with the
8
+ [Kjeks plugin](https://github.com/soderlind/kjeks) over REST only
9
+ (`scan-config` + `import`); see the
10
+ [kjeks ecosystem overview](https://github.com/soderlind/kjeks/blob/main/docs/architecture.md#9-ecosystem-the-kjeks-family).
11
+
12
+ > Discovery is observational. It records what a real browser encountered; it
13
+ > **cannot prove the absence** of tracking, and results vary by geo/IP. Imported
14
+ > observations are always **unreviewed** until an administrator classifies them.
15
+
16
+ ## Why real Chromium
17
+
18
+ The scanner drives a version-pinned, real headless Chromium via Playwright's
19
+ CDP-backed API. Only CDP exposes HttpOnly cookies and full network events, and
20
+ only a real browser executes third-party code the way a visitor's browser does.
21
+ See [docs/adr/0005-scanner-uses-real-chromium.md](docs/adr/0005-scanner-uses-real-chromium.md).
22
+
23
+ ## How it works end-to-end
24
+
25
+ 1. **Select** — the WordPress plugin's `scan-config` auto-picks representative URLs per
26
+ site (home, newest post/page, embed-bearing pages), capped. The scanner fetches this
27
+ over REST or from a file.
28
+ 2. **Scan** — for each site (in a bounded parallel pool with a per-host ceiling) the
29
+ scanner opens a fresh browser context per consent state, injects the consent record,
30
+ visits the URLs, and records cookies, storage, third-party requests, scripts, iframes,
31
+ and beacons.
32
+ 3. **Attribute** — every observation is tagged with `source_urls`, the page(s) it loaded on.
33
+ 4. **Diff** — results are written as one deterministic JSON file per site and diffed
34
+ against the committed baseline; any change exits non-zero for review.
35
+ 5. **Target** — the next run always re-scans pages that previously produced a tracker
36
+ (from `source_urls`), so coverage never silently regresses.
37
+ 6. **Import** — observations are POSTed to the plugin as *unreviewed* (in the same run
38
+ with `--import`, or separately) for an administrator to classify — optionally via the
39
+ [AI Reviewer](https://github.com/soderlind/kjeks-ai-reviewer).
40
+
41
+ Design rationale is recorded under [docs/adr/](docs/adr): see
42
+ [0006 — REST-driven scanning](docs/adr/0006-rest-driven-scanning.md) and
43
+ [0005 — real Chromium](docs/adr/0005-scanner-uses-real-chromium.md).
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ npm ci
49
+ npx playwright install --with-deps chromium
50
+ ```
51
+
52
+ ### Or run without installing (npx)
53
+
54
+ Run it directly — no clone required:
55
+
56
+ ```bash
57
+ npx kjeks-scanner --config-url "https://network.example.com/wp-json/kjeks/v1/scan-config" --out scan
58
+ ```
59
+
60
+ Use the package name with npx: `npx kjeks-scanner`. After a global or local
61
+ install, both the `kjeks-scanner` and `kjeks-scan` commands are available.
62
+ The first run downloads a pinned Chromium (~100+ MB) via Playwright; later runs
63
+ reuse it.
64
+
65
+ ## Run a scan
66
+
67
+ ```bash
68
+ # From a config file (recommended for multisite):
69
+ node src/cli.js --config config.json --out scan
70
+
71
+ # Or a single URL:
72
+ node src/cli.js --url https://example.com --blog-id 1 --out scan
73
+
74
+ # Fetch the site list from WordPress over REST (recommended for CI):
75
+ KJEKS_USER=admin KJEKS_APP_PASSWORD='xxxx xxxx xxxx xxxx' \
76
+ node src/cli.js --config-url "https://network.example.com/wp-json/kjeks/v1/scan-config" --out scan
77
+
78
+ # Against Cloudflare Browser Run instead of local Chromium (opt-in):
79
+ node src/cli.js --config config.json --endpoint "wss://…/browser-run/…"
80
+
81
+ # Scan several sites in parallel (default 3; polite per-host cap 2):
82
+ node src/cli.js --config-url "https://network.example.com/wp-json/kjeks/v1/scan-config" --concurrency 4 --out scan
83
+
84
+ # Scan and import in one step:
85
+ KJEKS_USER=admin KJEKS_APP_PASSWORD='xxxx xxxx xxxx xxxx' \
86
+ node src/cli.js --config-url "https://network.example.com/wp-json/kjeks/v1/scan-config" --import --out scan
87
+ ```
88
+
89
+ ### Flags
90
+
91
+ | Flag | Default | Purpose |
92
+ | --- | --- | --- |
93
+ | `--concurrency <n>` | 3 | Sites scanned in parallel. |
94
+ | `--per-host <n>` | 2 | Parallel scans allowed to share one hostname (politeness for subdirectory multisites on one server). |
95
+ | `--full` | off | Scan the server selection as-is; skip re-scanning pages that previously produced a tracker. |
96
+ | `--import [<url>]` | off | After scanning, POST observations to the Kjeks import endpoint. Base URL from the value, `--site`, or `--config-url`. |
97
+
98
+ Config shape: `{ "sites": [ { "url", "blog_id", "policy_version", "paths",
99
+ "scenarios" } ] }`. Generate it with `wp kjeks scan-config` or fetch it over
100
+ REST (below); `paths`/`scenarios` shape is shown in `overlay.example.json`.
101
+
102
+ ### Where the config comes from
103
+
104
+ Three interchangeable sources:
105
+
106
+ 1. **Static file** — `--config config.json`.
107
+ 2. **REST** — `--config-url .../wp-json/kjeks/v1/scan-config` fetches the live
108
+ site list (auth: `KJEKS_USER` + `KJEKS_APP_PASSWORD`, caller needs
109
+ `manage_network`). Best for CI: no committed config, new subsites appear
110
+ automatically. Add `--overlay overlay.json` to merge repo-side `paths` and
111
+ `scenarios` by `blog_id` (see `overlay.example.json`).
112
+ 3. **WP-CLI** — generate a static file locally:
113
+
114
+ ```bash
115
+ wp kjeks scan-config > config.json # auto-selects URLs per site
116
+ wp kjeks scan-config --cap=15 --include=1,3 # cap the auto-selection
117
+ wp kjeks scan-config --paths=/,/about > config.json # explicit paths (override)
118
+ ```
119
+
120
+ Omit `--paths` (CLI) or the `paths` query param (REST) and the plugin
121
+ **auto-selects representative URLs per site** via `WP_Query` — the home page, the
122
+ newest post and page, the posts archive, and pages whose content shows an embed /
123
+ inline-script signal — capped by `--cap` (default 10). Passing explicit paths
124
+ overrides the selection.
125
+
126
+ The REST endpoint and `wp kjeks scan-config` share the same builder, so they
127
+ produce identical output.
128
+
129
+ For every site the scanner opens a **fresh browser context** per consent state:
130
+ `before-choice`, `reject-all`, `only-preferences`, `only-analytics`,
131
+ `only-marketing`, `accept-all`. It collects Set-Cookie headers, context cookies
132
+ (incl. HttpOnly/Secure/SameSite), `document.cookie`, localStorage,
133
+ sessionStorage, IndexedDB names, third-party requests, redirects, scripts,
134
+ iframes, and beacon/pixel requests.
135
+
136
+ Each derived observation records **`source_urls`** — the page(s) it actually
137
+ loaded on — so reviewers can see *where* a tracker fires. On the next run the
138
+ scanner always re-scans those pages (on top of the server selection), so a page
139
+ that once produced a tracker is never dropped by sampling. Use `--full` to scan
140
+ the server selection as-is.
141
+
142
+ ## Output and diff
143
+
144
+ One deterministic JSON file per site at `<out>/<host>.json` (stable key order,
145
+ volatile fields normalized). The CLI prints a per-subsite diff (new / changed /
146
+ removed) against the previous file and exits non-zero when anything changed, so
147
+ CI can flag it for review. Commit `scan/<host>.json` as the baseline.
148
+
149
+ ## Import into WordPress
150
+
151
+ ```bash
152
+ KJEKS_USER=admin KJEKS_APP_PASSWORD='xxxx xxxx xxxx xxxx' \
153
+ node src/import.js --site https://network.example.com scan/*.json
154
+ ```
155
+
156
+ Uses a WordPress application password (HTTP Basic) against
157
+ `/wp-json/kjeks/v1/import`; the caller needs `manage_network`. Never commit the
158
+ password — pass it via environment / CI secrets. Locally you can instead use
159
+ `wp kjeks import <file>`.
160
+
161
+ To scan and import in a single command, pass `--import` to `src/cli.js` (see
162
+ Flags above) instead of running `src/import.js` separately.
163
+
164
+ ## Tests
165
+
166
+ ```bash
167
+ npm run test:unit # fast unit tests (node:test), no browser
168
+ BASE_URL=http://plugins.local/ npx playwright test # end-to-end, needs a site
169
+ ```
170
+
171
+ The end-to-end suite confirms optional cookies, storage, and third-party requests
172
+ do not occur before a consent choice, and that gated scripts stay inert.
173
+
174
+ ## Scheduled scanning
175
+
176
+ `.github/workflows/scan.yml` runs the scan weekly, uploads the full artifact,
177
+ imports observations (if secrets are set), and commits baseline changes. Set
178
+ `KJEKS_SITE_URL`, `KJEKS_USER`, and `KJEKS_APP_PASSWORD` as repository secrets.
179
+
180
+ ## Known limitations
181
+
182
+ - First/third-party classification uses an eTLD+1 approximation (last two
183
+ labels), not the full Public Suffix List.
184
+ - Consent states are injected via the shared record schema, not by clicking the
185
+ banner; a UI regression could pass the state matrix yet break the real banner.
186
+ - A single run from one location cannot capture geo/consent variations.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "kjeks-scanner",
3
+ "version": "0.2.0",
4
+ "description": "Standalone Playwright discovery scanner for the Kjeks cookie-consent plugin. Runs separately from the WordPress runtime.",
5
+ "license": "MIT",
6
+ "author": "Per Søderlind",
7
+ "keywords": [
8
+ "kjeks",
9
+ "cookie-consent",
10
+ "playwright",
11
+ "tracker-scanner",
12
+ "cookie-scanner",
13
+ "gdpr",
14
+ "privacy",
15
+ "wordpress"
16
+ ],
17
+ "homepage": "https://github.com/soderlind/kjeks-scanner",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/soderlind/kjeks-scanner.git"
21
+ },
22
+ "private": false,
23
+ "type": "module",
24
+ "files": [
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "bin": {
30
+ "kjeks-scan": "src/cli.js",
31
+ "kjeks-scanner": "src/cli.js"
32
+ },
33
+ "engines": {
34
+ "node": ">=20.0.0"
35
+ },
36
+ "scripts": {
37
+ "scan": "node src/cli.js",
38
+ "test": "playwright test",
39
+ "test:unit": "node --test tests/*.test.js"
40
+ },
41
+ "dependencies": {
42
+ "playwright": "^1.48.0"
43
+ },
44
+ "devDependencies": {
45
+ "@playwright/test": "^1.48.0"
46
+ }
47
+ }
package/src/cli.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Kjeks discovery scanner CLI.
4
+ *
5
+ * Usage:
6
+ * node src/cli.js --config config.json --out scan
7
+ * node src/cli.js --url https://example.com --blog-id 1 --out scan
8
+ * node src/cli.js --config-url https://net.example/wp-json/kjeks/v1/scan-config --out scan
9
+ * node src/cli.js --config-url <url> --overlay overlay.json --out scan
10
+ * node src/cli.js --config config.json --endpoint wss://…browser-run… (Browser Run)
11
+ *
12
+ * --config-url fetches the site list from WordPress (auth: KJEKS_USER +
13
+ * KJEKS_APP_PASSWORD env). --overlay merges repo-side paths/scenarios by blog_id.
14
+ *
15
+ * Options:
16
+ * --concurrency <n> Sites scanned in parallel (default 3).
17
+ * --per-host <n> Parallel scans sharing a hostname (default 2).
18
+ * --full Scan the server selection as-is; skip re-scanning pages
19
+ * that previously produced a tracker.
20
+ * --import [<url>] After scanning, POST observations to the Kjeks import
21
+ * endpoint (base from the value, --site, or --config-url).
22
+ *
23
+ * Writes one deterministic JSON file per site to <out>/<host>[_<path>].json and prints a
24
+ * per-subsite diff against any previous file of the same name. Exits non-zero when a
25
+ * subsite changed, a site errored, or an import failed.
26
+ */
27
+
28
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
29
+ import { existsSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { runScan } from './scan.js';
32
+ import { diffScans } from './diff.js';
33
+ import { priorTrackerPaths, mergePaths } from './targeting.js';
34
+ import { importSites } from './import.js';
35
+
36
+ async function main() {
37
+ const args = parseArgs( process.argv.slice( 2 ) );
38
+ const outDir = args.out || 'scan';
39
+
40
+ await mkdir( outDir, { recursive: true } );
41
+
42
+ // Targeted re-scan: always revisit pages that previously produced a tracker,
43
+ // on top of the server-selected paths. --full scans the selection as-is.
44
+ const config = await applyTargeting( await loadConfig( args ), outDir, Boolean( args.full ) );
45
+
46
+ const result = await runScan( config, {
47
+ endpoint: args.endpoint,
48
+ concurrency: args.concurrency ? Number( args.concurrency ) : undefined,
49
+ perHost: args[ 'per-host' ] ? Number( args[ 'per-host' ] ) : undefined,
50
+ } );
51
+ let anyChanged = false;
52
+
53
+ for ( const site of result.sites ) {
54
+ const slug = siteFileSlug( site );
55
+ const file = join( outDir, `${ slug }.json` );
56
+ const previous = existsSync( file ) ? JSON.parse( await readFile( file, 'utf8' ) ) : null;
57
+
58
+ const [ siteDiff ] = diffScans( previous, { sites: [ site ] } );
59
+
60
+ const single = { generated_at: result.generated_at, sites: [ site ] };
61
+ await writeFile( file, stableStringify( single ) + '\n', 'utf8' );
62
+
63
+ printDiff( slug, siteDiff );
64
+
65
+ if ( siteDiff && ( siteDiff.added.length || siteDiff.changed.length || siteDiff.removed.length ) ) {
66
+ anyChanged = true;
67
+ }
68
+ }
69
+
70
+ // Surface isolated per-site scan failures (the run continued past them).
71
+ const scanErrors = result.errors || [];
72
+ for ( const failure of scanErrors ) {
73
+ process.stderr.write( `scan error (blog ${ failure.site && failure.site.blog_id }): ${ failure.message }\n` );
74
+ }
75
+
76
+ // Optional: import the reviewed-less observations in the same run.
77
+ let importFailures = 0;
78
+ if ( args.import ) {
79
+ const base = ( typeof args.import === 'string' ? args.import : null )
80
+ || args.site
81
+ || originOf( args[ 'config-url' ] );
82
+ if ( ! base ) {
83
+ throw new Error( '--import needs a base URL: use --import <url>, --site <url>, or --config-url.' );
84
+ }
85
+ const outcome = await importSites( base, result.sites, {
86
+ log: ( message, level ) => ( level === 'error' ? process.stderr : process.stdout ).write( message + '\n' ),
87
+ } );
88
+ importFailures = outcome.failures;
89
+ }
90
+
91
+ // Non-zero exit if any subsite changed, any site errored, or an import failed.
92
+ process.exitCode = ( anyChanged || scanErrors.length > 0 || importFailures > 0 ) ? 1 : 0;
93
+ }
94
+
95
+ function originOf( url ) {
96
+ if ( ! url || typeof url !== 'string' ) {
97
+ return null;
98
+ }
99
+ try {
100
+ return new URL( url ).origin;
101
+ } catch ( e ) {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // Distinct output name per site. Subdirectory multisites share one host, so the
107
+ // path is folded in; subdomain/domain-mapped sites keep their host-only name.
108
+ function siteFileSlug( site ) {
109
+ let path = '';
110
+ try {
111
+ path = new URL( site.url ).pathname;
112
+ } catch ( e ) {
113
+ path = '';
114
+ }
115
+ const trimmed = path.replace( /^\/+|\/+$/g, '' );
116
+ const suffix = trimmed ? '_' + trimmed.replace( /[^a-z0-9._-]+/gi, '-' ) : '';
117
+ return `${ site.host }${ suffix }`;
118
+ }
119
+
120
+ function printDiff( host, diff ) {
121
+ if ( ! diff ) {
122
+ return;
123
+ }
124
+ const total = diff.added.length + diff.changed.length + diff.removed.length;
125
+ if ( total === 0 ) {
126
+ process.stdout.write( `${ host }: no changes\n` );
127
+ return;
128
+ }
129
+ process.stdout.write(
130
+ `${ host }: ${ diff.added.length } new, ${ diff.changed.length } changed, ${ diff.removed.length } removed\n`
131
+ );
132
+ for ( const o of diff.added ) {
133
+ process.stdout.write( ` + ${ o.storage_type } ${ o.name }${ o.domain ? ' @ ' + o.domain : '' }\n` );
134
+ }
135
+ for ( const o of diff.removed ) {
136
+ process.stdout.write( ` - ${ o.storage_type } ${ o.name }${ o.domain ? ' @ ' + o.domain : '' }\n` );
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Adds paths that previously produced a tracker (read from each site's prior
142
+ * committed scan file) to the config paths, so known-tracker pages are always
143
+ * re-checked. Skipped when `full` is set.
144
+ */
145
+ async function applyTargeting( config, outDir, full ) {
146
+ if ( full ) {
147
+ return config;
148
+ }
149
+
150
+ const sites = [];
151
+ for ( const site of config.sites || [] ) {
152
+ const file = join( outDir, `${ siteFileSlug( site ) }.json` );
153
+ let prior = null;
154
+ if ( existsSync( file ) ) {
155
+ try {
156
+ prior = JSON.parse( await readFile( file, 'utf8' ) );
157
+ } catch ( e ) {
158
+ prior = null;
159
+ }
160
+ }
161
+ sites.push( { ...site, paths: mergePaths( site.paths, priorTrackerPaths( prior ) ) } );
162
+ }
163
+
164
+ return { ...config, sites };
165
+ }
166
+
167
+ async function loadConfig( args ) {
168
+ if ( args.config ) {
169
+ return JSON.parse( await readFile( args.config, 'utf8' ) );
170
+ }
171
+ if ( args[ 'config-url' ] ) {
172
+ return applyOverlay( await fetchConfig( args[ 'config-url' ] ), args.overlay );
173
+ }
174
+ if ( args.url ) {
175
+ return {
176
+ sites: [
177
+ {
178
+ url: args.url,
179
+ blog_id: args[ 'blog-id' ] ? Number( args[ 'blog-id' ] ) : 1,
180
+ paths: args.path ? [].concat( args.path ) : [ '/' ],
181
+ },
182
+ ],
183
+ };
184
+ }
185
+ throw new Error( 'Provide --config <file>, --config-url <url>, or --url <url>.' );
186
+ }
187
+
188
+ /**
189
+ * Fetches the scanner config from the WordPress REST endpoint.
190
+ *
191
+ * Authenticates with an application password via HTTP Basic auth, supplied
192
+ * through KJEKS_USER / KJEKS_APP_PASSWORD in the environment.
193
+ */
194
+ async function fetchConfig( url ) {
195
+ const user = process.env.KJEKS_USER;
196
+ const password = process.env.KJEKS_APP_PASSWORD;
197
+ if ( ! user || ! password ) {
198
+ throw new Error( '--config-url requires KJEKS_USER and KJEKS_APP_PASSWORD in the environment.' );
199
+ }
200
+ const auth = 'Basic ' + Buffer.from( `${ user }:${ password }` ).toString( 'base64' );
201
+ const response = await fetch( url, { headers: { authorization: auth } } );
202
+ if ( ! response.ok ) {
203
+ throw new Error( `config-url returned ${ response.status }` );
204
+ }
205
+ return response.json();
206
+ }
207
+
208
+ /**
209
+ * Merges a repo-side overlay (paths / scenarios keyed by blog_id) into the
210
+ * site list fetched from REST. Operator intent stays in the repo; the live
211
+ * site list stays authoritative.
212
+ */
213
+ async function applyOverlay( config, overlayPath ) {
214
+ if ( ! overlayPath ) {
215
+ return config;
216
+ }
217
+ const overlay = JSON.parse( await readFile( overlayPath, 'utf8' ) );
218
+ const byBlog = new Map( ( overlay.sites || [] ).map( ( s ) => [ s.blog_id, s ] ) );
219
+
220
+ config.sites = ( config.sites || [] ).map( ( site ) => {
221
+ const extra = byBlog.get( site.blog_id );
222
+ if ( ! extra ) {
223
+ return site;
224
+ }
225
+ return {
226
+ ...site,
227
+ paths: extra.paths && extra.paths.length ? extra.paths : site.paths,
228
+ scenarios: extra.scenarios || site.scenarios,
229
+ };
230
+ } );
231
+
232
+ return config;
233
+ }
234
+
235
+ function parseArgs( argv ) {
236
+ const args = {};
237
+ for ( let i = 0; i < argv.length; i++ ) {
238
+ const token = argv[ i ];
239
+ if ( token.startsWith( '--' ) ) {
240
+ const key = token.slice( 2 );
241
+ const next = argv[ i + 1 ];
242
+ if ( ! next || next.startsWith( '--' ) ) {
243
+ args[ key ] = true;
244
+ } else {
245
+ args[ key ] = args[ key ] ? [].concat( args[ key ], next ) : next;
246
+ i++;
247
+ }
248
+ }
249
+ }
250
+ return args;
251
+ }
252
+
253
+ /**
254
+ * Stable JSON: object keys sorted recursively so output is byte-stable.
255
+ */
256
+ function stableStringify( value ) {
257
+ return JSON.stringify( sortKeys( value ), null, '\t' );
258
+ }
259
+
260
+ function sortKeys( value ) {
261
+ if ( Array.isArray( value ) ) {
262
+ return value.map( sortKeys );
263
+ }
264
+ if ( value && typeof value === 'object' ) {
265
+ return Object.keys( value )
266
+ .sort()
267
+ .reduce( ( acc, key ) => {
268
+ acc[ key ] = sortKeys( value[ key ] );
269
+ return acc;
270
+ }, {} );
271
+ }
272
+ return value;
273
+ }
274
+
275
+ main().catch( ( error ) => {
276
+ process.stderr.write( `kjeks-scan: ${ error.message }\n` );
277
+ process.exitCode = 2;
278
+ } );
package/src/collect.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Per-state forensic collection.
3
+ *
4
+ * Attaches network listeners, drives a page, and reads all client-side storage
5
+ * surfaces. HttpOnly cookies are only available via context.cookies() (CDP),
6
+ * never document.cookie — which is exactly why a real, CDP-capable browser is
7
+ * required (see docs/adr/0005).
8
+ */
9
+
10
+ /**
11
+ * Approximate registrable domain (eTLD+1). Good enough for first/third-party
12
+ * classification; documented as a known limitation.
13
+ *
14
+ * @param {string} host
15
+ */
16
+ export function registrableDomain( host ) {
17
+ const labels = String( host || '' ).split( '.' ).filter( Boolean );
18
+ if ( labels.length <= 2 ) {
19
+ return labels.join( '.' );
20
+ }
21
+ return labels.slice( -2 ).join( '.' );
22
+ }
23
+
24
+ function isBeacon( request ) {
25
+ const type = request.resourceType();
26
+ if ( type === 'beacon' || type === 'ping' ) {
27
+ return true;
28
+ }
29
+ const url = request.url();
30
+ return /\.(gif|png)(\?|$)/i.test( url ) && /(pixel|track|beacon|collect|pageview)/i.test( url );
31
+ }
32
+
33
+ /**
34
+ * Collects observations for a single already-configured page.
35
+ *
36
+ * @param {import('playwright').Page} page
37
+ * @param {import('playwright').BrowserContext} context
38
+ * @param {object} options
39
+ * @param {string} options.firstPartyDomain Registrable domain of the site.
40
+ * @param {string[]} options.paths Paths to visit (relative or absolute).
41
+ * @param {string} options.baseUrl Site base URL.
42
+ */
43
+ export async function collect( page, context, { firstPartyDomain, paths, baseUrl } ) {
44
+ const requests = [];
45
+ const redirects = [];
46
+ const setCookies = [];
47
+ let currentPath = paths && paths.length ? String( paths[ 0 ] ) : '/';
48
+
49
+ page.on( 'request', ( request ) => {
50
+ let host = '';
51
+ try {
52
+ host = new URL( request.url() ).hostname;
53
+ } catch ( e ) {
54
+ host = '';
55
+ }
56
+ const party = registrableDomain( host ) === firstPartyDomain ? 'first' : 'third';
57
+ requests.push( {
58
+ url: request.url(),
59
+ host,
60
+ method: request.method(),
61
+ resourceType: request.resourceType(),
62
+ party,
63
+ beacon: isBeacon( request ),
64
+ path: currentPath,
65
+ } );
66
+ } );
67
+
68
+ page.on( 'response', ( response ) => {
69
+ const headers = response.headers();
70
+ if ( headers[ 'set-cookie' ] ) {
71
+ setCookies.push( { url: response.url(), setCookie: headers[ 'set-cookie' ] } );
72
+ }
73
+ if ( response.status() >= 300 && response.status() < 400 && headers.location ) {
74
+ redirects.push( { from: response.url(), to: headers.location } );
75
+ }
76
+ } );
77
+
78
+ // First path where each cumulative cookie / storage key appears — the basis
79
+ // for per-URL attribution (which page a tracker actually loads on).
80
+ const cookieFirstSeen = new Map();
81
+ const localFirstSeen = new Map();
82
+ const sessionFirstSeen = new Map();
83
+ const idbFirstSeen = new Map();
84
+
85
+ let lastCookies = [];
86
+ let lastStorage = { documentCookie: [], localStorage: [], sessionStorage: [], indexedDB: [], scripts: [], iframes: [] };
87
+
88
+ // Resolve paths relative to the site base so subdirectory multisites work:
89
+ // new URL( '/', 'https://host/sub/' ) would resolve to the domain root and
90
+ // scan the wrong site. Treat a leading-slash path as relative to the base.
91
+ const base = baseUrl.endsWith( '/' ) ? baseUrl : baseUrl + '/';
92
+ for ( const path of paths ) {
93
+ currentPath = String( path );
94
+ const target = path.startsWith( 'http' )
95
+ ? path
96
+ : new URL( String( path ).replace( /^\/+/, '' ), base ).toString();
97
+ await page.goto( target, { waitUntil: 'networkidle', timeout: 30000 } ).catch( () => {} );
98
+
99
+ // Snapshot after each path; the delta attributes new items to this path.
100
+ lastCookies = await context.cookies();
101
+ lastStorage = await page.evaluate( readStorage );
102
+
103
+ for ( const c of lastCookies ) {
104
+ const key = `${ c.name }|${ c.domain }`;
105
+ if ( ! cookieFirstSeen.has( key ) ) {
106
+ cookieFirstSeen.set( key, currentPath );
107
+ }
108
+ }
109
+ for ( const k of lastStorage.localStorage ) {
110
+ if ( ! localFirstSeen.has( k ) ) {
111
+ localFirstSeen.set( k, currentPath );
112
+ }
113
+ }
114
+ for ( const k of lastStorage.sessionStorage ) {
115
+ if ( ! sessionFirstSeen.has( k ) ) {
116
+ sessionFirstSeen.set( k, currentPath );
117
+ }
118
+ }
119
+ for ( const k of lastStorage.indexedDB ) {
120
+ if ( ! idbFirstSeen.has( k ) ) {
121
+ idbFirstSeen.set( k, currentPath );
122
+ }
123
+ }
124
+ }
125
+
126
+ const cookies = lastCookies;
127
+ const storage = lastStorage;
128
+ const sources = buildSources( { requests, cookieFirstSeen, localFirstSeen, sessionFirstSeen, idbFirstSeen } );
129
+
130
+ return {
131
+ cookies: cookies.map( ( c ) => ( {
132
+ name: c.name,
133
+ domain: c.domain,
134
+ path: c.path,
135
+ secure: c.secure,
136
+ http_only: c.httpOnly,
137
+ same_site: c.sameSite,
138
+ session: c.expires === -1,
139
+ party: registrableDomain( ( c.domain || '' ).replace( /^\./, '' ) ) === firstPartyDomain ? 'first' : 'third',
140
+ } ) ),
141
+ documentCookie: storage.documentCookie,
142
+ localStorage: storage.localStorage,
143
+ sessionStorage: storage.sessionStorage,
144
+ indexedDB: storage.indexedDB,
145
+ scripts: storage.scripts,
146
+ iframes: storage.iframes,
147
+ requests,
148
+ redirects,
149
+ setCookies,
150
+ sources,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Maps observation keys to the sorted list of paths that produced them. Keys
156
+ * match scan.js deriveObservations() so attribution merges cleanly.
157
+ */
158
+ function buildSources( { requests, cookieFirstSeen, localFirstSeen, sessionFirstSeen, idbFirstSeen } ) {
159
+ const sources = {};
160
+ const add = ( key, path ) => {
161
+ if ( ! path ) {
162
+ return;
163
+ }
164
+ ( sources[ key ] ||= new Set() ).add( path );
165
+ };
166
+
167
+ for ( const r of requests ) {
168
+ if ( r.party === 'third' ) {
169
+ add( `script|${ r.host }`, r.path );
170
+ }
171
+ if ( r.beacon ) {
172
+ add( `pixel|${ stripQuery( r.url ) }`, r.path );
173
+ }
174
+ }
175
+ for ( const [ key, path ] of cookieFirstSeen ) {
176
+ add( `cookie|${ key }`, path );
177
+ }
178
+ for ( const [ key, path ] of localFirstSeen ) {
179
+ add( `localstorage|${ key }`, path );
180
+ }
181
+ for ( const [ key, path ] of sessionFirstSeen ) {
182
+ add( `sessionstorage|${ key }`, path );
183
+ }
184
+ for ( const [ key, path ] of idbFirstSeen ) {
185
+ add( `indexeddb|${ key }`, path );
186
+ }
187
+
188
+ const out = {};
189
+ for ( const key of Object.keys( sources ) ) {
190
+ out[ key ] = Array.from( sources[ key ] ).sort();
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function stripQuery( url ) {
196
+ try {
197
+ const u = new URL( url );
198
+ return u.origin + u.pathname;
199
+ } catch ( e ) {
200
+ return url;
201
+ }
202
+ }
203
+
204
+ /* Runs in the page context. */
205
+ function readStorage() {
206
+ const names = ( store ) => {
207
+ try {
208
+ return Object.keys( store );
209
+ } catch ( e ) {
210
+ return [];
211
+ }
212
+ };
213
+ return Promise.resolve()
214
+ .then( async () => {
215
+ let idb = [];
216
+ try {
217
+ if ( indexedDB.databases ) {
218
+ idb = ( await indexedDB.databases() ).map( ( d ) => d.name ).filter( Boolean );
219
+ }
220
+ } catch ( e ) {
221
+ idb = [];
222
+ }
223
+ return {
224
+ documentCookie: ( document.cookie || '' )
225
+ .split( ';' )
226
+ .map( ( c ) => c.split( '=' )[ 0 ].trim() )
227
+ .filter( Boolean ),
228
+ localStorage: names( window.localStorage ),
229
+ sessionStorage: names( window.sessionStorage ),
230
+ indexedDB: idb,
231
+ scripts: Array.from( document.querySelectorAll( 'script[src]' ) ).map( ( s ) => s.src ),
232
+ iframes: Array.from( document.querySelectorAll( 'iframe[src]' ) ).map( ( f ) => f.src ),
233
+ };
234
+ } );
235
+ }
package/src/consent.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Consent-state matrix and the shared consent-record schema.
3
+ *
4
+ * Mirrors the PHP `Soderlind\Kjeks\Consent\ConsentSchema` wire format so the
5
+ * scanner can drive each consent state deterministically by injecting the
6
+ * record directly, rather than clicking the banner UI.
7
+ */
8
+
9
+ export const COOKIE_NAME = 'kjeks_consent';
10
+ export const STORAGE_KEY = 'kjeks_consent';
11
+
12
+ // Must match Categories::optional() on the PHP side.
13
+ export const OPTIONAL_CATEGORIES = [ 'preferences', 'analytics', 'marketing' ];
14
+
15
+ /**
16
+ * Builds the ordered list of consent states to scan for one site.
17
+ *
18
+ * @returns {{ id: string, choices: Record<string, boolean> | null }[]}
19
+ */
20
+ export function consentStates() {
21
+ const states = [
22
+ // Before any choice: no record injected at all.
23
+ { id: 'before-choice', choices: null },
24
+ { id: 'reject-all', choices: denied() },
25
+ ];
26
+
27
+ // Each optional category granted on its own.
28
+ for ( const category of OPTIONAL_CATEGORIES ) {
29
+ states.push( { id: `only-${ category }`, choices: { ...denied(), [ category ]: true } } );
30
+ }
31
+
32
+ states.push( { id: 'accept-all', choices: accepted() } );
33
+ return states;
34
+ }
35
+
36
+ export function denied() {
37
+ return Object.fromEntries( OPTIONAL_CATEGORIES.map( ( c ) => [ c, false ] ) );
38
+ }
39
+
40
+ export function accepted() {
41
+ return Object.fromEntries( OPTIONAL_CATEGORIES.map( ( c ) => [ c, true ] ) );
42
+ }
43
+
44
+ /**
45
+ * Encodes a consent record in the shared wire format.
46
+ *
47
+ * @param {Record<string, boolean>} choices Optional-category choices.
48
+ * @param {number} version Policy version.
49
+ * @param {number} blogId Blog id.
50
+ * @param {number} time Unix seconds.
51
+ */
52
+ export function encodeRecord( choices, version, blogId, time ) {
53
+ const c = {};
54
+ for ( const category of OPTIONAL_CATEGORIES ) {
55
+ c[ category ] = choices[ category ] ? 1 : 0;
56
+ }
57
+ return { v: version, t: time, b: blogId, c };
58
+ }
package/src/diff.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Deterministic per-subsite diff against a previous scan.
3
+ */
4
+
5
+ /**
6
+ * Diffs two scan results, per site, by observation identity.
7
+ *
8
+ * @param {object|null} previous Previous scan result (or null for first run).
9
+ * @param {object} current Current scan result.
10
+ * @returns {{ host: string, added: object[], changed: object[], removed: object[] }[]}
11
+ */
12
+ export function diffScans( previous, current ) {
13
+ const previousByHost = indexByHost( previous );
14
+ const out = [];
15
+
16
+ for ( const site of current.sites ) {
17
+ const before = previousByHost.get( site.host );
18
+ out.push( diffSite( site.host, before ? before.observations : [], site.observations ) );
19
+ }
20
+
21
+ return out;
22
+ }
23
+
24
+ function diffSite( host, beforeObs, afterObs ) {
25
+ const before = indexObservations( beforeObs );
26
+ const after = indexObservations( afterObs );
27
+
28
+ const added = [];
29
+ const changed = [];
30
+ const removed = [];
31
+
32
+ for ( const [ key, observation ] of after ) {
33
+ if ( ! before.has( key ) ) {
34
+ added.push( observation );
35
+ } else if ( ! sameAttributes( before.get( key ), observation ) ) {
36
+ changed.push( { from: before.get( key ), to: observation } );
37
+ }
38
+ }
39
+
40
+ for ( const [ key, observation ] of before ) {
41
+ if ( ! after.has( key ) ) {
42
+ removed.push( observation );
43
+ }
44
+ }
45
+
46
+ return {
47
+ host,
48
+ added: added.sort( byName ),
49
+ changed: changed.sort( ( a, b ) => byName( a.to, b.to ) ),
50
+ removed: removed.sort( byName ),
51
+ };
52
+ }
53
+
54
+ function observationKey( o ) {
55
+ return `${ o.storage_type }|${ o.name }|${ o.domain || '' }`;
56
+ }
57
+
58
+ function indexObservations( list ) {
59
+ return new Map( ( list || [] ).map( ( o ) => [ observationKey( o ), o ] ) );
60
+ }
61
+
62
+ function indexByHost( scan ) {
63
+ return new Map( ( scan && scan.sites ? scan.sites : [] ).map( ( s ) => [ s.host, s ] ) );
64
+ }
65
+
66
+ /**
67
+ * Compares attributes that matter, ignoring volatile fields like triggered_by
68
+ * ordering (already sorted) and observation timestamps.
69
+ */
70
+ function sameAttributes( a, b ) {
71
+ const fields = [ 'party', 'secure', 'http_only', 'same_site', 'retention', 'path' ];
72
+ for ( const field of fields ) {
73
+ if ( ( a[ field ] ?? null ) !== ( b[ field ] ?? null ) ) {
74
+ return false;
75
+ }
76
+ }
77
+ return JSON.stringify( a.triggered_by || [] ) === JSON.stringify( b.triggered_by || [] );
78
+ }
79
+
80
+ function byName( a, b ) {
81
+ return observationKey( a ).localeCompare( observationKey( b ) );
82
+ }
package/src/import.js ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Posts scan results to the Kjeks REST import endpoint.
4
+ *
5
+ * Reads one or more scan JSON files and imports each site's observations as
6
+ * UNREVIEWED. Authentication uses a WordPress application password via HTTP
7
+ * Basic auth — supply it through the environment, never on the command line or
8
+ * in the repo.
9
+ *
10
+ * Usage:
11
+ * KJEKS_USER=admin KJEKS_APP_PASSWORD='xxxx xxxx xxxx' \
12
+ * node src/import.js --site https://network.example.com scan/*.json
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ export function basicAuthFromEnv() {
19
+ const user = process.env.KJEKS_USER;
20
+ const password = process.env.KJEKS_APP_PASSWORD;
21
+ if ( ! user || ! password ) {
22
+ throw new Error( 'Set KJEKS_USER and KJEKS_APP_PASSWORD in the environment.' );
23
+ }
24
+ return 'Basic ' + Buffer.from( `${ user }:${ password }` ).toString( 'base64' );
25
+ }
26
+
27
+ /**
28
+ * POSTs each site's observations to the Kjeks import endpoint as UNREVIEWED.
29
+ *
30
+ * @param {string} base Network base URL.
31
+ * @param {object[]} sites Sites with { blog_id, observations }.
32
+ * @param {object} [opts]
33
+ * @param {string} [opts.auth] Authorization header value (default: env basic auth).
34
+ * @param {(message: string, level?: string) => void} [opts.log]
35
+ * @returns {Promise<{ imported: number, failures: number }>}
36
+ */
37
+ export async function importSites( base, sites, opts = {} ) {
38
+ const auth = opts.auth || basicAuthFromEnv();
39
+ const log = opts.log || ( () => {} );
40
+ const endpoint = new URL( '/wp-json/kjeks/v1/import', base ).toString();
41
+
42
+ let imported = 0;
43
+ let failures = 0;
44
+ for ( const site of sites || [] ) {
45
+ const response = await fetch( endpoint, {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json', authorization: auth },
48
+ body: JSON.stringify( { blog_id: site.blog_id, observations: site.observations } ),
49
+ } );
50
+ const body = await response.json().catch( () => ( {} ) );
51
+ if ( ! response.ok ) {
52
+ log( `Import failed for blog ${ site.blog_id }: ${ response.status } ${ JSON.stringify( body ) }`, 'error' );
53
+ failures++;
54
+ continue;
55
+ }
56
+ imported += Number( body.imported ) || 0;
57
+ log( `blog ${ site.blog_id }: imported ${ body.imported } unreviewed observation(s)` );
58
+ }
59
+
60
+ return { imported, failures };
61
+ }
62
+
63
+ async function main() {
64
+ const args = process.argv.slice( 2 );
65
+ const siteIndex = args.indexOf( '--site' );
66
+ if ( siteIndex === -1 ) {
67
+ throw new Error( 'Provide --site <network-base-url>.' );
68
+ }
69
+ const base = args[ siteIndex + 1 ];
70
+ const files = args.filter( ( a, i ) => i !== siteIndex && i !== siteIndex + 1 && ! a.startsWith( '--' ) );
71
+
72
+ let failures = 0;
73
+ for ( const file of files ) {
74
+ const scan = JSON.parse( await readFile( file, 'utf8' ) );
75
+ const result = await importSites( base, scan.sites, {
76
+ log: ( message, level ) => ( level === 'error' ? process.stderr : process.stdout ).write( message + '\n' ),
77
+ } );
78
+ failures += result.failures;
79
+ }
80
+ if ( failures ) {
81
+ process.exitCode = 1;
82
+ }
83
+ }
84
+
85
+ // Only run as a CLI when executed directly (not when imported by scan CLI).
86
+ if ( process.argv[ 1 ] && fileURLToPath( import.meta.url ) === process.argv[ 1 ] ) {
87
+ main().catch( ( error ) => {
88
+ process.stderr.write( `kjeks-import: ${ error.message }\n` );
89
+ process.exitCode = 2;
90
+ } );
91
+ }
package/src/scan.js ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Scan orchestration.
3
+ *
4
+ * For each site and each consent state, opens a FRESH browser context, injects
5
+ * the consent record for that state, visits the configured paths, and collects
6
+ * observations. Produces a deterministic result per site.
7
+ */
8
+
9
+ import { chromium } from 'playwright';
10
+ import { COOKIE_NAME, STORAGE_KEY, consentStates, encodeRecord } from './consent.js';
11
+ import { collect, registrableDomain } from './collect.js';
12
+
13
+ const IGNORED_COOKIES = new Set( [ COOKIE_NAME ] );
14
+
15
+ /**
16
+ * @param {object} config Parsed scanner config.
17
+ * @param {object} [options]
18
+ * @param {string} [options.endpoint] CDP endpoint (e.g. Cloudflare Browser Run). Omit for local Chromium.
19
+ * @param {number} [options.concurrency] Max sites scanned in parallel (default 3).
20
+ * @param {number} [options.perHost] Max parallel scans sharing a hostname (default 2).
21
+ */
22
+ export async function runScan( config, options = {} ) {
23
+ const browser = options.endpoint
24
+ ? await chromium.connectOverCDP( options.endpoint )
25
+ : await chromium.launch( { headless: true } );
26
+
27
+ const concurrency = Math.max( 1, Number( options.concurrency ) || 3 );
28
+ const perHost = Math.max( 1, Number( options.perHost ) || 2 );
29
+
30
+ try {
31
+ const results = await mapWithLimit(
32
+ config.sites || [],
33
+ { concurrency, perHost, hostOf: siteHost },
34
+ ( site ) => scanSite( browser, site, config )
35
+ );
36
+
37
+ const sites = [];
38
+ const errors = [];
39
+ results.forEach( ( result, i ) => {
40
+ if ( result && result.__error ) {
41
+ errors.push( { site: config.sites[ i ], message: String( result.__error && result.__error.message || result.__error ) } );
42
+ } else if ( result ) {
43
+ sites.push( result );
44
+ }
45
+ } );
46
+
47
+ return {
48
+ // generated_at is excluded from diffs; kept for provenance only.
49
+ generated_at: Math.floor( Date.now() / 1000 ),
50
+ sites: sites.sort( ( a, b ) => a.host.localeCompare( b.host ) ),
51
+ errors,
52
+ };
53
+ } finally {
54
+ await browser.close();
55
+ }
56
+ }
57
+
58
+ function siteHost( site ) {
59
+ try {
60
+ return new URL( site.url ).hostname;
61
+ } catch ( e ) {
62
+ return '';
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Runs `worker` over `items` with a global concurrency cap and a per-host cap.
68
+ * A failing item is isolated: its slot holds `{ __error }` and the run continues.
69
+ * Results preserve input order. Exported for testing without a browser.
70
+ *
71
+ * @param {any[]} items
72
+ * @param {{ concurrency: number, perHost: number, hostOf: (item:any)=>string }} limits
73
+ * @param {(item:any, index:number)=>Promise<any>} worker
74
+ * @returns {Promise<any[]>}
75
+ */
76
+ export function mapWithLimit( items, { concurrency, perHost, hostOf }, worker ) {
77
+ const results = new Array( items.length );
78
+ const queue = items.map( ( item, index ) => ( { item, index } ) );
79
+ const hostActive = new Map();
80
+ let active = 0;
81
+
82
+ return new Promise( ( resolve ) => {
83
+ const pump = () => {
84
+ if ( queue.length === 0 && active === 0 ) {
85
+ resolve( results );
86
+ return;
87
+ }
88
+
89
+ for ( let qi = 0; qi < queue.length && active < concurrency; ) {
90
+ const { item, index } = queue[ qi ];
91
+ const host = hostOf( item );
92
+ if ( ( hostActive.get( host ) || 0 ) >= perHost ) {
93
+ qi++;
94
+ continue;
95
+ }
96
+
97
+ queue.splice( qi, 1 );
98
+ active++;
99
+ hostActive.set( host, ( hostActive.get( host ) || 0 ) + 1 );
100
+
101
+ Promise.resolve()
102
+ .then( () => worker( item, index ) )
103
+ .then( ( value ) => {
104
+ results[ index ] = value;
105
+ } )
106
+ .catch( ( error ) => {
107
+ results[ index ] = { __error: error };
108
+ } )
109
+ .finally( () => {
110
+ active--;
111
+ hostActive.set( host, hostActive.get( host ) - 1 );
112
+ pump();
113
+ } );
114
+ // queue shifted; do not advance qi.
115
+ }
116
+ };
117
+
118
+ pump();
119
+ } );
120
+ }
121
+
122
+ async function scanSite( browser, site, config ) {
123
+ const baseUrl = site.url;
124
+ const host = new URL( baseUrl ).hostname;
125
+ const firstPartyDomain = registrableDomain( host );
126
+ const paths = site.paths && site.paths.length ? site.paths : [ '/' ];
127
+ const blogId = site.blog_id || 1;
128
+ const policyVersion = site.policy_version || 1;
129
+
130
+ const states = [];
131
+ const sourcesByState = [];
132
+ for ( const state of consentStates() ) {
133
+ const context = await browser.newContext();
134
+
135
+ if ( state.choices !== null ) {
136
+ const record = encodeRecord( state.choices, policyVersion, blogId, 0 );
137
+ const json = JSON.stringify( record );
138
+ await context.addCookies( [
139
+ {
140
+ name: COOKIE_NAME,
141
+ value: encodeURIComponent( json ),
142
+ domain: host,
143
+ path: '/',
144
+ sameSite: 'Lax',
145
+ },
146
+ ] );
147
+ await context.addInitScript(
148
+ ( { key, value } ) => {
149
+ try {
150
+ window.localStorage.setItem( key, value );
151
+ } catch ( e ) {}
152
+ },
153
+ { key: STORAGE_KEY, value: json }
154
+ );
155
+ }
156
+
157
+ const page = await context.newPage();
158
+ const collected = await collect( page, context, { firstPartyDomain, paths, baseUrl } );
159
+
160
+ if ( site.scenarios ) {
161
+ await runScenarios( page, site.scenarios );
162
+ }
163
+
164
+ states.push( { state: state.id, ...normalizeState( collected, firstPartyDomain ) } );
165
+ sourcesByState.push( { state: state.id, sources: collected.sources || {} } );
166
+ await context.close();
167
+ }
168
+
169
+ return {
170
+ host,
171
+ url: baseUrl,
172
+ blog_id: blogId,
173
+ states: states.sort( ( a, b ) => a.state.localeCompare( b.state ) ),
174
+ observations: deriveObservations( states, sourcesByState ),
175
+ };
176
+ }
177
+
178
+ async function runScenarios( page, scenarios ) {
179
+ for ( const scenario of scenarios ) {
180
+ for ( const step of scenario.steps || [] ) {
181
+ if ( step.action === 'click' && step.selector ) {
182
+ await page.click( step.selector, { timeout: 5000 } ).catch( () => {} );
183
+ }
184
+ if ( step.action === 'wait' && step.ms ) {
185
+ await page.waitForTimeout( step.ms );
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Deterministically normalizes one state's collected data.
193
+ */
194
+ function normalizeState( collected, firstPartyDomain ) {
195
+ const cookies = collected.cookies
196
+ .filter( ( c ) => ! IGNORED_COOKIES.has( c.name ) )
197
+ .sort( byKey( ( c ) => `${ c.name }|${ c.domain }|${ c.path }` ) );
198
+
199
+ const thirdPartyHosts = uniqueSorted(
200
+ collected.requests.filter( ( r ) => r.party === 'third' ).map( ( r ) => r.host )
201
+ );
202
+
203
+ const beacons = uniqueSorted(
204
+ collected.requests.filter( ( r ) => r.beacon ).map( ( r ) => stripQuery( r.url ) )
205
+ );
206
+
207
+ return {
208
+ cookies,
209
+ localStorage: uniqueSorted( collected.localStorage ).filter( ( k ) => k !== STORAGE_KEY ),
210
+ sessionStorage: uniqueSorted( collected.sessionStorage ),
211
+ indexedDB: uniqueSorted( collected.indexedDB ),
212
+ thirdPartyHosts,
213
+ beacons,
214
+ scripts: uniqueSorted( collected.scripts.map( stripQuery ) ),
215
+ iframes: uniqueSorted( collected.iframes.map( stripQuery ) ),
216
+ redirects: collected.redirects.map( ( r ) => ( { from: stripQuery( r.from ), to: stripQuery( r.to ) } ) ),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Builds a flat, import-ready observation list from all states.
222
+ *
223
+ * @param {object[]} states
224
+ * @param {Array<{state: string, sources: object}>} [sourcesByState] Per-state observation-key -> source paths.
225
+ */
226
+ export function deriveObservations( states, sourcesByState = [] ) {
227
+ const sourcesById = new Map( sourcesByState.map( ( s ) => [ s.state, s.sources || {} ] ) );
228
+ const map = new Map();
229
+
230
+ const add = ( key, observation, stateId ) => {
231
+ if ( ! map.has( key ) ) {
232
+ map.set( key, { ...observation, triggered_by: [], source_urls: [] } );
233
+ }
234
+ const existing = map.get( key );
235
+ if ( ! existing.triggered_by.includes( stateId ) ) {
236
+ existing.triggered_by.push( stateId );
237
+ }
238
+ const srcs = ( sourcesById.get( stateId ) || {} )[ key ];
239
+ if ( srcs ) {
240
+ for ( const url of srcs ) {
241
+ if ( ! existing.source_urls.includes( url ) ) {
242
+ existing.source_urls.push( url );
243
+ }
244
+ }
245
+ }
246
+ };
247
+
248
+ for ( const state of states ) {
249
+ for ( const c of state.cookies ) {
250
+ add( `cookie|${ c.name }|${ c.domain }`, {
251
+ name: c.name,
252
+ storage_type: 'cookie',
253
+ domain: c.domain,
254
+ path: c.path,
255
+ party: c.party,
256
+ secure: c.secure,
257
+ http_only: c.http_only,
258
+ same_site: c.same_site,
259
+ retention: c.session ? 'session' : 'persistent',
260
+ }, state.state );
261
+ }
262
+ for ( const key of state.localStorage ) {
263
+ add( `localstorage|${ key }`, { name: key, storage_type: 'localstorage', party: 'first' }, state.state );
264
+ }
265
+ for ( const key of state.sessionStorage ) {
266
+ add( `sessionstorage|${ key }`, { name: key, storage_type: 'sessionstorage', party: 'first' }, state.state );
267
+ }
268
+ for ( const db of state.indexedDB ) {
269
+ add( `indexeddb|${ db }`, { name: db, storage_type: 'indexeddb', party: 'first' }, state.state );
270
+ }
271
+ for ( const host of state.thirdPartyHosts ) {
272
+ add( `script|${ host }`, { name: host, storage_type: 'script', domain: host, party: 'third' }, state.state );
273
+ }
274
+ for ( const url of state.beacons ) {
275
+ add( `pixel|${ url }`, { name: url, storage_type: 'pixel', party: 'third' }, state.state );
276
+ }
277
+ }
278
+
279
+ for ( const observation of map.values() ) {
280
+ observation.triggered_by.sort();
281
+ observation.source_urls.sort();
282
+ }
283
+
284
+ return Array.from( map.values() ).sort(
285
+ byKey( ( o ) => `${ o.storage_type }|${ o.name }|${ o.domain || '' }` )
286
+ );
287
+ }
288
+
289
+ function byKey( keyFn ) {
290
+ return ( a, b ) => keyFn( a ).localeCompare( keyFn( b ) );
291
+ }
292
+
293
+ function uniqueSorted( list ) {
294
+ return Array.from( new Set( list.filter( Boolean ) ) ).sort();
295
+ }
296
+
297
+ function stripQuery( url ) {
298
+ try {
299
+ const u = new URL( url );
300
+ return u.origin + u.pathname;
301
+ } catch ( e ) {
302
+ return url;
303
+ }
304
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Incremental targeting: always re-scan URLs that previously loaded a tracker.
3
+ *
4
+ * Pure helpers (no filesystem) so they unit-test without a browser. The CLI
5
+ * reads the prior committed per-site scan file and feeds its observations here.
6
+ */
7
+
8
+ /**
9
+ * Union of `source_urls` across every observation in a prior scan file.
10
+ *
11
+ * @param {object|null} priorScan Parsed previous per-site scan ({ sites: [...] }).
12
+ * @returns {string[]} Sorted, unique paths that previously produced a tracker.
13
+ */
14
+ export function priorTrackerPaths( priorScan ) {
15
+ if ( ! priorScan || ! Array.isArray( priorScan.sites ) ) {
16
+ return [];
17
+ }
18
+ const set = new Set();
19
+ for ( const site of priorScan.sites ) {
20
+ for ( const observation of site.observations || [] ) {
21
+ for ( const url of observation.source_urls || [] ) {
22
+ set.add( url );
23
+ }
24
+ }
25
+ }
26
+ return Array.from( set ).sort();
27
+ }
28
+
29
+ /**
30
+ * Config paths plus prior tracker paths, de-duped and order-stable.
31
+ *
32
+ * @param {string[]} configPaths
33
+ * @param {string[]} priorPaths
34
+ * @returns {string[]}
35
+ */
36
+ export function mergePaths( configPaths, priorPaths ) {
37
+ const out = [];
38
+ for ( const path of [ ...( configPaths || [] ), ...( priorPaths || [] ) ] ) {
39
+ if ( ! out.includes( path ) ) {
40
+ out.push( path );
41
+ }
42
+ }
43
+ return out.length ? out : [ '/' ];
44
+ }