@ai-ecoverse/slicc-browsers 1.14.10 → 1.15.1
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 +28 -10
- package/guard-runner.mjs +137 -42
- package/guard-vectors.json +377 -2
- package/package.json +4 -4
- package/redirect-secret.mjs +69 -17
- package/schema/guard-vectors.schema.json +26 -5
package/README.md
CHANGED
|
@@ -185,12 +185,15 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
|
|
|
185
185
|
- the cert lane's E and B rows;
|
|
186
186
|
- `C5.continue-request` and `C5.continue-response`, added next to `C5.document`;
|
|
187
187
|
- the `C8` rows for privileged pages: `C8.navigate` (a page navigates to `chrome://settings`, `chrome://password-manager`, an extension page or `file:///`; refused), `C8.view-source` (the same through `view-source:`; refused), `C8.create` (`Target.createTarget` straight on those pages; refused), `C8.browser-ui-listed` (`Target.getTargets` lists no `browser_ui` target) and `C8.browser-ui-attach` (`Target.attachToTarget` by a `browser_ui` target's id; refused). All five have an unguarded control, since the guard refuses them only on the privileged target. Two cases aren't rows, because Chrome itself blocks them, so an unguarded client can't tell a guard from none: a page's `window.open('chrome://settings')`, and an `about:` alias such as `about:settings`, which `Page.navigate` and `Target.createTarget` both turn into `about:blank#blocked`. The `about:` aliases are in `protected.json` instead.
|
|
188
|
+
- slicc-cdp's owner vectors in `test/integration/guard-native.test.mjs` at `d85ac3c`: `C5.script`, a protected script subresource under the client's own `Fetch.enable` `*` at both stages (the guard neither pauses it for the client nor lets the client fulfil it, and the page runs the stand-in's real `/script`, while the control's paused request carries its headers as an object with `User-Agent` and runs the forged script), and `X1.after-exit`, the client lifecycle row below.
|
|
189
|
+
|
|
190
|
+
`X1.after-exit` (family `X`, client lifecycle) needs `clientExit`. Through the guard, the client opens an ordinary tab, registers `Page.addScriptToEvaluateOnNewDocument` with `{beacon}`, `Network.setExtraHTTPHeaders` with `x-probe: {tag}` and `Fetch.enable` `*` with a rule that fulfils the destination with a forged `FULFILLED` page, then loads the `/page` trigger whose `go()` navigates to `{target}/canary/{tag}`. Before the exit it must see its init-script beacon, its header on the trigger request and the trigger's `Fetch.requestPaused`: those are the positive evidence on both legs. Then the client exits, the unguarded observer calls `go()`, and the destination must be served and read as the real `canary` document, with no beacon from `{target}`, no `x-probe` on its request, no forged body and no `Fetch.requestPaused` for it reaching the client: nothing the client registered may outlive it. The control runs the same on `{plain}` through the guard, and expects the same, since an ordinary navigation after the exit must be just as clean. A guard that keeps its upstream session, or its own init scripts, headers or interception, past the client's exit fails the row.
|
|
188
191
|
|
|
189
192
|
The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. The control waits up to 5000 ms (scaled by `settleScale`) for `Debugger.paused`; when it never pauses, `reachable` records `"incomplete"`, not `false`, and the row fails with `extensionWorld debugger: no Debugger.paused within <ms>ms`. The guarded leg waits up to 1500 ms, scaled by `refusalScale`. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
|
|
190
193
|
|
|
191
194
|
The integration launcher loads shared-web 1.25.2’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.2 also loads it by default in its own harness. A transport unable to host a foreign content-script extension omits `extensionWorld` and reports `not-supported:extensionWorld`; an advertised capability with no observed world fails, rather than silently skipping.
|
|
192
195
|
|
|
193
|
-
`C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies but not the probe’s own observer session traffic, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. `Network.getResponseBody` is attempted, once per id, with every network id the client learns for a 301–308 hop: the `requestId` of a Network event carrying the redirect status and the `networkId` of the Fetch response-stage pause. For each registered hop whose id the client learned neither way, the probe reads the hop’s real id from the harness’s own unguarded tap (the observer session, which enables Network on the guarded leg) and attempts the read on the guarded session with it. The evidence’s `networkBodyCarriers` counts the ids per carrier, and `networkBodyPath` names the weakest carrier any id needed: `withheld+tap`, then `fetch-networkId`, then `network`, or `none` without any id. These reads never hold up a Fetch pause, and the probe gives them five seconds after `Fetch.disable` to settle. A read still unanswered then is not a refusal. `networkBodyAttempted` and `networkBodyRefused` hold only when every attempt is refused. On the guarded leg a `fetch-networkId` or `withheld+tap` carrier also needs the same row’s unguarded control to have seen the redirect’s Network events. Without that proof, or with no id at all, both read `incomplete` and the probe returns `redirect-secret: network body leg <reason>`; an attempt that is not refused is measured and fails. Every request the client sees for a registered redirect URL, through its initial `Network.requestWillBeSent` or a redirect `requestWillBeSent` whose `redirectResponse.url` is that URL, must end within the step in a redirect `requestWillBeSent`, `loadingFinished` or `loadingFailed` for that `requestId`. The evidence’s `hops` records per `requestId` the `delivery` (`redacted` for an origin-only protected redirect in place, `withheld` for a terminal without the redirect, `ordinary`, `raw` or `unterminated`) and the `terminal` event method. `requestsTerminated` is false on any `unterminated` request, a measured guard failure rather than missing evidence; `hopsRedacted` is false on any `raw` or `ordinary` hop, so a delivered protected hop must be origin-only, never the raw or a substituted URL; the guarded leg expects it. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
|
|
196
|
+
`C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. After each one it waits, at least 250 ms and up to two seconds scaled by `settleScale` (stall-tolerant), for the fixture to log the redirect URL and every request seen for it to end, before it navigates away. On the guarded leg, once one action's wait times out, the remaining actions skip it, so a guard that never terminates costs that bound once per leg. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies but not the probe’s own observer session traffic, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. `Network.getResponseBody` is attempted, once per id, with every network id the client learns for a 301–308 hop: the `requestId` of a Network event carrying the redirect status and the `networkId` of the Fetch response-stage pause. For each registered hop whose id the client learned neither way, the probe reads the hop’s real id from the harness’s own unguarded tap (the observer session, which enables Network on the guarded leg) and attempts the read on the guarded session with it. The evidence’s `networkBodyCarriers` counts the ids per carrier, and `networkBodyPath` names the weakest carrier any id needed: `withheld+tap`, then `fetch-networkId`, then `network`, or `none` without any id. These reads never hold up a Fetch pause, and the probe gives them five seconds after `Fetch.disable` to settle, and again after the terminal wait for reads that late events started. A read still unanswered then is not a refusal. `networkBodyAttempted` and `networkBodyRefused` hold only when every attempt is refused. On the guarded leg a `fetch-networkId` or `withheld+tap` carrier also needs the same row’s unguarded control to have seen the redirect’s Network events. Without that proof, or with no id at all, both read `incomplete` and the probe returns `redirect-secret: network body leg <reason>`; an attempt that is not refused is measured and fails. Every request the client sees for a registered redirect URL, through its initial `Network.requestWillBeSent` or a redirect `requestWillBeSent` whose `redirectResponse.url` is that URL, must end within the step in a redirect `requestWillBeSent`, `loadingFinished` or `loadingFailed` for that `requestId`. Before the evidence is evaluated, on every leg, the probe waits up to five seconds scaled by `settleScale` (stall-tolerant) for every seen request to reach one of those events. The evidence’s `hops` records per `requestId` the `delivery` (`redacted` for an origin-only protected redirect in place, `withheld` for a terminal without the redirect, `ordinary`, `raw` or `unterminated`) and the `terminal` event method. On the guarded leg `requestsTerminated` is false on any `unterminated` request after that wait, a measured guard failure rather than missing evidence; on the unguarded control an `unterminated` request is missing evidence, so `requestsTerminated` reads `incomplete` and the probe returns `redirect-secret: control requests <ids> unterminated`; `hopsRedacted` is false on any `raw` or `ordinary` hop, so a delivered protected hop must be origin-only, never the raw or a substituted URL; the guarded leg expects it. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
|
|
194
197
|
|
|
195
198
|
`C2.redirect-body` calls `Network.loadNetworkResource` on an ordinary redirect with `includeCredentials: true`, reads any stream with `IO.read`, replays a redirected XHR, requests Page resource content, and captures Page/MHTML and DOM snapshots after a main-frame redirect. It also captures an ordinary parent containing an iframe redirected to vault, including its error document, and overrides a paused ordinary Fetch request to the allowed redirect URL. No protected marker may appear in any message or body, and no request from those operations may send the independently seeded seven cookie. The ordinary control must return the body, resource content and snapshots, and accept the URL override. Its network-body carrier, `networkBodyAttempted`, `networkBodyRefused`, `hops`, `requestsTerminated` and `hopsRedacted` follow `C2.redirect-secret`. Each probe cleans up its redirect registrations, observer session and cookie. The focused Chrome tests use HTTPS with SPKI-pinned fixture trust and default-port protected URLs; host resolver rules route seven and vault to the fixture rather than the public service.
|
|
196
199
|
|
|
@@ -213,11 +216,11 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
|
|
|
213
216
|
- `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.
|
|
214
217
|
- `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`, `ordinary-same-site`, `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-search`, `network-error`, `chrome-extension-page` and `file-root`. Each has a `description` and, for the protected ones, a default `host`. `chrome-newtab` (`chrome://newtab/`), `chrome-new-tab-page` (`chrome://new-tab-page/`), `chrome-settings` (`chrome://settings/`), `chrome-passwords` (`chrome://password-manager/`), `chrome-search` (`chrome-search://most-visited/`), `network-error` (`http://nonexistent.invalid/`), `chrome-extension-page` (a page of Chrome's built-in PDF viewer extension) and `file-root` (`file:///`) carry a `url`, which the runner uses unless `targets` maps the name to something else. No other URL in the file is one a runner must use.
|
|
215
218
|
- `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.
|
|
216
|
-
- `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`; `extensionWorld` means it can host a foreign content-script extension isolated world on the ordinary fixture; `discovery` means session-less `Target.setDiscoverTargets` and `Target.targetCreated`/`Target.targetInfoChanged` delivery; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`.
|
|
219
|
+
- `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`; `extensionWorld` means it can host a foreign content-script extension isolated world on the ordinary fixture; `discovery` means session-less `Target.setDiscoverTargets` and `Target.targetCreated`/`Target.targetInfoChanged` delivery; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`. `clientExit` means the harness can close the guarded client in the middle of a row: the client has `close()`, the `clientFactory` makes a fresh guarded client for the next leg, and an unguarded observer independent of the client outlives it.
|
|
217
220
|
- `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
|
|
218
221
|
|
|
219
222
|
**A vector** has:
|
|
220
|
-
- `id`, and `family` (`C`, `E` or `
|
|
223
|
+
- `id`, and `family` (`C`, `E`, `B` or `X`, the id's first letter);
|
|
221
224
|
- `kind`: `cdp` (runnable steps) or `text` (rows in words, which runners report as `not-run`, never `pass`);
|
|
222
225
|
- `scope`: `any`, or the one guard the vector is for;
|
|
223
226
|
- `setup` and `trigger`, both lists of steps;
|
|
@@ -238,7 +241,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
|
|
|
238
241
|
- `open`: `window.open` with a user gesture;
|
|
239
242
|
- `iframe`: add an iframe;
|
|
240
243
|
- `framed`: load the `/framed` page from `from`, with `url` in an iframe;
|
|
241
|
-
- `fetch`, `xhr`, `image`: load `url` and name the body (`width N` for an image, or `<status> <body>` with `status`); `background` doesn't wait;
|
|
244
|
+
- `fetch`, `xhr`, `image`, `script`: load `url` and name the body (`width N` for an image, `String(globalThis.vectorScript)` after a `<script src>` loads, `error` when it fails, or `<status> <body>` with `status`); `background` doesn't wait;
|
|
242
245
|
- `eval`: evaluate `expression`.
|
|
243
246
|
`hops` routes `url` through `/hop` on each `origin`, with its `code`.
|
|
244
247
|
- `{ "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.
|
|
@@ -247,6 +250,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
|
|
|
247
250
|
- `{ "wait": ms }`.
|
|
248
251
|
- `{ "paused": { "url", "ms" } }`: wait until the client has seen the pause that an `answer` rule for `url` matches (`Fetch.requestPaused` at the rule's stage, or `Fetch.authRequired` for a `Fetch.continueWithAuth` rule). It waits up to `ms`. On the control leg, a pause that never comes fails the row with `paused: no pause for <url> within <ms>ms`. On the guarded leg the guard may hide that pause, so the step waits the full `ms` and doesn't fail. The C5 guessed-id vectors and `C5.auth` use it before their second request, so the probe's interception id falls between the two ids the guess spans.
|
|
249
252
|
- `{ "guess": { "between", "cdp", "params" }, "as" }`: the call for each request id between the ids paused for the two URLs.
|
|
253
|
+
- `{ "client": "exit" }`: the guarded client exits. The runner calls its `close()`, waits up to 5000 ms (scaled by `settleScale`) for the page target to be no longer attached, noting in `notes` when it stays attached, attaches its unguarded observer to the page, and runs every later step of the leg through that observer. Observations still read the exited client's events. It runs only at the top level of `setup` or `trigger`, and the vector requires `clientExit`.
|
|
250
254
|
- `{ "text": … }`: a step in words.
|
|
251
255
|
|
|
252
256
|
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`.
|
|
@@ -256,6 +260,8 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
256
260
|
- `/hop?code=&to=` and `/framed?src=`;
|
|
257
261
|
- `/listener.js`: one line registering a window `load` listener, served with `SourceMap: /listener.js.map`;
|
|
258
262
|
- `/data?n=`: `real-<n>`, with CORS;
|
|
263
|
+
- `/script?n=`: a script setting `globalThis.vectorScript` to `real-<n>`;
|
|
264
|
+
- `/page?k=&to=`: a trigger page whose `go()` sends the page to `to` (`k=href`, `replace`, `popup` or `form`), and `/canary/<tag>`: a document titled `canary` with the marker;
|
|
259
265
|
- `/img`: a 1×1 PNG;
|
|
260
266
|
- `/auth`: Basic `u`:`p`, then `authed`;
|
|
261
267
|
- `/rewritten` (title `rewritten`);
|
|
@@ -325,6 +331,7 @@ It exports:
|
|
|
325
331
|
- `observe` defines every expected name;
|
|
326
332
|
- 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;
|
|
327
333
|
- every `Target.setAutoAttach`, `children`, child-session, browser-session and browser-context step has the matching `requires`, and a vector's `requires` includes its steps' requirements; observation-only requirements can remain local;
|
|
334
|
+
- a client exit step is top-level and its vector requires `clientExit`, and only a vector with a client exit step scopes a check with `exit`;
|
|
328
335
|
- every `{name}` is in `placeholders`;
|
|
329
336
|
- no URL uses a port from 9222 to 9229.
|
|
330
337
|
|
|
@@ -348,7 +355,7 @@ means guarded transport only; rows requiring an observer report
|
|
|
348
355
|
`skips`, the number of skipped rows per reason, and appends
|
|
349
356
|
`skips <n> (<reason>=<count>, ...)` to `line` whenever any row was skipped, so a
|
|
350
357
|
certification line never reads skipped rows as coverage.
|
|
351
|
-
`clientFactory({ variant, detachDelay, vector, target, slot })` is called per row
|
|
358
|
+
`clientFactory({ variant, detachDelay, vector, target, slot, leg? })` is called per row
|
|
352
359
|
and may return either a client or `{ client, unguarded, close, transportVariant }`.
|
|
353
360
|
The factory can configure the guard's own transport delay from this metadata;
|
|
354
361
|
the runner never reaches into the guard. `transportVariant: true` acknowledges
|
|
@@ -363,6 +370,15 @@ of supported names, and a capability that isn't declared is unsupported: a CDP
|
|
|
363
370
|
transport over a DevTools port declares `chromeUrlsAttach: true`, and an
|
|
364
371
|
extension on `chrome.debugger` leaves it out, so `C8.newtab` is skipped with
|
|
365
372
|
`not-supported:chromeUrlsAttach`. `capabilities.scope` selects owner-specific rows; pass the same name as `guard` to `summary()`.
|
|
373
|
+
A row with a client exit step needs `clientExit` declared, a `clientFactory` and an
|
|
374
|
+
unguarded observer that isn't the guarded client: without the capability or the factory
|
|
375
|
+
it is `skip` with `not-supported:clientExit`, and without such an observer, with
|
|
376
|
+
`not-supported:unguarded-observer`, since nothing could watch the page after the exit.
|
|
377
|
+
When the control leg exits, the runner calls `clientFactory` again with `leg: "guarded"`
|
|
378
|
+
for the guarded leg's clients and releases them with their `close` after the row. If
|
|
379
|
+
that call fails, or its observer is the client, the guarded leg is `incomplete`, with
|
|
380
|
+
`clientFactory: <reason>` or `client exit: <reason>` in `incomplete`, never a pass. A
|
|
381
|
+
leg whose client can't exit (it has no `close()`) fails with `client exit: <reason>`.
|
|
366
382
|
|
|
367
383
|
Map `ordinary`, `ordinary-same-site`, `seven`, `vault`, `vault-x`, `apex`, `own`
|
|
368
384
|
and `client` to origins. `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-search`, `network-error`, `chrome-extension-page` and `file-root` default to their `url`.
|
|
@@ -410,7 +426,9 @@ Checks include: `beacon`, `served`, `header` with
|
|
|
410
426
|
`compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
|
|
411
427
|
`hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
|
|
412
428
|
`history`, `frameBlocked`, `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
|
|
413
|
-
checks are not valid liveness proofs.
|
|
429
|
+
checks are not valid liveness proofs. A `beacon`, `served` or `header` check with
|
|
430
|
+
`exit: "before"` reads only the requests logged before the leg's client exit, and with
|
|
431
|
+
`exit: "after"` only those logged after it; on a leg that never exited it is false.
|
|
414
432
|
Compose checks with `all`, `any` or `not`. Predicates support nested partial
|
|
415
433
|
objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
|
|
416
434
|
`{ value, prefix }` for a base64-encoded value with an optional prefix. Reads of titles and
|
|
@@ -497,7 +515,7 @@ Without `tls`, the server and logged URLs remain HTTP. It returns
|
|
|
497
515
|
The server serves document, frame, redirect, CSP, authentication, data, image,
|
|
498
516
|
canary and beacon paths. Configure host resolution in your test browser, not in
|
|
499
517
|
the runner. `npm run test:guard` exercises Chromium twice with no guard, one target per
|
|
500
|
-
invocation: controls must pass and guarded protection expectations must fail; the `C8.newtab` reachability control must instead pass both legs. For a single
|
|
518
|
+
invocation: controls must pass and guarded protection expectations must fail; the `C8.newtab` reachability control and `X1.after-exit`, whose plain client leaves nothing behind when it exits, must instead pass both legs. For a single
|
|
501
519
|
target use `GUARD_TARGET=seven npm run test:guard-target`. The fixture also serves
|
|
502
520
|
`/live` image requests from stand-in documents, independently of injected code.
|
|
503
521
|
|
|
@@ -648,15 +666,15 @@ Changing a table in `slicc-cli/internal/browser` means changing `catalog.json` h
|
|
|
648
666
|
|
|
649
667
|
## Releases
|
|
650
668
|
|
|
651
|
-
semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`).
|
|
669
|
+
semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`).
|
|
652
670
|
1. The tag `vX.Y.Z` and a GitHub release are the Go module's release. `scripts/major-guard.mjs` fails the release when the major doesn't fit the module path in `go.mod` (no suffix allows v0 and v1, `/vN` only vN). The workflow then asks `proxy.golang.org` for the new version and `pkg.go.dev` to fetch it; both only warn.
|
|
653
|
-
2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped.
|
|
671
|
+
2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped. It goes out on the `latest` dist-tag without `--tag`, so npm refuses a version below the current `latest`.
|
|
654
672
|
3. **crates.io:** with the repository variable `SLICC_BROWSERS_CRATES_PUBLISH` set to `true`, `Cargo.toml` is stamped with the version and `cargo publish` runs with a short-lived token from crates.io trusted publishing (`rust-lang/crates-io-auth-action`). A version that is already on crates.io is skipped.
|
|
655
673
|
4. **Cert line:** after the npm publish, the workflow runs `npm pack @ai-ecoverse/slicc-browsers@X.Y.Z` against the registry and appends `cert: @ai-ecoverse/slicc-browsers@X.Y.Z sha256=<hex> gitHead=<sha>` to the `vX.Y.Z` GitHub release and the job summary, once; the cert lane copies it from there.
|
|
656
674
|
|
|
657
675
|
GitHub sometimes rejects the push of `refs/notes/semantic-release-vX.Y.Z` with a bare `(failed)` right after the tag push; no ruleset covers `refs/notes/*`. The semantic-release step runs with `pipefail` and puts `scripts/git-retry.mjs` first on `PATH` as `git`: it runs the real git (`REAL_GIT`) and retries a push of a `refs/notes/` ref up to five times, 5, 10, 15 and 20 seconds apart; any other git command runs once. When semantic-release still fails, the job fails before npm and crates.io publish. Recover without moving the tag: create the GitHub release on the existing tag, then run `gh workflow run release.yml --ref main -f tag=vX.Y.Z`. A tag without its note counts as on the default channel (semantic-release reads a missing note as `channels: [null]`), so the next release builds on it. `test/integration/git-retry.test.mjs` tests the retry with a fake git.
|
|
658
676
|
|
|
659
|
-
A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version.
|
|
677
|
+
A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version.
|
|
660
678
|
|
|
661
679
|
**Go module versions.** A pushed tag is the release: `proxy.golang.org` caches it and `sum.golang.org` records its hash for good. Never move or delete a pushed tag; retract a bad version in `go.mod` and release anew. A `BREAKING CHANGE` needs a `/vN` module path first.
|
|
662
680
|
|
package/guard-runner.mjs
CHANGED
|
@@ -105,6 +105,9 @@ function pageExpression(step, url) {
|
|
|
105
105
|
case 'image':
|
|
106
106
|
expression = `new Promise(resolve=>{const i=new Image();i.onload=()=>resolve('width '+i.naturalWidth);i.onerror=()=>resolve('error');i.src=${q(url)};})`;
|
|
107
107
|
break;
|
|
108
|
+
case 'script':
|
|
109
|
+
expression = `new Promise(resolve=>{const s=document.createElement('script');s.onload=()=>resolve(String(globalThis.vectorScript));s.onerror=()=>resolve('error');s.src=${q(url)};document.head.append(s);})`;
|
|
110
|
+
break;
|
|
108
111
|
default:
|
|
109
112
|
throw new Error(`Unknown page kind: ${step.page}`);
|
|
110
113
|
}
|
|
@@ -148,6 +151,34 @@ class Execution {
|
|
|
148
151
|
this.stopped = false;
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
actor() {
|
|
155
|
+
return this.exitedTo ?? this.client;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async exit() {
|
|
159
|
+
const gone = this.client;
|
|
160
|
+
if (typeof gone.close !== 'function') throw new Error('the guarded client has no close()');
|
|
161
|
+
this.exitRequestStart = (await this.options.fixture.log()).length;
|
|
162
|
+
this.clients.exited = true;
|
|
163
|
+
await gone.close();
|
|
164
|
+
const released = await this.poll(async () => {
|
|
165
|
+
const read = await outcome(
|
|
166
|
+
this.reader.send('Target.getTargetInfo', { targetId: this.pageTarget })
|
|
167
|
+
);
|
|
168
|
+
return read.ok && read.result.targetInfo?.attached === false;
|
|
169
|
+
}, 5000);
|
|
170
|
+
if (!released)
|
|
171
|
+
this.notes.push(`client exit: the page stayed attached for ${this.bound(5000)}ms`);
|
|
172
|
+
const { sessionId } = await this.reader.send('Target.attachToTarget', {
|
|
173
|
+
targetId: this.pageTarget,
|
|
174
|
+
flatten: true,
|
|
175
|
+
});
|
|
176
|
+
await this.reader.send('Page.enable', {}, sessionId);
|
|
177
|
+
this.exitedTo = this.reader;
|
|
178
|
+
this.sessions.page = sessionId;
|
|
179
|
+
return { sessionId, released };
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
session(kind) {
|
|
152
183
|
if (kind === 'browser') return undefined;
|
|
153
184
|
return this.sessions[kind === 'child' ? 'child' : 'page'];
|
|
@@ -642,6 +673,7 @@ class Execution {
|
|
|
642
673
|
(key) => step[key]
|
|
643
674
|
);
|
|
644
675
|
if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
|
|
676
|
+
if (step.client && !out.ok) this.errors.push(`client exit: ${out.error.message}`);
|
|
645
677
|
if (['errorPage', 'redirectSecret'].includes(probe) && out.ok && out.result?.incomplete)
|
|
646
678
|
this.incomplete.push(`${probe}: ${out.result.incomplete}`);
|
|
647
679
|
if (step.as && !out.result?.pending) this.calls[step.as] = out;
|
|
@@ -693,6 +725,7 @@ class Execution {
|
|
|
693
725
|
await this.settle(step.settle);
|
|
694
726
|
return;
|
|
695
727
|
}
|
|
728
|
+
if (step.client) return outcome(this.exit());
|
|
696
729
|
if (step.redirectSecret) {
|
|
697
730
|
this.internalSessions.add(session);
|
|
698
731
|
try {
|
|
@@ -707,6 +740,7 @@ class Execution {
|
|
|
707
740
|
fixture: this.options.fixture,
|
|
708
741
|
mode: step.redirectSecret,
|
|
709
742
|
side: this.side,
|
|
743
|
+
settleScale: this.scale,
|
|
710
744
|
controls: (this.clients.redirectSecretControls ??= {}),
|
|
711
745
|
})
|
|
712
746
|
);
|
|
@@ -793,7 +827,7 @@ class Execution {
|
|
|
793
827
|
(await this.requestCheck({ served: this.actionDestination }, this.actionRequestStart)))
|
|
794
828
|
);
|
|
795
829
|
if (settle.title) return this.check({ title: settle.title, session: settle.session });
|
|
796
|
-
const events = this.
|
|
830
|
+
const events = this.actor().events.slice(this.actionEventStart);
|
|
797
831
|
return events.some(
|
|
798
832
|
(event, index) =>
|
|
799
833
|
event.method === 'Page.frameNavigated' &&
|
|
@@ -850,7 +884,7 @@ class Execution {
|
|
|
850
884
|
session === undefined
|
|
851
885
|
)
|
|
852
886
|
params.browserContextId = this.vars.context;
|
|
853
|
-
const out = await outcome(this.
|
|
887
|
+
const out = await outcome(this.actor().send(step.cdp, params, session));
|
|
854
888
|
if (step.cdp === 'Target.setAutoAttach' && out.ok) {
|
|
855
889
|
if (params.autoAttach) this.attachSettings.set(session, true);
|
|
856
890
|
else this.attachSettings.delete(session);
|
|
@@ -873,7 +907,8 @@ class Execution {
|
|
|
873
907
|
}
|
|
874
908
|
|
|
875
909
|
async page(step, session, phase) {
|
|
876
|
-
|
|
910
|
+
const client = this.actor();
|
|
911
|
+
this.actionEventStart = client.events.length;
|
|
877
912
|
this.actionRequestStart = (await this.options.fixture.log()).length;
|
|
878
913
|
this.actionDestination = step.hops ? step.url : undefined;
|
|
879
914
|
const url = step.hops ? hop(step.hops, step.url) : step.url;
|
|
@@ -885,7 +920,7 @@ class Execution {
|
|
|
885
920
|
const tab = await this.create(
|
|
886
921
|
url,
|
|
887
922
|
step.context ?? this.vars.context,
|
|
888
|
-
|
|
923
|
+
client,
|
|
889
924
|
step.background ?? true
|
|
890
925
|
);
|
|
891
926
|
this.sessions.page = tab.sessionId;
|
|
@@ -894,20 +929,20 @@ class Execution {
|
|
|
894
929
|
}
|
|
895
930
|
if (pre && step.page !== 'framed') this.preSent.push(url);
|
|
896
931
|
if (step.page === 'navigate') {
|
|
897
|
-
if (pre) await this.evaluate(pre, session);
|
|
898
|
-
return this.navigate(
|
|
932
|
+
if (pre) await this.evaluate(pre, session, client);
|
|
933
|
+
return this.navigate(client, session, url);
|
|
899
934
|
}
|
|
900
935
|
if (step.page === 'framed') {
|
|
901
936
|
if (phase === 'trigger') this.preSent.push(url);
|
|
902
937
|
return this.navigate(
|
|
903
|
-
|
|
938
|
+
client,
|
|
904
939
|
session,
|
|
905
940
|
`${step.from}/framed?tag=${encodeURIComponent(this.vars.tag)}&url=${encodeURIComponent(url)}&log=${encodeURIComponent(this.vars.log)}`
|
|
906
941
|
);
|
|
907
942
|
}
|
|
908
943
|
let expression = pageExpression(step, url);
|
|
909
944
|
if (pre) expression = `(() => { ${pre} return (${expression}); })()`;
|
|
910
|
-
const promise = this.evaluate(expression, session);
|
|
945
|
+
const promise = this.evaluate(expression, session, client);
|
|
911
946
|
if (step.background) {
|
|
912
947
|
const done = promise.then((out) => {
|
|
913
948
|
if (typeof step.as === 'string') this.calls[step.as] = out;
|
|
@@ -1127,7 +1162,12 @@ class Execution {
|
|
|
1127
1162
|
}
|
|
1128
1163
|
|
|
1129
1164
|
async requestCheck(c, start = this.requestStart) {
|
|
1130
|
-
|
|
1165
|
+
if (c.exit && this.exitRequestStart === undefined) return false;
|
|
1166
|
+
const all = await this.options.fixture.log();
|
|
1167
|
+
const logs = all.slice(
|
|
1168
|
+
c.exit === 'after' ? this.exitRequestStart : start,
|
|
1169
|
+
c.exit === 'before' ? this.exitRequestStart : all.length
|
|
1170
|
+
);
|
|
1131
1171
|
if (c.hops)
|
|
1132
1172
|
return c.hops.every((_, i) =>
|
|
1133
1173
|
logs.some((entry) => entry?.url === hop(c.hops.slice(i), c.to))
|
|
@@ -1294,6 +1334,15 @@ const inactive = (vector, target, capabilities) =>
|
|
|
1294
1334
|
inapplicable(vector, target, capabilities.scope) || !supported(vector.requires, capabilities);
|
|
1295
1335
|
|
|
1296
1336
|
const allSteps = (list) => list.flatMap((step) => [step, ...allSteps(step.children ?? [])]);
|
|
1337
|
+
const exitsClient = (vector) =>
|
|
1338
|
+
[...(vector.setup ?? []), ...(vector.trigger ?? [])].some((step) => step.client === 'exit');
|
|
1339
|
+
const skipRow = (base, reason) => ({
|
|
1340
|
+
...base,
|
|
1341
|
+
control: 'skip',
|
|
1342
|
+
guarded: 'skip',
|
|
1343
|
+
live: 'skip',
|
|
1344
|
+
reason,
|
|
1345
|
+
});
|
|
1297
1346
|
const browserContextStep = (vector, capabilities) =>
|
|
1298
1347
|
allSteps([...vector.setup, ...vector.trigger]).some(
|
|
1299
1348
|
(step) =>
|
|
@@ -1510,35 +1559,16 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1510
1559
|
live: 'skip',
|
|
1511
1560
|
reason: 'not-supported:browserSession',
|
|
1512
1561
|
};
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
...base,
|
|
1516
|
-
control: 'skip',
|
|
1517
|
-
guarded: 'skip',
|
|
1518
|
-
live: 'skip',
|
|
1519
|
-
reason: 'not-supported:unguarded-observer',
|
|
1520
|
-
};
|
|
1562
|
+
const observerReason = observerSkip(vector, opts, made, clients);
|
|
1563
|
+
if (observerReason) return skipRow(base, observerReason);
|
|
1521
1564
|
const legs = {};
|
|
1565
|
+
const renewed = [];
|
|
1522
1566
|
flights.add({ flight, base, clients });
|
|
1523
1567
|
opts.onStart?.(base);
|
|
1524
|
-
const sides = (
|
|
1525
|
-
for (const side of ['control', 'guarded']) {
|
|
1526
|
-
const vars = variables(opts.targets, target, side, slot, index);
|
|
1527
|
-
const execution = new Execution(vector, side, opts, clients, vars, set.variants[variant]);
|
|
1528
|
-
try {
|
|
1529
|
-
legs[side] = await execution.run();
|
|
1530
|
-
} catch (error) {
|
|
1531
|
-
legs[side] = {
|
|
1532
|
-
observed: {},
|
|
1533
|
-
live: false,
|
|
1534
|
-
errors: [...execution.errors, String(error.message ?? error)],
|
|
1535
|
-
notes: execution.notes,
|
|
1536
|
-
};
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
})();
|
|
1568
|
+
const sides = runLegs({ vector, opts, clients, set, job, slot, index, legs, renewed });
|
|
1540
1569
|
const stuck = await watch(sides, opts.rowTimeoutMs, () => view(flight, base, clients));
|
|
1541
1570
|
for (const entry of flights) if (entry.flight === flight) flights.delete(entry);
|
|
1571
|
+
await closeAll(renewed);
|
|
1542
1572
|
if (stuck) {
|
|
1543
1573
|
flight.abandoned = true;
|
|
1544
1574
|
return timedOut(stuck, base, made, sides, opts);
|
|
@@ -1573,6 +1603,65 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1573
1603
|
return row;
|
|
1574
1604
|
}
|
|
1575
1605
|
|
|
1606
|
+
function observerSkip(vector, opts, made, clients) {
|
|
1607
|
+
if (needsObserver(vector) && !clients.unguarded) return 'not-supported:unguarded-observer';
|
|
1608
|
+
if (!exitsClient(vector)) return undefined;
|
|
1609
|
+
if (!opts.clientFactory) return 'not-supported:clientExit';
|
|
1610
|
+
if (!made.unguarded || made.unguarded === made.client) return 'not-supported:unguarded-observer';
|
|
1611
|
+
return undefined;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
async function runLegs({ vector, opts, clients, set, job, slot, index, legs, renewed }) {
|
|
1615
|
+
for (const side of ['control', 'guarded']) {
|
|
1616
|
+
if (clients.exited) {
|
|
1617
|
+
const missing = await renew(job, opts, set, slot, clients, renewed);
|
|
1618
|
+
if (missing) {
|
|
1619
|
+
legs[side] = unmeasured(vector, missing);
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
const vars = variables(opts.targets, job.target, side, slot, index);
|
|
1624
|
+
const execution = new Execution(vector, side, opts, clients, vars, set.variants[job.variant]);
|
|
1625
|
+
try {
|
|
1626
|
+
legs[side] = await execution.run();
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
legs[side] = {
|
|
1629
|
+
observed: {},
|
|
1630
|
+
live: false,
|
|
1631
|
+
errors: [...execution.errors, String(error.message ?? error)],
|
|
1632
|
+
notes: execution.notes,
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
async function closeAll(closers) {
|
|
1639
|
+
for (const close of closers) await close?.();
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
async function renew(job, opts, set, slot, clients, renewed) {
|
|
1643
|
+
clients.exited = false;
|
|
1644
|
+
const factory = await factoryClients(opts, set, { ...job, leg: 'guarded' }, slot);
|
|
1645
|
+
if (!factory.ok) return `clientFactory: ${factory.error.message}`;
|
|
1646
|
+
const fresh = normalize(factory.result, opts);
|
|
1647
|
+
renewed.push(fresh.close);
|
|
1648
|
+
if (!fresh.unguarded || fresh.unguarded === fresh.client)
|
|
1649
|
+
return 'client exit: the fresh client has no independent unguarded observer';
|
|
1650
|
+
clients.client = tracked(fresh.client, clients.flight.calls, 'client');
|
|
1651
|
+
clients.unguarded = tracked(fresh.unguarded, clients.flight.calls, 'unguarded');
|
|
1652
|
+
return undefined;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function unmeasured(vector, reason) {
|
|
1656
|
+
return {
|
|
1657
|
+
observed: Object.fromEntries(Object.keys(vector.expected).map((name) => [name, 'incomplete'])),
|
|
1658
|
+
live: 'incomplete',
|
|
1659
|
+
errors: [],
|
|
1660
|
+
notes: [],
|
|
1661
|
+
incomplete: [reason],
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1576
1665
|
function settleIncomplete(row, vector, base, legs) {
|
|
1577
1666
|
const incomplete = ['control', 'guarded'].flatMap((side) =>
|
|
1578
1667
|
(legs[side].incomplete ?? []).map((reason) => `${side} ${reason}`)
|
|
@@ -1675,11 +1764,25 @@ function factoryClients(opts, set, job, slot) {
|
|
|
1675
1764
|
detachDelay: set.variants[job.variant],
|
|
1676
1765
|
vector: job.vector,
|
|
1677
1766
|
target: job.target,
|
|
1767
|
+
...(job.leg ? { leg: job.leg } : {}),
|
|
1678
1768
|
})
|
|
1679
1769
|
: opts.client)()
|
|
1680
1770
|
);
|
|
1681
1771
|
}
|
|
1682
1772
|
|
|
1773
|
+
function normalize(made, opts) {
|
|
1774
|
+
const clients =
|
|
1775
|
+
typeof made?.send === 'function'
|
|
1776
|
+
? {
|
|
1777
|
+
client: made,
|
|
1778
|
+
unguarded: opts.unguarded ?? made.unguarded,
|
|
1779
|
+
transportVariant: made.transportVariant,
|
|
1780
|
+
}
|
|
1781
|
+
: (made ?? { client: undefined, unguarded: opts.unguarded });
|
|
1782
|
+
clients.unguarded ??= opts.unguarded;
|
|
1783
|
+
return clients;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1683
1786
|
function unmade(job, message) {
|
|
1684
1787
|
const reason = `clientFactory: ${message}`;
|
|
1685
1788
|
return {
|
|
@@ -1739,15 +1842,7 @@ async function runAll(set, options, flights) {
|
|
|
1739
1842
|
}
|
|
1740
1843
|
const made = factory.result;
|
|
1741
1844
|
transport = (typeof made?.send === 'function' ? made : made?.client)?.transport ?? transport;
|
|
1742
|
-
const clients =
|
|
1743
|
-
typeof made?.send === 'function'
|
|
1744
|
-
? {
|
|
1745
|
-
client: made,
|
|
1746
|
-
unguarded: opts.unguarded ?? made.unguarded,
|
|
1747
|
-
transportVariant: made.transportVariant,
|
|
1748
|
-
}
|
|
1749
|
-
: (made ?? { client: undefined, unguarded: opts.unguarded });
|
|
1750
|
-
clients.unguarded ??= opts.unguarded;
|
|
1845
|
+
const clients = normalize(made, opts);
|
|
1751
1846
|
try {
|
|
1752
1847
|
const stateful = [...job.vector.setup, ...job.vector.trigger].some((s) =>
|
|
1753
1848
|
/Cookie|Storage|IndexedDB|CacheStorage/.test(s.cdp ?? '')
|
package/guard-vectors.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"setVersion": "1.
|
|
3
|
+
"setVersion": "1.15.1",
|
|
4
4
|
"sources": [
|
|
5
5
|
{
|
|
6
6
|
"repo": "ai-ecoverse/slicc-cli",
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
"repo": "cert lane",
|
|
22
22
|
"ref": "guard-rows.md",
|
|
23
23
|
"path": "E1-E6, B1-B7"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"repo": "ai-ecoverse/slicc-cdp",
|
|
27
|
+
"ref": "d85ac3c93d398cbadefda9fc8602f5bbd686b5c6",
|
|
28
|
+
"path": "test/integration/guard-native.test.mjs"
|
|
24
29
|
}
|
|
25
30
|
],
|
|
26
31
|
"targets": {
|
|
@@ -92,7 +97,8 @@
|
|
|
92
97
|
"browserContexts": "Target.createBrowserContext and Target.disposeBrowserContext",
|
|
93
98
|
"extensionWorld": "a browser hosting a foreign content-script extension isolated world on the ordinary fixture page",
|
|
94
99
|
"discovery": "session-less Target.setDiscoverTargets and targetCreated/targetInfoChanged delivery; absent transports must emit neither event",
|
|
95
|
-
"chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't"
|
|
100
|
+
"chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't",
|
|
101
|
+
"clientExit": "closing the guarded client mid-row: the client has close(), the clientFactory makes a fresh guarded client for the next leg, and an independent unguarded observer outlives it"
|
|
96
102
|
},
|
|
97
103
|
"placeholders": {
|
|
98
104
|
"target": "the target's origin; {plain} on the control",
|
|
@@ -9984,6 +9990,172 @@
|
|
|
9984
9990
|
"observer": "client"
|
|
9985
9991
|
}
|
|
9986
9992
|
},
|
|
9993
|
+
{
|
|
9994
|
+
"id": "C5.script",
|
|
9995
|
+
"family": "C",
|
|
9996
|
+
"kind": "cdp",
|
|
9997
|
+
"scope": "any",
|
|
9998
|
+
"setup": [
|
|
9999
|
+
{
|
|
10000
|
+
"text": "a plain page whose own Fetch.enable pauses every request at both stages, and fulfils the probe's script with a forged one"
|
|
10001
|
+
},
|
|
10002
|
+
{
|
|
10003
|
+
"page": "tab",
|
|
10004
|
+
"url": "{ordinary}/doc?n=fetcher-{tag}"
|
|
10005
|
+
},
|
|
10006
|
+
{
|
|
10007
|
+
"cdp": "Fetch.enable",
|
|
10008
|
+
"params": {
|
|
10009
|
+
"patterns": [
|
|
10010
|
+
{
|
|
10011
|
+
"urlPattern": "*",
|
|
10012
|
+
"requestStage": "Request"
|
|
10013
|
+
},
|
|
10014
|
+
{
|
|
10015
|
+
"urlPattern": "*",
|
|
10016
|
+
"requestStage": "Response"
|
|
10017
|
+
}
|
|
10018
|
+
]
|
|
10019
|
+
}
|
|
10020
|
+
},
|
|
10021
|
+
{
|
|
10022
|
+
"answer": {
|
|
10023
|
+
"url": "{target}/script?n={tag}",
|
|
10024
|
+
"stage": "Request",
|
|
10025
|
+
"cdp": "Fetch.fulfillRequest",
|
|
10026
|
+
"params": {
|
|
10027
|
+
"responseCode": 200,
|
|
10028
|
+
"responseHeaders": [
|
|
10029
|
+
{
|
|
10030
|
+
"name": "content-type",
|
|
10031
|
+
"value": "text/javascript"
|
|
10032
|
+
},
|
|
10033
|
+
{
|
|
10034
|
+
"name": "access-control-allow-origin",
|
|
10035
|
+
"value": "*"
|
|
10036
|
+
}
|
|
10037
|
+
],
|
|
10038
|
+
"body": "Z2xvYmFsVGhpcy52ZWN0b3JTY3JpcHQ9J2Z1bGZpbGxlZCc="
|
|
10039
|
+
}
|
|
10040
|
+
}
|
|
10041
|
+
}
|
|
10042
|
+
],
|
|
10043
|
+
"trigger": [
|
|
10044
|
+
{
|
|
10045
|
+
"page": "script",
|
|
10046
|
+
"url": "{target}/script?n={tag}",
|
|
10047
|
+
"as": "body"
|
|
10048
|
+
}
|
|
10049
|
+
],
|
|
10050
|
+
"targets": ["seven", "vault", "vault-x", "apex"],
|
|
10051
|
+
"observe": {
|
|
10052
|
+
"seen": {
|
|
10053
|
+
"means": "a Fetch.requestPaused for {target}/script?n={tag} reached the client",
|
|
10054
|
+
"check": {
|
|
10055
|
+
"event": "Fetch.requestPaused",
|
|
10056
|
+
"where": {
|
|
10057
|
+
"params": {
|
|
10058
|
+
"request": {
|
|
10059
|
+
"url": "{target}/script?n={tag}"
|
|
10060
|
+
}
|
|
10061
|
+
}
|
|
10062
|
+
},
|
|
10063
|
+
"observer": "client"
|
|
10064
|
+
}
|
|
10065
|
+
},
|
|
10066
|
+
"headers": {
|
|
10067
|
+
"means": "the Fetch.requestPaused for {target}/script?n={tag} carried its request headers as an object with User-Agent",
|
|
10068
|
+
"check": {
|
|
10069
|
+
"event": "Fetch.requestPaused",
|
|
10070
|
+
"where": {
|
|
10071
|
+
"params": {
|
|
10072
|
+
"request": {
|
|
10073
|
+
"url": "{target}/script?n={tag}",
|
|
10074
|
+
"headers": {
|
|
10075
|
+
"$regex": "^\\{.*\"[Uu]ser-[Aa]gent\":"
|
|
10076
|
+
}
|
|
10077
|
+
}
|
|
10078
|
+
}
|
|
10079
|
+
},
|
|
10080
|
+
"observer": "client"
|
|
10081
|
+
}
|
|
10082
|
+
},
|
|
10083
|
+
"fulfilled": {
|
|
10084
|
+
"means": "body is 'fulfilled'",
|
|
10085
|
+
"check": {
|
|
10086
|
+
"result": "body",
|
|
10087
|
+
"path": "result.result.value",
|
|
10088
|
+
"equals": "fulfilled",
|
|
10089
|
+
"observer": "client"
|
|
10090
|
+
}
|
|
10091
|
+
},
|
|
10092
|
+
"answered": {
|
|
10093
|
+
"means": "body is 'real-{tag}', the stand-in's own script",
|
|
10094
|
+
"check": {
|
|
10095
|
+
"result": "body",
|
|
10096
|
+
"path": "result.result.value",
|
|
10097
|
+
"equals": "real-{tag}",
|
|
10098
|
+
"observer": "client"
|
|
10099
|
+
}
|
|
10100
|
+
}
|
|
10101
|
+
},
|
|
10102
|
+
"live": "the runner's own CDP sees {target}'s document load (title, marker or URL), or the guard returned its refusal for a refusable step",
|
|
10103
|
+
"control": {
|
|
10104
|
+
"origin": "ordinary",
|
|
10105
|
+
"via": "guard",
|
|
10106
|
+
"expected": {
|
|
10107
|
+
"seen": true,
|
|
10108
|
+
"headers": true,
|
|
10109
|
+
"fulfilled": true,
|
|
10110
|
+
"answered": false
|
|
10111
|
+
}
|
|
10112
|
+
},
|
|
10113
|
+
"expected": {
|
|
10114
|
+
"seen": false,
|
|
10115
|
+
"headers": false,
|
|
10116
|
+
"fulfilled": false,
|
|
10117
|
+
"answered": true
|
|
10118
|
+
},
|
|
10119
|
+
"liveCheck": {
|
|
10120
|
+
"any": [
|
|
10121
|
+
{
|
|
10122
|
+
"loaded": "{target}/script?n={tag}",
|
|
10123
|
+
"observer": "fixture"
|
|
10124
|
+
},
|
|
10125
|
+
{
|
|
10126
|
+
"all": [
|
|
10127
|
+
{
|
|
10128
|
+
"pre": "{target}/script?n={tag}",
|
|
10129
|
+
"observer": "fixture"
|
|
10130
|
+
},
|
|
10131
|
+
{
|
|
10132
|
+
"any": [
|
|
10133
|
+
{
|
|
10134
|
+
"blocked": {
|
|
10135
|
+
"url": "{target}/script?n={tag}",
|
|
10136
|
+
"session": "page",
|
|
10137
|
+
"frame": false
|
|
10138
|
+
},
|
|
10139
|
+
"observer": "client"
|
|
10140
|
+
},
|
|
10141
|
+
{
|
|
10142
|
+
"blocked": {
|
|
10143
|
+
"url": "{target}/script?n={tag}",
|
|
10144
|
+
"session": "page",
|
|
10145
|
+
"frame": false
|
|
10146
|
+
},
|
|
10147
|
+
"observer": "unguarded"
|
|
10148
|
+
}
|
|
10149
|
+
],
|
|
10150
|
+
"observer": "client"
|
|
10151
|
+
}
|
|
10152
|
+
],
|
|
10153
|
+
"observer": "client"
|
|
10154
|
+
}
|
|
10155
|
+
],
|
|
10156
|
+
"observer": "client"
|
|
10157
|
+
}
|
|
10158
|
+
},
|
|
9987
10159
|
{
|
|
9988
10160
|
"id": "C5.auth",
|
|
9989
10161
|
"family": "C",
|
|
@@ -17809,6 +17981,209 @@
|
|
|
17809
17981
|
],
|
|
17810
17982
|
"observer": "client"
|
|
17811
17983
|
}
|
|
17984
|
+
},
|
|
17985
|
+
{
|
|
17986
|
+
"id": "X1.after-exit",
|
|
17987
|
+
"family": "X",
|
|
17988
|
+
"kind": "cdp",
|
|
17989
|
+
"scope": "any",
|
|
17990
|
+
"requires": ["clientExit"],
|
|
17991
|
+
"setup": [
|
|
17992
|
+
{
|
|
17993
|
+
"text": "a plain page whose client registers an init script, an x-probe header and Fetch.enable '*' that fulfils the destination with a forged body, then loads a trigger page whose navigation into {target} is pending when the client exits"
|
|
17994
|
+
},
|
|
17995
|
+
{
|
|
17996
|
+
"page": "tab",
|
|
17997
|
+
"url": "{ordinary}/doc?n=exit-{tag}"
|
|
17998
|
+
},
|
|
17999
|
+
{
|
|
18000
|
+
"cdp": "Page.addScriptToEvaluateOnNewDocument",
|
|
18001
|
+
"params": {
|
|
18002
|
+
"source": "{beacon}"
|
|
18003
|
+
}
|
|
18004
|
+
},
|
|
18005
|
+
{
|
|
18006
|
+
"cdp": "Network.setExtraHTTPHeaders",
|
|
18007
|
+
"params": {
|
|
18008
|
+
"headers": {
|
|
18009
|
+
"x-probe": "{tag}"
|
|
18010
|
+
}
|
|
18011
|
+
}
|
|
18012
|
+
},
|
|
18013
|
+
{
|
|
18014
|
+
"cdp": "Fetch.enable",
|
|
18015
|
+
"params": {
|
|
18016
|
+
"patterns": [
|
|
18017
|
+
{
|
|
18018
|
+
"urlPattern": "*"
|
|
18019
|
+
}
|
|
18020
|
+
]
|
|
18021
|
+
}
|
|
18022
|
+
},
|
|
18023
|
+
{
|
|
18024
|
+
"answer": {
|
|
18025
|
+
"url": "{target}/canary/{tag}",
|
|
18026
|
+
"stage": "Request",
|
|
18027
|
+
"cdp": "Fetch.fulfillRequest",
|
|
18028
|
+
"params": {
|
|
18029
|
+
"responseCode": 200,
|
|
18030
|
+
"responseHeaders": [
|
|
18031
|
+
{
|
|
18032
|
+
"name": "content-type",
|
|
18033
|
+
"value": "text/html"
|
|
18034
|
+
}
|
|
18035
|
+
],
|
|
18036
|
+
"body": "PCFkb2N0eXBlIGh0bWw+PHRpdGxlPmZvcmdlZDwvdGl0bGU+PHA+RlVMRklMTEVEPC9wPg=="
|
|
18037
|
+
}
|
|
18038
|
+
}
|
|
18039
|
+
}
|
|
18040
|
+
],
|
|
18041
|
+
"trigger": [
|
|
18042
|
+
{
|
|
18043
|
+
"page": "navigate",
|
|
18044
|
+
"url": "{ordinary}/page?k=href&to={target}/canary/{tag}&tag={tag}"
|
|
18045
|
+
},
|
|
18046
|
+
{
|
|
18047
|
+
"settle": {
|
|
18048
|
+
"beacon": "{ordinary}",
|
|
18049
|
+
"required": true
|
|
18050
|
+
}
|
|
18051
|
+
},
|
|
18052
|
+
{
|
|
18053
|
+
"client": "exit"
|
|
18054
|
+
},
|
|
18055
|
+
{
|
|
18056
|
+
"page": "eval",
|
|
18057
|
+
"expression": "go()"
|
|
18058
|
+
},
|
|
18059
|
+
{
|
|
18060
|
+
"settle": {
|
|
18061
|
+
"title": "canary",
|
|
18062
|
+
"ms": 5000
|
|
18063
|
+
}
|
|
18064
|
+
}
|
|
18065
|
+
],
|
|
18066
|
+
"targets": ["seven", "vault"],
|
|
18067
|
+
"observe": {
|
|
18068
|
+
"instrumented": {
|
|
18069
|
+
"means": "the client's init script sent its beacon from {ordinary} before the exit",
|
|
18070
|
+
"check": {
|
|
18071
|
+
"beacon": "{ordinary}",
|
|
18072
|
+
"exit": "before",
|
|
18073
|
+
"observer": "fixture"
|
|
18074
|
+
}
|
|
18075
|
+
},
|
|
18076
|
+
"headed": {
|
|
18077
|
+
"means": "the trigger page's request carried the client's x-probe header before the exit",
|
|
18078
|
+
"check": {
|
|
18079
|
+
"header": "x-probe",
|
|
18080
|
+
"on": "/page",
|
|
18081
|
+
"exit": "before",
|
|
18082
|
+
"observer": "fixture"
|
|
18083
|
+
}
|
|
18084
|
+
},
|
|
18085
|
+
"intercepted": {
|
|
18086
|
+
"means": "a Fetch.requestPaused for the trigger page reached the client before the exit",
|
|
18087
|
+
"check": {
|
|
18088
|
+
"event": "Fetch.requestPaused",
|
|
18089
|
+
"where": {
|
|
18090
|
+
"params": {
|
|
18091
|
+
"request": {
|
|
18092
|
+
"url": "{ordinary}/page?k=href&to={target}/canary/{tag}&tag={tag}"
|
|
18093
|
+
}
|
|
18094
|
+
}
|
|
18095
|
+
},
|
|
18096
|
+
"observer": "client"
|
|
18097
|
+
}
|
|
18098
|
+
},
|
|
18099
|
+
"served": {
|
|
18100
|
+
"means": "the stand-in served {target}/canary/{tag} after the exit",
|
|
18101
|
+
"check": {
|
|
18102
|
+
"served": "{target}/canary/{tag}",
|
|
18103
|
+
"exit": "after",
|
|
18104
|
+
"observer": "fixture"
|
|
18105
|
+
}
|
|
18106
|
+
},
|
|
18107
|
+
"document": {
|
|
18108
|
+
"means": "the unguarded observer reads {target}'s real document, title 'canary'",
|
|
18109
|
+
"check": {
|
|
18110
|
+
"title": "canary",
|
|
18111
|
+
"via": "unguarded",
|
|
18112
|
+
"observer": "unguarded"
|
|
18113
|
+
}
|
|
18114
|
+
},
|
|
18115
|
+
"forged": {
|
|
18116
|
+
"means": "the unguarded observer reads the client's forged FULFILLED body",
|
|
18117
|
+
"check": {
|
|
18118
|
+
"expression": "document.body ? document.body.innerText.includes('FULFILLED') : false",
|
|
18119
|
+
"equals": true,
|
|
18120
|
+
"via": "unguarded",
|
|
18121
|
+
"observer": "unguarded"
|
|
18122
|
+
}
|
|
18123
|
+
},
|
|
18124
|
+
"scripted": {
|
|
18125
|
+
"means": "the client's init script sent a beacon from {target} after the exit",
|
|
18126
|
+
"check": {
|
|
18127
|
+
"beacon": "{target}",
|
|
18128
|
+
"exit": "after",
|
|
18129
|
+
"observer": "fixture"
|
|
18130
|
+
}
|
|
18131
|
+
},
|
|
18132
|
+
"probed": {
|
|
18133
|
+
"means": "a request for {target}/canary/{tag} carried the client's x-probe header after the exit",
|
|
18134
|
+
"check": {
|
|
18135
|
+
"header": "x-probe",
|
|
18136
|
+
"on": "{target}/canary/{tag}",
|
|
18137
|
+
"exit": "after",
|
|
18138
|
+
"observer": "fixture"
|
|
18139
|
+
}
|
|
18140
|
+
},
|
|
18141
|
+
"paused": {
|
|
18142
|
+
"means": "a Fetch.requestPaused for {target}/canary/{tag} reached the client",
|
|
18143
|
+
"check": {
|
|
18144
|
+
"event": "Fetch.requestPaused",
|
|
18145
|
+
"where": {
|
|
18146
|
+
"params": {
|
|
18147
|
+
"request": {
|
|
18148
|
+
"url": "{target}/canary/{tag}"
|
|
18149
|
+
}
|
|
18150
|
+
}
|
|
18151
|
+
},
|
|
18152
|
+
"observer": "client"
|
|
18153
|
+
}
|
|
18154
|
+
}
|
|
18155
|
+
},
|
|
18156
|
+
"live": "the stand-in serves {target}'s document after the client exits",
|
|
18157
|
+
"control": {
|
|
18158
|
+
"origin": "ordinary",
|
|
18159
|
+
"via": "guard",
|
|
18160
|
+
"expected": {
|
|
18161
|
+
"instrumented": true,
|
|
18162
|
+
"headed": true,
|
|
18163
|
+
"intercepted": true,
|
|
18164
|
+
"served": true,
|
|
18165
|
+
"document": true,
|
|
18166
|
+
"forged": false,
|
|
18167
|
+
"scripted": false,
|
|
18168
|
+
"probed": false,
|
|
18169
|
+
"paused": false
|
|
18170
|
+
}
|
|
18171
|
+
},
|
|
18172
|
+
"expected": {
|
|
18173
|
+
"instrumented": true,
|
|
18174
|
+
"headed": true,
|
|
18175
|
+
"intercepted": true,
|
|
18176
|
+
"served": true,
|
|
18177
|
+
"document": true,
|
|
18178
|
+
"forged": false,
|
|
18179
|
+
"scripted": false,
|
|
18180
|
+
"probed": false,
|
|
18181
|
+
"paused": false
|
|
18182
|
+
},
|
|
18183
|
+
"liveCheck": {
|
|
18184
|
+
"loaded": "{target}/canary/{tag}",
|
|
18185
|
+
"observer": "fixture"
|
|
18186
|
+
}
|
|
17812
18187
|
}
|
|
17813
18188
|
]
|
|
17814
18189
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.1",
|
|
4
4
|
"description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -66,14 +66,14 @@
|
|
|
66
66
|
"validate": "node scripts/validate.mjs",
|
|
67
67
|
"lint": "slicc-lint && test -z \"$(gofmt -l .)\" && go mod tidy -diff && go vet ./... && golangci-lint run && cargo fmt --check && cargo clippy --all-features --all-targets -- -D warnings",
|
|
68
68
|
"format": "biome check --write . && gofmt -w . && cargo fmt",
|
|
69
|
-
"test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/
|
|
69
|
+
"test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
70
70
|
"test:unit": "go test -race -cover ./...",
|
|
71
71
|
"build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
|
|
72
72
|
"trust": "npx fledgling sync -y --skip-publish",
|
|
73
73
|
"trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
|
|
74
74
|
"release": "semantic-release",
|
|
75
75
|
"test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
|
|
76
|
-
"test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-redirect-secret.test.mjs test/integration/guard-error-page.test.mjs test/integration/guard-discovery.test.mjs test/integration/guard-browser-page.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/guard-pre-beacon.test.mjs test/integration/guard-world-pause.test.mjs test/integration/guard-parallel.test.mjs test/integration/self-contained.test.mjs",
|
|
76
|
+
"test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-redirect-secret.test.mjs test/integration/guard-error-page.test.mjs test/integration/guard-discovery.test.mjs test/integration/guard-browser-page.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/guard-pre-beacon.test.mjs test/integration/guard-world-pause.test.mjs test/integration/guard-parallel.test.mjs test/integration/guard-client-exit.test.mjs test/integration/self-contained.test.mjs",
|
|
77
77
|
"test:guard-sweep": "node scripts/test-guards.mjs",
|
|
78
78
|
"test:gates": "node --test test/integration/gates.test.mjs",
|
|
79
79
|
"test:guard-target": "node --test test/integration/guard-runner.test.mjs",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"test:unit:guard": "mkdir -p coverage/unit && node --experimental-vm-modules --no-warnings --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-include=fixture-client.mjs --test-coverage-include=observer-target.mjs --test-coverage-include=protected-error-page.mjs --test-coverage-include=unparseable-url.mjs --test-coverage-include=scripts/browser-runner.mjs --test-coverage-include=scripts/validate.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/guard.lcov test/unit/guard-*.test.mjs && slicc-diff-cover coverage/unit/guard.lcov"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@ai-ecoverse/slicc-shared-web": "2.4.
|
|
84
|
+
"@ai-ecoverse/slicc-shared-web": "2.4.5",
|
|
85
85
|
"@biomejs/biome": "2.5.15",
|
|
86
86
|
"ajv": "8.20.0",
|
|
87
87
|
"ajv-formats": "3.0.1",
|
package/redirect-secret.mjs
CHANGED
|
@@ -92,6 +92,7 @@ class RedirectProbe {
|
|
|
92
92
|
this.learned = { network: new Set(), fetch: new Set(), tap: new Set() };
|
|
93
93
|
this.registrations = [];
|
|
94
94
|
this.seen = new Map();
|
|
95
|
+
this.scale = this.settleScale ?? 1;
|
|
95
96
|
this.protectedURLs = [
|
|
96
97
|
`https://seven.sliccy.ai/auth/callback?code=${encodeURIComponent(this.marker)}&state=${encodeURIComponent(this.marker)}`,
|
|
97
98
|
`https://vault.sliccy.ai/oauth/mcp-callback?code=${encodeURIComponent(this.marker)}`,
|
|
@@ -215,31 +216,69 @@ class RedirectProbe {
|
|
|
215
216
|
};
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
+
requests(events, match = (url) => url?.startsWith(this.prefix)) {
|
|
219
220
|
const own = events.filter((event) => event.sessionId === this.session);
|
|
220
221
|
const seen = new Set(
|
|
221
222
|
own
|
|
222
223
|
.filter(
|
|
223
224
|
(event) =>
|
|
224
225
|
event.method === 'Network.requestWillBeSent' &&
|
|
225
|
-
(
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
match(
|
|
227
|
+
event.params.redirectResponse
|
|
228
|
+
? event.params.redirectResponse.url
|
|
229
|
+
: event.params.request?.url
|
|
230
|
+
)
|
|
228
231
|
)
|
|
229
232
|
.map((event) => event.params.requestId)
|
|
230
233
|
);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
return [...seen].map((requestId) => {
|
|
235
|
+
const mine = own.filter((event) => event.params?.requestId === requestId);
|
|
236
|
+
return {
|
|
237
|
+
requestId,
|
|
238
|
+
hop: mine.find(
|
|
236
239
|
(event) =>
|
|
237
240
|
event.method === 'Network.requestWillBeSent' &&
|
|
238
|
-
event.params.redirectResponse?.url
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
+
match(event.params.redirectResponse?.url)
|
|
242
|
+
),
|
|
243
|
+
end: mine.find((event) =>
|
|
241
244
|
['Network.loadingFinished', 'Network.loadingFailed'].includes(event.method)
|
|
242
|
-
)
|
|
245
|
+
),
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
open(events, match) {
|
|
251
|
+
return this.requests(events, match).filter(({ hop, end }) => !hop && !end);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async land(url, start) {
|
|
255
|
+
const path = new URL(url).pathname;
|
|
256
|
+
await pause(250);
|
|
257
|
+
if (this.stalled) return;
|
|
258
|
+
const landed = await waitFor(
|
|
259
|
+
async () =>
|
|
260
|
+
(await this.fixture.log()).some((entry) => entry.url.includes(path)) &&
|
|
261
|
+
!this.open(this.client.events.slice(start), (seen) => seen === url).length,
|
|
262
|
+
Math.round(2000 * this.scale),
|
|
263
|
+
50
|
|
264
|
+
);
|
|
265
|
+
this.stalled = !landed && this.side === 'guarded';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async terminated() {
|
|
269
|
+
await waitFor(
|
|
270
|
+
() => !this.open(this.client.events.slice(this.eventsStart)).length,
|
|
271
|
+
Math.round(5000 * this.scale),
|
|
272
|
+
50
|
|
273
|
+
);
|
|
274
|
+
await waitFor(() => this.cursor >= this.client.events.length, 1000, 5);
|
|
275
|
+
await this.settle();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
hops(events) {
|
|
279
|
+
const protectedOrigins = this.protectedURLs.map((url) => new URL(url).origin);
|
|
280
|
+
const hops = Object.fromEntries(
|
|
281
|
+
this.requests(events).map(({ requestId, hop, end }) => {
|
|
243
282
|
const url = hop?.params.request?.url ?? '';
|
|
244
283
|
const origin = protectedOrigins.find((value) => url.startsWith(value));
|
|
245
284
|
return [
|
|
@@ -260,13 +299,25 @@ class RedirectProbe {
|
|
|
260
299
|
})
|
|
261
300
|
);
|
|
262
301
|
const deliveries = Object.values(hops).map((entry) => entry.delivery);
|
|
302
|
+
const open = Object.keys(hops).filter((id) => hops[id].delivery === 'unterminated');
|
|
303
|
+
const missing = open.length > 0 && this.side === 'control';
|
|
263
304
|
return {
|
|
264
305
|
hops,
|
|
265
|
-
requestsTerminated: !
|
|
306
|
+
requestsTerminated: missing ? 'incomplete' : !open.length,
|
|
307
|
+
...(missing
|
|
308
|
+
? { incomplete: `redirect-secret: control requests ${open.join(', ')} unterminated` }
|
|
309
|
+
: {}),
|
|
266
310
|
hopsRedacted: !deliveries.some((delivery) => ['raw', 'ordinary'].includes(delivery)),
|
|
267
311
|
};
|
|
268
312
|
}
|
|
269
313
|
|
|
314
|
+
legs(events) {
|
|
315
|
+
const network = this.networkLeg();
|
|
316
|
+
const hops = this.hops(events);
|
|
317
|
+
const incomplete = [network.incomplete, hops.incomplete].filter(Boolean).join('; ');
|
|
318
|
+
return { ...network, ...hops, ...(incomplete ? { incomplete } : {}) };
|
|
319
|
+
}
|
|
320
|
+
|
|
270
321
|
async register(key, to, code = 302) {
|
|
271
322
|
const url = `${this.ordinary}/redirect-secret/${this.tag}-${key}`;
|
|
272
323
|
const path = new URL(url).pathname;
|
|
@@ -339,6 +390,7 @@ class RedirectProbe {
|
|
|
339
390
|
for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
|
|
340
391
|
const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
|
|
341
392
|
const url = await this.register(`${index}-${kind}`, to, code);
|
|
393
|
+
const start = this.client.events.length;
|
|
342
394
|
if (kind === 'navigate') await this.send('Page.navigate', { url });
|
|
343
395
|
else {
|
|
344
396
|
await this.reset();
|
|
@@ -350,7 +402,7 @@ class RedirectProbe {
|
|
|
350
402
|
: this.xhrExpression(url);
|
|
351
403
|
await this.send('Runtime.evaluate', { expression });
|
|
352
404
|
}
|
|
353
|
-
await
|
|
405
|
+
await this.land(url, start);
|
|
354
406
|
await this.send('Page.getFrameTree');
|
|
355
407
|
await this.send('Page.getResourceTree');
|
|
356
408
|
await this.send('Page.getNavigationHistory');
|
|
@@ -487,6 +539,7 @@ class RedirectProbe {
|
|
|
487
539
|
returnByValue: true,
|
|
488
540
|
});
|
|
489
541
|
await this.settle();
|
|
542
|
+
await this.terminated();
|
|
490
543
|
const tapped = `"sessionId":"${this.observer}"`;
|
|
491
544
|
const events = this.client.events
|
|
492
545
|
.slice(this.eventsStart)
|
|
@@ -536,8 +589,7 @@ class RedirectProbe {
|
|
|
536
589
|
sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
|
|
537
590
|
fetchBodyAttempted: fetchAttempts.length > 0,
|
|
538
591
|
fetchBodyRefused: fetchAttempts.length > 0 && fetchAttempts.every(refused),
|
|
539
|
-
...this.
|
|
540
|
-
...this.hops(events),
|
|
592
|
+
...this.legs(events),
|
|
541
593
|
extraInPlace,
|
|
542
594
|
...redirectFragments({
|
|
543
595
|
messages,
|
|
@@ -87,7 +87,8 @@
|
|
|
87
87
|
"browserContexts",
|
|
88
88
|
"chromeUrlsAttach",
|
|
89
89
|
"extensionWorld",
|
|
90
|
-
"discovery"
|
|
90
|
+
"discovery",
|
|
91
|
+
"clientExit"
|
|
91
92
|
],
|
|
92
93
|
"properties": {
|
|
93
94
|
"autoAttach": {
|
|
@@ -107,7 +108,8 @@
|
|
|
107
108
|
"chromeUrlsAttach": {
|
|
108
109
|
"type": "string",
|
|
109
110
|
"minLength": 1
|
|
110
|
-
}
|
|
111
|
+
},
|
|
112
|
+
"clientExit": { "type": "string", "minLength": 1 }
|
|
111
113
|
}
|
|
112
114
|
},
|
|
113
115
|
"placeholders": {
|
|
@@ -136,7 +138,8 @@
|
|
|
136
138
|
"browserContexts",
|
|
137
139
|
"chromeUrlsAttach",
|
|
138
140
|
"extensionWorld",
|
|
139
|
-
"discovery"
|
|
141
|
+
"discovery",
|
|
142
|
+
"clientExit"
|
|
140
143
|
]
|
|
141
144
|
},
|
|
142
145
|
"requires": {
|
|
@@ -233,10 +236,10 @@
|
|
|
233
236
|
"properties": {
|
|
234
237
|
"id": {
|
|
235
238
|
"type": "string",
|
|
236
|
-
"pattern": "^[
|
|
239
|
+
"pattern": "^[CEBX][1-9](\\.[A-Za-z0-9-]+)*$"
|
|
237
240
|
},
|
|
238
241
|
"family": {
|
|
239
|
-
"enum": ["C", "E", "B"]
|
|
242
|
+
"enum": ["C", "E", "B", "X"]
|
|
240
243
|
},
|
|
241
244
|
"kind": {
|
|
242
245
|
"enum": ["cdp", "text"]
|
|
@@ -482,6 +485,14 @@
|
|
|
482
485
|
}
|
|
483
486
|
}
|
|
484
487
|
},
|
|
488
|
+
{
|
|
489
|
+
"type": "object",
|
|
490
|
+
"additionalProperties": false,
|
|
491
|
+
"required": ["client"],
|
|
492
|
+
"properties": {
|
|
493
|
+
"client": { "const": "exit" }
|
|
494
|
+
}
|
|
495
|
+
},
|
|
485
496
|
{
|
|
486
497
|
"type": "object",
|
|
487
498
|
"additionalProperties": false,
|
|
@@ -561,6 +572,7 @@
|
|
|
561
572
|
"fetch",
|
|
562
573
|
"xhr",
|
|
563
574
|
"image",
|
|
575
|
+
"script",
|
|
564
576
|
"eval"
|
|
565
577
|
]
|
|
566
578
|
},
|
|
@@ -805,6 +817,9 @@
|
|
|
805
817
|
},
|
|
806
818
|
"observer": {
|
|
807
819
|
"const": "fixture"
|
|
820
|
+
},
|
|
821
|
+
"exit": {
|
|
822
|
+
"enum": ["before", "after"]
|
|
808
823
|
}
|
|
809
824
|
}
|
|
810
825
|
},
|
|
@@ -824,6 +839,9 @@
|
|
|
824
839
|
},
|
|
825
840
|
"tagged": {
|
|
826
841
|
"type": "boolean"
|
|
842
|
+
},
|
|
843
|
+
"exit": {
|
|
844
|
+
"enum": ["before", "after"]
|
|
827
845
|
}
|
|
828
846
|
}
|
|
829
847
|
},
|
|
@@ -843,6 +861,9 @@
|
|
|
843
861
|
},
|
|
844
862
|
"observer": {
|
|
845
863
|
"const": "fixture"
|
|
864
|
+
},
|
|
865
|
+
"exit": {
|
|
866
|
+
"enum": ["before", "after"]
|
|
846
867
|
}
|
|
847
868
|
}
|
|
848
869
|
},
|