@ai-ecoverse/slicc-browsers 1.4.0 → 1.6.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/README.md +122 -8
- package/catalog.json +13 -4
- package/guard-driver.mjs +213 -0
- package/guard-vectors.json +5407 -0
- package/package.json +9 -2
- package/protected.json +549 -131
- package/schema/catalog.schema.json +1 -0
- package/schema/guard-vectors.schema.json +581 -0
- package/seven.mjs +6 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The browser catalog, JSON schemas and conformance corpus shared by the two progr
|
|
|
4
4
|
- `slicc browsers` and `slicc launch` in [slicc-cli](https://github.com/ai-ecoverse/slicc-cli) (Go, `CGO_ENABLED=0`);
|
|
5
5
|
- `playwright-cli list` and `open` in [slicc-cdp](https://github.com/ai-ecoverse/slicc-cdp) (Rust, also built for `wasm32-wasip1`).
|
|
6
6
|
|
|
7
|
-
It also holds the [protected origins](#protected-origins): the origins browser control must never reach, which slicc-agent checks too.
|
|
7
|
+
It also holds the [protected origins](#protected-origins): the origins browser control must never reach, which slicc-agent checks too. It also holds the [guard vectors](#guard-vectors), which every CDP guard runs: bios, slicc-cdp, slicc-extension and slicc-cli.
|
|
8
8
|
|
|
9
9
|
They can't share code, so they share data. Three packages wrap the same files, at one version:
|
|
10
10
|
|
|
@@ -24,6 +24,8 @@ They can't share code, so they share data. Three packages wrap the same files, a
|
|
|
24
24
|
| `corpus.json` | every case in one file, generated from `corpus/` by `npm run corpus` |
|
|
25
25
|
| `protected.json`, `schema/protected.schema.json` | the protected-origin test vectors and their schema |
|
|
26
26
|
| `protected.mjs` | the reference protected-origin matcher, in JavaScript (npm only) |
|
|
27
|
+
| `guard-vectors.json`, `schema/guard-vectors.schema.json` | the guard vectors and their schema |
|
|
28
|
+
| `guard-driver.mjs` | the guard vectors' beacon and canary helpers, in JavaScript (npm only) |
|
|
27
29
|
|
|
28
30
|
## Go
|
|
29
31
|
|
|
@@ -38,6 +40,7 @@ b, ok := c.Browser("chrome")
|
|
|
38
40
|
- `(*Catalog).Browser(id)` and `(*Catalog).Priority(id)`, the index in `browsers` (its length for an unknown id).
|
|
39
41
|
- `CatalogJSON()`, `CatalogSchemaJSON()`, `BrowsersSchemaJSON()`, `CorpusJSON()`, `ProtectedJSON()` and `ProtectedSchemaJSON()` return copies of the embedded files.
|
|
40
42
|
- `(*Catalog).ProtectedOrigins` is `catalog.json`'s `protectedOrigins`. `Protected() (*ProtectedVectors, error)` parses `protected.json` (`Origins`, `URLs`, `Targets`, `CookieDomains`, `StorageKeys`; a target's `Opener` is `""`, `OpenerProtected`, `OpenerUnprotected` or `OpenerUnknown`).
|
|
43
|
+
- `Guard() (*GuardVectors, error)` parses `guard-vectors.json`; `GuardVectorsJSON()` and `GuardVectorsSchemaJSON()` return copies of the files. `Guard()` parses it into `SchemaVersion`, `SetVersion`, `Targets`, `Variants`, `Capabilities`, `Placeholders` and `Vectors`. A `GuardExpected` is `Checks` (names to booleans) or `Text`, and a `GuardObservation` is `Means`, `DependsOn` and `Requires`. The `GuardTarget*`, `GuardOrdinary`, `GuardSameSite`, `GuardKind*`, `GuardVia*`, `GuardScopeAny`, `GuardControl`, `GuardGuarded`, `GuardCap*` and `GuardSession*` constants name the values.
|
|
41
44
|
- `Corpus() ([]Case, error)` returns the cases. See [Running the corpus](#running-the-corpus) for the helpers.
|
|
42
45
|
|
|
43
46
|
## Rust
|
|
@@ -50,15 +53,17 @@ slicc-browsers = "1"
|
|
|
50
53
|
slicc-browsers = { version = "1", features = ["corpus"] }
|
|
51
54
|
```
|
|
52
55
|
|
|
53
|
-
- Always: `CATALOG`, `CATALOG_SCHEMA`, `BROWSERS_SCHEMA`, `CORPUS_SCHEMA` and `
|
|
56
|
+
- Always: `CATALOG`, `CATALOG_SCHEMA`, `BROWSERS_SCHEMA`, `CORPUS_SCHEMA`, `PROTECTED_SCHEMA` and `GUARD_VECTORS_SCHEMA`, each the file's text via `include_str!`, and `SCHEMA` (1). No dependencies.
|
|
54
57
|
- Feature `serde`: `Catalog` and the other types (`protected_origins` is a `ProtectedOrigins`), `Catalog::parse(&str)`, `catalog()` (parses `CATALOG`), `Catalog::browser` and `Catalog::priority`. It pulls in `serde` and `serde_json`.
|
|
55
|
-
- Feature `corpus` (implies `serde`): `CORPUS` and `
|
|
58
|
+
- Feature `corpus` (implies `serde`): `CORPUS`, `PROTECTED` and `GUARD_VECTORS`, the text of `corpus.json`, `protected.json` and `guard-vectors.json`; the module `corpus` with `corpus()`, `Case`, `Layout` and the helpers below; the module `protected` with `vectors()`, `Vectors` and the `OPENER_*` constants; and the module `guard` with `vectors()`, `Vectors`, `Vector`, `Step`, `Observation`, `Expected` (`Checks` or `Text`), and the `TARGET_*`, `ORDINARY*`, `KIND_*`, `VIA_*`, `SCOPE_ANY`, `CONTROL`, `GUARDED`, `CAP_*` and `SESSION_*` constants. Use it from `[dev-dependencies]` only; with resolver 2 it stays out of normal builds.
|
|
56
59
|
|
|
57
60
|
Every feature builds for `wasm32-wasip1`.
|
|
58
61
|
|
|
59
62
|
## npm
|
|
60
63
|
|
|
61
|
-
`import catalog from '@ai-ecoverse/slicc-browsers' with { type: 'json' }`. The exports are `.` (`catalog.json`), `./catalog.json`, `./corpus.json`, `./protected.json`, `./protected.mjs`, `./schema/catalog.schema.json`, `./schema/browsers.schema.json`, `./schema/corpus.schema.json` and `./schema/
|
|
64
|
+
`import catalog from '@ai-ecoverse/slicc-browsers' with { type: 'json' }`. The exports are `.` (`catalog.json`), `./catalog.json`, `./corpus.json`, `./protected.json`, `./protected.mjs`, `./guard-vectors.json`, `./guard-driver.mjs`, `./schema/catalog.schema.json`, `./schema/browsers.schema.json`, `./schema/corpus.schema.json`, `./schema/protected.schema.json` and `./schema/guard-vectors.schema.json`.
|
|
65
|
+
|
|
66
|
+
`guard-driver.mjs` is described under [Guard vectors](#guard-vectors).
|
|
62
67
|
|
|
63
68
|
`protected.mjs` is the reference matcher for [protected origins](#protected-origins): `import { protectedOrigins } from '@ai-ecoverse/slicc-browsers/protected.mjs'`, then `protectedOrigins(catalog.protectedOrigins, extraOrigins)` returns `{ url(text), target({ url, previous, opener }), cookieDomain(domain), storageKey(key) }`, each a boolean. It needs only the WHATWG `URL`, so it runs in Node and in a browser.
|
|
64
69
|
|
|
@@ -82,7 +87,7 @@ Every feature builds for `wasm32-wasip1`.
|
|
|
82
87
|
- `names`: an endpoint's name when it isn't from a known browser's user-data dir: `electron` when the user agent contains `electronUserAgent`, else `products[<product>]` for the product before the `/` in `/json/version`'s `Browser`, else that product, else `fallback`;
|
|
83
88
|
- `process`: the process scan, see [The process source](#the-process-source).
|
|
84
89
|
- `seven`:
|
|
85
|
-
- `originScheme` and `originHostPattern`: a seven origin is `https://<host>` with a host matching the pattern, except for the `excludedLabels` (`www`). An origin is compared without the scheme's default port (443 for `https`, 80 for `http`), so `https://seven.sliccy.ai:443` is seven and `https://seven.sliccy.ai:8443` is not. The same goes for extra seven origins;
|
|
90
|
+
- `originScheme` and `originHostPattern`: a seven origin is `https://<host>` with a host matching the pattern, except for the `excludedLabels` (`www`, `vault`, `vault-preview`, `get`, `mcpcat`) and the `excludedLabelPrefixes` (`vault-`, which excludes `vault-feature-x` and any other vault branch preview). The bare apex (`sliccy.ai`, i.e. an empty label) never matches. An origin is compared without the scheme's default port (443 for `https`, 80 for `http`), so `https://seven.sliccy.ai:443` is seven and `https://seven.sliccy.ai:8443` is not. The same goes for extra seven origins;
|
|
86
91
|
- `sitePattern` (`[*.]sliccy.ai`);
|
|
87
92
|
- `switches` and `disabledFeatures`: the seven flags, all `--disable-features` values merged into one switch;
|
|
88
93
|
- `prefs`: values merged into a profile's `Default/Preferences` at the `path` of keys;
|
|
@@ -107,9 +112,9 @@ Browser control must never navigate to, open or attach to a target on seven's ow
|
|
|
107
112
|
3. A scheme in `schemes` is protected when its host is one of `hosts`, or one of `domains` or under one, at any depth and on any port. The host is lower-cased, and one trailing dot is dropped.
|
|
108
113
|
4. Any other URL is protected when its origin is one of the program's **extra origins**: the seven origins it accepts besides `sliccy.ai` (slicc-cli's `--origin` and `--page` origins), compared as `scheme://host[:port]` without the scheme's default port, with the host as in 3.
|
|
109
114
|
|
|
110
|
-
This is wider than the rule for finding seven (`seven.originHostPattern`, which leaves out `www`). Every `sliccy.ai` host shares the `sliccy.ai` cookies, and cookies ignore the port and, without `Secure`, the scheme. SLICC six lives on `www.sliccy.ai` and the apex, and its cookies matter. `*.kernel.localhost` is not protected: each port is its own origin, and the agent's own servers live there. An extra origin is matched exactly, so `http://127.0.0.1:5710` doesn't protect `http://127.0.0.1:5711`.
|
|
115
|
+
This is wider than the rule for finding seven (`seven.originHostPattern`, which leaves out the `excludedLabels` and `excludedLabelPrefixes`—`www`, `vault`, `vault-preview`, `get`, `mcpcat`, and any `vault-*` prefix). Every `sliccy.ai` host shares the `sliccy.ai` cookies, and cookies ignore the port and, without `Secure`, the scheme. SLICC six lives on `www.sliccy.ai` and the apex, and its cookies matter. `*.kernel.localhost` is not protected: each port is its own origin, and the agent's own servers live there. An extra origin is matched exactly, so `http://127.0.0.1:5710` doesn't protect `http://127.0.0.1:5711`.
|
|
111
116
|
|
|
112
|
-
**The vault.** `vault.sliccy.ai` is in `hosts` by name. `sliccy.ai` already covers
|
|
117
|
+
**The vault.** `vault.sliccy.ai` is in `hosts` by name, and vault branch previews (`vault-preview.sliccy.ai`, `vault-*.sliccy.ai`) are covered by the `sliccy.ai` domain rule. `sliccy.ai` already covers them all, but vault must stay protected even if `domains` ever changes: the vault must never be a CDP target for agent tools. The one exception is slicc-cli's own pairing attach for the vault, which is the CLI's own connection and not browser control handed to the agent.
|
|
113
118
|
|
|
114
119
|
**Targets.** A target's effective origin is its URL's (`TargetInfo.url`), except when the URL inherits the origin of whoever made the document: the empty URL, and `inheriting` with any query or fragment. Such a target is protected when it was protected before this URL (a seven tab that went to `about:blank` keeps seven's origin), or when its opener (`openerId`, else `openerFrameId`) is protected or unknown, for an opener closed before the program saw it. The URL, `openerFrameId` and `browserContextId` alone can't tell: a blank popup that seven opens has seven's origin and the URL `about:blank`. A program that holds a session to the target also has the document's real origin: the main frame's `securityOrigin` in `Page.frameNavigated`, and the default context's `origin` in `Runtime.executionContextCreated`. When one of those is protected, the target is protected too, until a URL with an origin of its own replaces it.
|
|
115
120
|
|
|
@@ -123,6 +128,115 @@ A same-site frame shares its parent's process and has no target of its own. Ever
|
|
|
123
128
|
|
|
124
129
|
**`protected.json`** (schema `schema/protected.schema.json`): `origins`, the extra origins every vector runs with; `urls` (`url`, `protected`); `targets` (`url`, `previous?`, whether the target was protected before; `opener?`, `protected`, `unprotected` or `unknown`, and no `opener` for a target without one; `protected`); `cookieDomains` (`domain`, `protected`); `storageKeys` (`key`, `protected`). `npm run validate` runs every vector through `protected.mjs`, and no vector uses a port from 9222 to 9229.
|
|
125
130
|
|
|
131
|
+
## Guard vectors
|
|
132
|
+
|
|
133
|
+
Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-vectors.schema.json`): bios, slicc-cdp, slicc-extension and slicc-cli. The sources are listed in `sources`:
|
|
134
|
+
- slicc-cli's `protectVectors` at `2d5d981` (`test/integration/testdata/link/protect.js`), with the ids kept;
|
|
135
|
+
- slicc-cdp's `test/integration/guard-vectors.json` at `4907a45`, where its `E1`, `E2`, `E3`, `E5` and `B2` are renamed `E1.onload`, `E2.binding`, `E3.bypass-csp`, `E5.console` and `B2.headers`;
|
|
136
|
+
- the cert lane's E and B rows;
|
|
137
|
+
- `C5.continue-request` and `C5.continue-response`, added next to `C5.document`.
|
|
138
|
+
|
|
139
|
+
**Top level.**
|
|
140
|
+
- `schemaVersion` (1): guards pin it.
|
|
141
|
+
- `setVersion`: the package version. The release flow stamps it into the npm package and the crate (`scripts/set-crate-version.mjs`). In git, and so in the Go module, it reads `0.0.0`; Go code gets the version from the module.
|
|
142
|
+
- `targets`: the names a runner maps to a scheme, host and port: `seven`, `vault`, `vault-x` (a vault branch preview), `apex`, `own` (the client page's origin, `{client}`), `ordinary` and `ordinary-same-site`. Each has a `description` and, for the protected ones, a default `host`. Nothing in the file is a URL a runner must use.
|
|
143
|
+
- `variants`: the detach variants in ms. `prompt` and `lagging` are documented; a runner may add others, such as `lagging-8s`, and reports the name it ran.
|
|
144
|
+
- `capabilities`: what a transport may lack. `autoAttach` covers `Target.setAutoAttach`, `children` steps and child sessions; `browserSession` covers calls on the browser session; `browserContexts` covers `Target.createBrowserContext` and `Target.disposeBrowserContext`.
|
|
145
|
+
- `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
|
|
146
|
+
|
|
147
|
+
**A vector** has:
|
|
148
|
+
- `id`, and `family` (`C`, `E` or `B`, the id's first letter);
|
|
149
|
+
- `kind`: `cdp` (runnable steps) or `text` (rows in words, which runners report as `not-run`, never `pass`);
|
|
150
|
+
- `scope`: `any`, or the one guard the vector is for;
|
|
151
|
+
- `setup` and `trigger`, both lists of steps;
|
|
152
|
+
- `targets`: the protected targets it runs against;
|
|
153
|
+
- `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability>`;
|
|
154
|
+
- `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"? }`. `dependsOn` lists the refusals the observation depends on (see Refusals);
|
|
155
|
+
- `live`: the liveness check;
|
|
156
|
+
- `control` (`origin: "ordinary"`, `via`, `expected`) and `expected`: an object of names and booleans, or text;
|
|
157
|
+
- `skip?` (`target`, `reason`): `reason` starts with a code, as in `not-applicable: …`.
|
|
158
|
+
|
|
159
|
+
**Steps** are declarative and transport-neutral. Any step can take `on` (`control` or `guarded`), which runs it on that leg only.
|
|
160
|
+
Any `cdp`, `page` or `children` step can take `requires`, the capabilities it needs: `autoAttach` for `Target.setAutoAttach`, `children` and `session: "child"`, `browserSession` for `session: "browser"`, and `browserContexts` for `Target.createBrowserContext` and `Target.disposeBrowserContext`.
|
|
161
|
+
- `{ "cdp": method, "params"?, "session"?, "as"?, "refusable"?, "refusal"? }`: a CDP call. It runs on the page session by default, on the browser with `browser`, on either with `page-or-browser` (the driver uses the page session), or on the current auto-attached target with `child`. `as` names its outcome. `refusable: true` with `refusal: { "match" }` (a regular expression for the error message or code) marks a call that a guard may refuse at registration.
|
|
162
|
+
- `{ "page": action, … }`:
|
|
163
|
+
- `tab`: a new tab at `url`, in `context`, which becomes the page. `background` defaults to `true`;
|
|
164
|
+
- `navigate`: go to `url` and wait for the load;
|
|
165
|
+
- `href`, `replace`, `form`, `post`: navigate from the page, 50 ms later;
|
|
166
|
+
- `open`: `window.open` with a user gesture;
|
|
167
|
+
- `iframe`: add an iframe;
|
|
168
|
+
- `framed`: load the `/framed` page from `from`, with `url` in an iframe;
|
|
169
|
+
- `fetch`, `xhr`, `image`: load `url` and name the body (`width N` for an image, or `<status> <body>` with `status`); `background` doesn't wait;
|
|
170
|
+
- `eval`: evaluate `expression`.
|
|
171
|
+
`hops` routes `url` through `/hop` on each `origin`, with its `code`.
|
|
172
|
+
- `{ "answer": { "url", "stage"?, "cdp"?, "params"?, "hold"? } }`: answer each paused request or auth challenge for `url` with that call; `hold` leaves it paused. Every other paused request continues unchanged.
|
|
173
|
+
- `{ "children": steps }`: run these steps on each target auto-attached from then on, then resume it.
|
|
174
|
+
- `{ "settle": { "beacon"?, "title"?, "ms"?, "variant"?, "session"? } }`: wait for a beacon from `beacon`, for the page `title`, or for the session to detach. It waits up to `ms` (10000 by default), then another 600 ms. `variant` waits the run's detach variant instead.
|
|
175
|
+
- `{ "wait": ms }`.
|
|
176
|
+
- `{ "guess": { "between", "cdp", "params" }, "as" }`: the call for each request id between the ids paused for the two URLs.
|
|
177
|
+
- `{ "text": … }`: a step in words.
|
|
178
|
+
|
|
179
|
+
Popups are observed by `Target.targetCreated` with the opener's `openerId`, or by the child's `Target.attachedToTarget`, never by the URL: a popup waiting for the debugger can still be `about:blank`.
|
|
180
|
+
|
|
181
|
+
**The stand-in** serves these routes on every origin. It never listens on 9222 to 9229.
|
|
182
|
+
- `/doc?n=`: an HTML page with the `data-canary` marker; `csp=1` adds `Content-Security-Policy: script-src 'none'` and an inline script that sends a beacon tagged `inline-<n>` to the log, which the CSP blocks unless it is bypassed;
|
|
183
|
+
- `/hop?code=&to=` and `/framed?src=`;
|
|
184
|
+
- `/data?n=`: `real-<n>`, with CORS;
|
|
185
|
+
- `/img`: a 1×1 PNG;
|
|
186
|
+
- `/auth`: Basic `u`:`p`, then `authed`;
|
|
187
|
+
- `/rewritten` (title `rewritten`);
|
|
188
|
+
- `/plant`: sets the canary;
|
|
189
|
+
- `/beacon`, and `/log?n=`, which returns `{ hits, beacons }`.
|
|
190
|
+
|
|
191
|
+
**The control rule.** Every vector has a control on an ordinary origin: the same setup and trigger against an unprotected origin must succeed. A missing or failing control fails the id. With `via: "guard"`, the control runs through the guard. With `via: "unguarded"`, the runner runs setup and trigger through its own CDP against the ordinary origin and against the protected stand-in, and both must show the effect. The runner reports the control's transport as `via`.
|
|
192
|
+
|
|
193
|
+
**Refusals.** Each refusable step has a refusal name: its `as`, or `refused.<i>`, where `i` is its index in the vector's steps, setup then trigger, with each `children` step's steps right after it, counted from 0 before `on` is applied. When a guard refuses the step and the error matches `refusal.match`, the runner sets that name to `true` in the leg's observations. On that leg, an observation whose `dependsOn` lists the name is skipped; every other observation is checked as usual. A guard that refuses the step on every origin passes in one of two ways: through the guard, with the step refused on both legs, or with an unguarded control and the guard's matched refusal on the guarded leg. With `via: "guard"`, a refusal on one leg only fails that leg. A guard that allows the step must pass its control through the guard.
|
|
194
|
+
|
|
195
|
+
**Liveness.** A runner checks `live` through its own CDP, never through the guard path: either the protected document loaded (title, marker or URL), or the guard returned its own refusal for a refusable step. A failing `live` fails the id.
|
|
196
|
+
|
|
197
|
+
**Design-dependent outcomes.** A key listed in `designDependent` may expect `"either"`, which `true` and `false` both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. The hard negatives stay required.
|
|
198
|
+
|
|
199
|
+
**Observability.** `observable.own: false` marks a vector the guard host's own origin can't show (headers, auth, redirects); a runner whose `own` is a static server reports it as `skip` with `not-observable`.
|
|
200
|
+
|
|
201
|
+
**Applies to.** `appliesTo` lists the triggers a vector runs with (`href`, `iframe`, `popup`, `redirect`). Chrome doesn't apply some session overrides inside a cross-site iframe, so those E6 rows list `href` only.
|
|
202
|
+
|
|
203
|
+
**Variants.** `variants` gives the delay in milliseconds before the client's `Target.detachFromTarget`: `prompt` 0, `lagging` 8000. A runner may add its own variant names.
|
|
204
|
+
|
|
205
|
+
**Reports.** There is one row per id, target, variant and transport. `control`, `guarded` and `live` are each `pass`, `fail`, `skip` (with the skip's `reason`) or `not-run` (for text rows):
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{ "id": "C1.script-nav", "target": "vault", "variant": "lagging", "transport": "cli", "via": "guard", "control": "pass", "guarded": "pass", "live": "pass" }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**`guard-driver.mjs`** has no dependencies and no imports. It uses no Node APIs, so it runs in a plain browser page as well as in Node. It drives a client of this shape:
|
|
212
|
+
- `send(method, params, sessionId)`: returns a promise of the result, and rejects with an error carrying `cdp`, the CDP error;
|
|
213
|
+
- `waitEvent(predicate, ms)`: resolves with the first matching event `{ method, params, sessionId }`;
|
|
214
|
+
- `events`: every event received so far.
|
|
215
|
+
|
|
216
|
+
It exports:
|
|
217
|
+
- `beacon(log, tag)` and `probe(log, tag)`: the sources for `{beacon}` and `{probe}`;
|
|
218
|
+
- `canary.plant(value)`, `canary.read()` and `canary.untouched(value, read)`: the canary in `localStorage`, IndexedDB and the stand-in's marker;
|
|
219
|
+
- `hop`, `expand(value, vars)`, and `steps(vector, side, vars)`, a leg's expanded steps, with `refusal.name` on each refusable step and `background` set on each `tab` step;
|
|
220
|
+
- `refusals(vector)`: the vector's refusal names;
|
|
221
|
+
- `outcome`, `call(client, step, sessionId)` and `isRefusal(outcome, step)`;
|
|
222
|
+
- `instrument(client, sessionId, log, tag)`, `load(client, sessionId, url)` and `popup(client, openerId)`;
|
|
223
|
+
- `judge(vector, side, observed)`: `{ pass, refused, skipped, mismatches }`, where `refused` lists the leg's refusals and `skipped` the observations that depend on them;
|
|
224
|
+
- `result(vector, run)`: a report row from `{ target, variant, transport, via?, capabilities?, control, guarded, live }`. `capabilities` is the transport's; without it, the transport has them all;
|
|
225
|
+
- `summary({ guard, sha, set, results })`: `set` is the parsed `guard-vectors.json`. It returns `{ set, schemaVersion, guard, sha, variants, targets, transports, results, skipped, notRun, missing, pass, line }`. `missing` lists each `{ id, target, variant, transport }` without a row, for every vector whose `scope` is `any` or the guard, every target it lists, every variant in `variants` or `results`, and every transport in `results`; `pass` needs it empty, where `line` is `GUARD-PASS <guard>@<sha> (vectors @ai-ecoverse/slicc-browsers@X.Y.Z)` or `GUARD-FAIL …`.
|
|
226
|
+
|
|
227
|
+
**`npm run validate`** checks that:
|
|
228
|
+
- the file matches the schema, and ids are unique;
|
|
229
|
+
- every vector has a control and an `expected`, text rows included;
|
|
230
|
+
- each id is in its family;
|
|
231
|
+
- a `cdp` vector has a `cdp` or `page` step, and a `text` vector has neither;
|
|
232
|
+
- every CDP method on the guarded leg also runs on the control;
|
|
233
|
+
- a skip names one of the vector's targets;
|
|
234
|
+
- `observe` defines every expected name;
|
|
235
|
+
- in a vector with a refusable step, every observation, except an `as` of a step before the first refusable one, has `dependsOn`, and each name in it is a refusable step's refusal name;
|
|
236
|
+
- every `Target.setAutoAttach`, `children`, child-session, browser-session and browser-context step has the matching `requires`, and a vector's `requires` is the union of its steps' and observations';
|
|
237
|
+
- every `{name}` is in `placeholders`;
|
|
238
|
+
- no URL uses a port from 9222 to 9229.
|
|
239
|
+
|
|
126
240
|
## The process source
|
|
127
241
|
|
|
128
242
|
The process scan finds browsers started with a debugging port by something else:
|
|
@@ -232,7 +346,7 @@ for case in corpus().cases {
|
|
|
232
346
|
|
|
233
347
|
Go 1.26, Rust stable with `wasm32-wasip1`, Node ≥ 24.
|
|
234
348
|
- `npm run corpus` regenerates `corpus.json` from `corpus/`; the pre-commit hook and CI fail when it's stale.
|
|
235
|
-
- `npm run validate` checks `catalog.json`, `corpus.json` and `
|
|
349
|
+
- `npm run validate` checks `catalog.json`, `corpus.json`, `protected.json` and `guard-vectors.json` against the schemas, runs the protected-origin vectors through `protected.mjs`, and checks what a schema can't say: unique ids, bundle ids and names, known Windows roots, rising tiers, compiling patterns, known tokens, tier order, `sevenOpen`, `six` and `auto` in each `expected.json`, and no port from 9222 to 9229.
|
|
236
350
|
- `npm run lint`: the slicc lint tools (Biome, no comments, no unit tests in git, `AGENTS.md`), `gofmt`, `go mod tidy -diff`, `go vet`, golangci-lint, `cargo fmt --check` and clippy.
|
|
237
351
|
- `npm test`: `validate`, the Go tests in `test/integration/`, and `cargo test --all-features`.
|
|
238
352
|
- `npm run build`: the Go module, the crate (also for `wasm32-wasip1`), and `npm pack --dry-run`.
|
package/catalog.json
CHANGED
|
@@ -642,7 +642,8 @@
|
|
|
642
642
|
"seven": {
|
|
643
643
|
"originScheme": "https",
|
|
644
644
|
"originHostPattern": "^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\\.sliccy\\.ai$",
|
|
645
|
-
"excludedLabels": ["www"],
|
|
645
|
+
"excludedLabels": ["www", "vault", "vault-preview", "get", "mcpcat"],
|
|
646
|
+
"excludedLabelPrefixes": ["vault-"],
|
|
646
647
|
"sitePattern": "[*.]sliccy.ai",
|
|
647
648
|
"switches": [
|
|
648
649
|
"--disable-background-timer-throttling",
|
|
@@ -719,15 +720,23 @@
|
|
|
719
720
|
"six": {
|
|
720
721
|
"roots": {
|
|
721
722
|
"macos": {
|
|
722
|
-
"base": {
|
|
723
|
+
"base": {
|
|
724
|
+
"home": ["Library", "Application Support"]
|
|
725
|
+
},
|
|
723
726
|
"path": ["Slicc", "profiles"]
|
|
724
727
|
},
|
|
725
728
|
"linux": {
|
|
726
|
-
"base": {
|
|
729
|
+
"base": {
|
|
730
|
+
"env": "XDG_STATE_HOME",
|
|
731
|
+
"home": [".local", "state"]
|
|
732
|
+
},
|
|
727
733
|
"path": ["slicc", "profiles"]
|
|
728
734
|
},
|
|
729
735
|
"windows": {
|
|
730
|
-
"base": {
|
|
736
|
+
"base": {
|
|
737
|
+
"env": "LOCALAPPDATA",
|
|
738
|
+
"home": ["AppData", "Local"]
|
|
739
|
+
},
|
|
731
740
|
"path": ["Slicc", "profiles"]
|
|
732
741
|
}
|
|
733
742
|
},
|
package/guard-driver.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const q = JSON.stringify;
|
|
2
|
+
|
|
3
|
+
export const beacon = (log, tag) =>
|
|
4
|
+
`try { navigator.sendBeacon(${q(log)} + '/beacon?tag=' + ${q(tag)} + '&origin=' + encodeURIComponent(location.origin)); } catch {}`;
|
|
5
|
+
|
|
6
|
+
export const probe = (log, tag) =>
|
|
7
|
+
`(() => { ${beacon(log, tag)} const p = btoa(location.origin + location.pathname); if (window.probeBinding) window.probeBinding(p); console.log('probe:' + p); })()`;
|
|
8
|
+
|
|
9
|
+
const openCanary = `new Promise((resolve) => { const r = indexedDB.open('canary', 1); r.onupgradeneeded = () => r.result.createObjectStore('canary'); r.onsuccess = () => resolve(r.result); r.onerror = () => resolve(null); })`;
|
|
10
|
+
|
|
11
|
+
export const canary = {
|
|
12
|
+
plant: (value) =>
|
|
13
|
+
`(async () => { localStorage.setItem('canary', ${q(value)}); const db = await ${openCanary}; if (!db) return false; await new Promise((resolve) => { const t = db.transaction('canary', 'readwrite'); t.objectStore('canary').put(${q(value)}, 'canary'); t.oncomplete = resolve; t.onerror = resolve; }); db.close(); return true; })()`,
|
|
14
|
+
read: () =>
|
|
15
|
+
`(async () => { const db = await ${openCanary}; const record = db ? await new Promise((resolve) => { const g = db.transaction('canary').objectStore('canary').get('canary'); g.onsuccess = () => resolve(g.result ?? null); g.onerror = () => resolve(null); }) : null; db?.close(); return { localStorage: localStorage.getItem('canary'), indexedDB: record, marker: document.querySelector('[data-canary]')?.dataset.canary ?? null }; })()`,
|
|
16
|
+
untouched: (value, read) =>
|
|
17
|
+
read?.localStorage === value && read?.indexedDB === value && read?.marker === value,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const hop = (hops, end) =>
|
|
21
|
+
hops.reduceRight(
|
|
22
|
+
(to, { origin, code }) => `${origin}/hop?code=${code}&to=${encodeURIComponent(to)}`,
|
|
23
|
+
end
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export function expand(value, vars) {
|
|
27
|
+
if (typeof value === 'string')
|
|
28
|
+
return value.replace(/\{([A-Za-z][A-Za-z-]*)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
|
|
29
|
+
if (Array.isArray(value)) return value.map((v) => expand(v, vars));
|
|
30
|
+
if (value && typeof value === 'object')
|
|
31
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expand(v, vars)]));
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function named(list, n = { i: 0 }) {
|
|
36
|
+
return list.map((s) => {
|
|
37
|
+
const i = n.i++;
|
|
38
|
+
const step = s.children ? { ...s, children: named(s.children, n) } : { ...s };
|
|
39
|
+
if (s.refusable) step.refusal = { ...s.refusal, name: s.as ?? `refused.${i}` };
|
|
40
|
+
if (s.page === 'tab') step.background = s.background ?? true;
|
|
41
|
+
return step;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const flatten = (list) => list.flatMap((s) => [s, ...flatten(s.children ?? [])]);
|
|
46
|
+
|
|
47
|
+
export const refusals = (vector) =>
|
|
48
|
+
flatten(named([...(vector.setup ?? []), ...(vector.trigger ?? [])]))
|
|
49
|
+
.filter((s) => s.refusable)
|
|
50
|
+
.map((s) => s.refusal.name);
|
|
51
|
+
|
|
52
|
+
export function steps(vector, side, vars) {
|
|
53
|
+
const pick = (list) =>
|
|
54
|
+
list
|
|
55
|
+
.filter((s) => !s.on || s.on === side)
|
|
56
|
+
.map(({ on: _, ...s }) => (s.children ? { ...s, children: pick(s.children) } : s));
|
|
57
|
+
const all = { ...vars, beacon: beacon(vars.log, vars.tag), probe: probe(vars.log, vars.tag) };
|
|
58
|
+
const [setup, trigger] = [vector.setup, vector.trigger].map((list, k, both) =>
|
|
59
|
+
named(list, { i: k === 0 ? 0 : flatten(both[0]).length })
|
|
60
|
+
);
|
|
61
|
+
return { setup: expand(pick(setup), all), trigger: expand(pick(trigger), all) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function outcome(promise) {
|
|
65
|
+
try {
|
|
66
|
+
return { ok: true, result: await promise };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { ok: false, error: error?.cdp ?? { message: String(error?.message ?? error) } };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const isRefusal = (out, step) =>
|
|
73
|
+
Boolean(step?.refusal) &&
|
|
74
|
+
!out.ok &&
|
|
75
|
+
[out.error?.message, out.error?.code]
|
|
76
|
+
.filter((v) => v !== undefined && v !== null)
|
|
77
|
+
.some((v) => new RegExp(step.refusal.match).test(String(v)));
|
|
78
|
+
|
|
79
|
+
export function call(client, step, sessionId) {
|
|
80
|
+
const target = step.session === 'browser' ? undefined : sessionId;
|
|
81
|
+
return outcome(client.send(step.cdp, step.params ?? {}, target));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function instrument(client, sessionId, log, tag) {
|
|
85
|
+
await client.send('Page.enable', {}, sessionId);
|
|
86
|
+
await client.send(
|
|
87
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
88
|
+
{ source: beacon(log, tag) },
|
|
89
|
+
sessionId
|
|
90
|
+
);
|
|
91
|
+
await client.send('Page.setBypassCSP', { enabled: true }, sessionId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function load(client, sessionId, url, ms = 20000) {
|
|
95
|
+
const loaded = client.waitEvent(
|
|
96
|
+
(e) => e.method === 'Page.loadEventFired' && e.sessionId === sessionId,
|
|
97
|
+
ms
|
|
98
|
+
);
|
|
99
|
+
await client.send('Page.navigate', { url }, sessionId);
|
|
100
|
+
await loaded;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function popup(client, openerId, ms = 10000) {
|
|
104
|
+
const isPopup = (e) =>
|
|
105
|
+
(e.method === 'Target.targetCreated' || e.method === 'Target.attachedToTarget') &&
|
|
106
|
+
e.params?.targetInfo?.openerId === openerId;
|
|
107
|
+
const seen = client.events.find(isPopup);
|
|
108
|
+
if (seen) return Promise.resolve(seen.params.targetInfo);
|
|
109
|
+
return client.waitEvent(isPopup, ms).then(
|
|
110
|
+
(e) => e.params.targetInfo,
|
|
111
|
+
() => null
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const matches = (want, got) => (want === 'either' ? typeof got === 'boolean' : got === want);
|
|
116
|
+
|
|
117
|
+
export function judge(vector, side, observed) {
|
|
118
|
+
const want = side === 'control' ? vector.control.expected : vector.expected;
|
|
119
|
+
const refused = refusals(vector).filter((name) => observed?.[name] === true);
|
|
120
|
+
if (typeof want === 'string') {
|
|
121
|
+
const mismatches = observed === true ? [] : [want];
|
|
122
|
+
return { pass: mismatches.length === 0, refused, skipped: [], mismatches };
|
|
123
|
+
}
|
|
124
|
+
const skipped = Object.keys(want).filter((k) =>
|
|
125
|
+
(vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
|
|
126
|
+
);
|
|
127
|
+
const mismatches = Object.entries(want)
|
|
128
|
+
.filter(([k, v]) => !skipped.includes(k) && !matches(v, observed?.[k]))
|
|
129
|
+
.map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
|
|
130
|
+
return { pass: mismatches.length === 0, refused, skipped, mismatches };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function result(vector, run) {
|
|
134
|
+
const row = {
|
|
135
|
+
id: vector.id,
|
|
136
|
+
target: run.target,
|
|
137
|
+
variant: run.variant,
|
|
138
|
+
transport: run.transport,
|
|
139
|
+
via: run.via ?? vector.control.via,
|
|
140
|
+
};
|
|
141
|
+
if (vector.kind === 'text')
|
|
142
|
+
return { ...row, control: 'not-run', guarded: 'not-run', live: 'not-run' };
|
|
143
|
+
if (vector.skip?.target === run.target)
|
|
144
|
+
return { ...row, control: 'skip', guarded: 'skip', live: 'skip', reason: vector.skip.reason };
|
|
145
|
+
const missing = (vector.requires ?? []).find(
|
|
146
|
+
(cap) => Array.isArray(run.capabilities) && !run.capabilities.includes(cap)
|
|
147
|
+
);
|
|
148
|
+
if (missing) {
|
|
149
|
+
const reason = `not-supported:${missing}`;
|
|
150
|
+
return { ...row, control: 'skip', guarded: 'skip', live: 'skip', reason };
|
|
151
|
+
}
|
|
152
|
+
const c = run.control ? judge(vector, 'control', run.control) : null;
|
|
153
|
+
const g = run.guarded ? judge(vector, 'guarded', run.guarded) : null;
|
|
154
|
+
const only = (a, b) => (a?.refused ?? []).filter((name) => !(b?.refused ?? []).includes(name));
|
|
155
|
+
const why = [];
|
|
156
|
+
let control = c?.pass ? 'pass' : 'fail';
|
|
157
|
+
if (!c) why.push('no control');
|
|
158
|
+
else if (c.refused.length > 0 && row.via !== 'guard') {
|
|
159
|
+
control = 'fail';
|
|
160
|
+
why.push('an unguarded control was refused');
|
|
161
|
+
} else if (row.via === 'guard' && g && only(c, g).length > 0) {
|
|
162
|
+
control = 'fail';
|
|
163
|
+
why.push(`refused only on the control: ${only(c, g).join(', ')}`);
|
|
164
|
+
} else why.push(...c.mismatches.map((m) => `control ${m}`));
|
|
165
|
+
let guarded = g?.pass ? 'pass' : 'fail';
|
|
166
|
+
if (!g) why.push('no guarded run');
|
|
167
|
+
else if (row.via === 'guard' && only(g, c).length > 0) {
|
|
168
|
+
guarded = 'fail';
|
|
169
|
+
why.push(`refused only on the protected target: ${only(g, c).join(', ')}`);
|
|
170
|
+
} else if (row.via === 'unguarded' && g.refused.length > 0 && !c?.pass) {
|
|
171
|
+
guarded = 'fail';
|
|
172
|
+
why.push('refused on the protected target without a passing control');
|
|
173
|
+
} else why.push(...g.mismatches);
|
|
174
|
+
const live = run.live === true ? 'pass' : 'fail';
|
|
175
|
+
if (live === 'fail') why.push('not live');
|
|
176
|
+
return { ...row, control, guarded, live, ...(why.length ? { why: why.join('; ') } : {}) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const passed = (r) =>
|
|
180
|
+
[r.control, r.guarded, r.live].every((s) => s === 'pass') ||
|
|
181
|
+
[r.control, r.guarded, r.live].every((s) => s === 'skip') ||
|
|
182
|
+
[r.control, r.guarded, r.live].every((s) => s === 'not-run');
|
|
183
|
+
|
|
184
|
+
export function summary({ guard, sha, set, results }) {
|
|
185
|
+
const uniq = (k) => [...new Set(results.map((r) => r[k]))].sort();
|
|
186
|
+
const variants = [...new Set([...Object.keys(set.variants ?? {}), ...uniq('variant')])].sort();
|
|
187
|
+
const has = new Set(results.map((r) => q([r.id, r.target, r.variant, r.transport])));
|
|
188
|
+
const missing = set.vectors
|
|
189
|
+
.filter((v) => v.scope === 'any' || v.scope === guard)
|
|
190
|
+
.flatMap((v) => v.targets.map((target) => ({ id: v.id, target })))
|
|
191
|
+
.flatMap((row) => variants.map((variant) => ({ ...row, variant })))
|
|
192
|
+
.flatMap((row) => uniq('transport').map((transport) => ({ ...row, transport })))
|
|
193
|
+
.filter((r) => !has.has(q([r.id, r.target, r.variant, r.transport])));
|
|
194
|
+
const pass = results.length > 0 && missing.length === 0 && results.every(passed);
|
|
195
|
+
const name = `@ai-ecoverse/slicc-browsers@${set.setVersion}`;
|
|
196
|
+
return {
|
|
197
|
+
set: name,
|
|
198
|
+
schemaVersion: set.schemaVersion,
|
|
199
|
+
guard,
|
|
200
|
+
sha,
|
|
201
|
+
variants: uniq('variant'),
|
|
202
|
+
targets: uniq('target'),
|
|
203
|
+
transports: uniq('transport'),
|
|
204
|
+
results,
|
|
205
|
+
skipped: results
|
|
206
|
+
.filter((r) => r.control === 'skip')
|
|
207
|
+
.map(({ id, target, reason }) => ({ id, target, reason })),
|
|
208
|
+
notRun: [...new Set(results.filter((r) => r.control === 'not-run').map((r) => r.id))],
|
|
209
|
+
missing,
|
|
210
|
+
pass,
|
|
211
|
+
line: `${pass ? 'GUARD-PASS' : 'GUARD-FAIL'} ${guard}@${sha} (vectors ${name})`,
|
|
212
|
+
};
|
|
213
|
+
}
|