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 +21 -0
- package/README.md +186 -0
- package/package.json +47 -0
- package/src/cli.js +278 -0
- package/src/collect.js +235 -0
- package/src/consent.js +58 -0
- package/src/diff.js +82 -0
- package/src/import.js +91 -0
- package/src/scan.js +304 -0
- package/src/targeting.js +44 -0
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
|
+
}
|
package/src/targeting.js
ADDED
|
@@ -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
|
+
}
|