@ai-ecoverse/slicc-browsers 1.13.0 → 1.13.2
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 +15 -4
- package/fixture-client.mjs +51 -0
- package/fixture-server.mjs +38 -12
- package/guard-runner.mjs +33 -6
- package/guard-vectors.json +9 -1
- package/observer-target.mjs +23 -0
- package/package.json +10 -7
- package/protected-error-page.mjs +24 -6
- package/redirect-secret.mjs +19 -3
- package/schema/guard-vectors.schema.json +5 -2
- package/unparseable-url.mjs +10 -4
package/README.md
CHANGED
|
@@ -67,6 +67,8 @@ Every feature builds for `wasm32-wasip1`.
|
|
|
67
67
|
|
|
68
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, type, documentURL, unreachableURL, navigating }), cookieDomain(domain), storageKey(key), unparseable(text) }`, each a boolean. `url(text)` uses the load/loaded-URL fail-closed rule; `url(text, { readOnly: true })` preserves the old parse-failure result for read-only keys and value redaction, and `storageKey(key)` uses that mode internally. `unparseable(text)` identifies parse or unwrap failures without changing whether a parsed origin is protected. It needs only the WHATWG `URL`, so it runs in Node and in a browser. `protected.mjs` and `seven.mjs` import nothing and re-export nothing, so an extension or service worker can ship either file alone; `npm run validate` (through `scripts/self-contained.mjs`) evaluates each as a module in a fresh `node:vm` context that has only `URL`, with no linker resolution allowed.
|
|
69
69
|
|
|
70
|
+
The complete static import graph of `guard-runner.mjs` is browser-safe too: it uses ordinary browser globals (`URL`, timers, `fetch`, `atob`/`btoa`, `Uint8Array`, `TextEncoder` and `TextDecoder`), never `Buffer`, `process`, `require` or `node:` imports. Base64 bodies and postData are decoded with `atob` and UTF-8 bytes rather than a Node shim. `npm run validate` loads and executes that graph in a fresh `node:vm` browser-like context, including an encoded-body exercise and a drivable page row. The separate host-side `./fixture` server intentionally uses Node HTTP/TLS APIs and is not imported by the browser runner.
|
|
71
|
+
|
|
70
72
|
## `catalog.json`
|
|
71
73
|
|
|
72
74
|
`"schema": 1`. A minor release only adds fields; a field that changes meaning or goes away needs schema 2 and a major release. Relative paths are arrays of path segments, joined with the platform's separator. Top-level fields:
|
|
@@ -209,7 +211,7 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
|
|
|
209
211
|
- `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.
|
|
210
212
|
- `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.
|
|
211
213
|
- `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.
|
|
212
|
-
- `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; `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`.
|
|
214
|
+
- `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`.
|
|
213
215
|
- `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
|
|
214
216
|
|
|
215
217
|
**A vector** has:
|
|
@@ -259,9 +261,13 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
259
261
|
|
|
260
262
|
`{ "extensionWorld": "hidden" | "select" | "debugger" | "drivable", "as": "world", "requires": ["extensionWorld"] }` runs the C9 compound probe on the current ordinary page. The runner attaches an independent observer to discover actual context ids, unique ids and names, and sends probes through the client session. Discovery matches the expected fixture extension’s exact origin, never the first extension world: `run()`’s optional `extensionWorldId` defaults to shared-web’s harness id `ankmbodmgimjemnojmeddfcdlbocolob`. Multiple unrelated content-script worlds are ignored for fixture selection, while guards must still protect foreign worlds. A custom fixture must provide the same content-script marker contract. Object handles are minted and validated on the unguarded control session, then replayed as guesses on the guarded leg; Chrome object handles and DOM node ids are session-local, so observer handles must never be mistaken for client handles. Numeric ids from the control are also replayed alongside fresh observer-discovered selectors, but numeric ids are local to a tab: a control id may name the next tab’s exposed default context, which must work and return no extension marker. The same-tab hidden world’s actual numeric id and every extension unique-id guess still require a matched refusal. Unknown numeric ids and stale object handles (`not exposed`) need explicit refusals rather than Chrome’s invalid-id errors. `guessesRefused` aggregates only required refusals; `guessesReached` must be false on the guarded leg, and `exposedContextsWork` must remain true. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
|
|
261
263
|
|
|
262
|
-
`{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no capability. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
|
|
264
|
+
`{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
|
|
265
|
+
|
|
266
|
+
`{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream.
|
|
267
|
+
|
|
268
|
+
**Host-harness requirements for every guard.** Host Node processes may use `fixtureServer()` from `./fixture`; browser pages must import only the browser-safe runner graph, served with JavaScript MIME types, and need no Node-global shim. All guards supply a guarded client, an independent unguarded observer, mapped fixture origins and the relevant advertised capabilities. C2 clients additionally record every received raw CDP frame in `client.messages` before parsing. Route protected seven/vault hosts to the fixture with `--host-resolver-rules`, including default-port callback URLs; a live protected-body negative needs HTTPS fixture trust (the reference harness pins its SPKI).
|
|
263
269
|
|
|
264
|
-
`
|
|
270
|
+
For remote/page harnesses, `fixtureClient(base)` is exported by `./guard-runner` and `./fixture-client`, or `run(set, { fixtureBase: base, ... })` constructs it. The bundled server serves CORS-safe `GET /log?since=N` as `{ from: N, entries: [...] }`, with an append-only history unaffected by `fixture.reset()`, and `POST /redirect` with a JSON-encoded string body (a simple text/plain request, without a preflight). Control requests do not appear in the log. Registration descriptors are `{ path, host?, search?, to?, code?, body, remove? }`: `path` starts with `/`, `host` selects the callback hostname, `search` distinguishes query-bearing callbacks, `to` creates a redirect with the four Location/Content-Location/Refresh/Link headers, `code` defaults to 302, an omitted `to` serves the callback body, and `remove: true` unregisters it. The bundled endpoint returns 204; custom servers must acknowledge with a successful status and actually serve the registered routes. Custom fixtures may instead supply an async `fixture.redirectSecret` hook directly; a separate log/control server can implement the same `GET /log` and `POST /redirect` contract. A Node harness using browser-only DNS aliases should pass a reachable `fixtureBase` or its direct hook.
|
|
265
271
|
|
|
266
272
|
`{ "unparseable": true, "as": "unparseable" }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
|
|
267
273
|
|
|
@@ -374,6 +380,8 @@ every alternative of an `any`, or a required (`all`, `not` or plain) check, need
|
|
|
374
380
|
selects one variant or an array; omission selects all. `parallel` defaults to 1.
|
|
375
381
|
With the `browserContexts` capability each leg runs in its own browser context. A `Target.createBrowserContext` that fails with a timeout is retried once, and the row's `notes` records it; any other failure fails the row.
|
|
376
382
|
|
|
383
|
+
Every target created by the runner’s own unguarded observer uses `Target.createTarget` with `newWindow: true`, then verifies through `Target.getTargetInfo` that `openerId` is absent. A supplied opener or missing target evidence fails and cleans up that target, rather than inheriting the active client tab (which could be seven). This includes ordinary control setup, storage canaries, protected-error history seeds and the malformed-URL observer. The row notes record `newWindow=true, openerId absent`. Intentional guarded-client creation commands and popup/opener tests keep their original parameters.
|
|
384
|
+
|
|
377
385
|
`settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), the wait for the pre-action beacons, the C9 control's `Debugger.paused`, and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
|
|
378
386
|
|
|
379
387
|
`refusalScale` (default 1) multiplies the bounds of waits for what the guarded leg expects to be absent, the beacon, request, title or navigation that the guard should block: settles that are not `required`, and `paused`, on the guarded leg. Such a wait runs to its full bound whenever the guard works, so `settleScale` leaves it alone. The trade-off: a larger `refusalScale` gives more sensitivity to a leak that arrives late on a slow host, because the observations after the wait see it, at the cost of that much more time per guarded row. Adopters should keep `refusalScale` at 1 or higher, never below 1; the runner refuses a `refusalScale` below 1 with `refusalScale must be a number of at least 1`.
|
|
@@ -499,6 +507,7 @@ const capabilities = {
|
|
|
499
507
|
browserSession: true,
|
|
500
508
|
browserContexts: true,
|
|
501
509
|
chromeUrlsAttach: true,
|
|
510
|
+
discovery: true,
|
|
502
511
|
};
|
|
503
512
|
const execution = await run(set, {
|
|
504
513
|
clientFactory: (metadata) => connectGuardAndObserver(metadata),
|
|
@@ -624,7 +633,7 @@ Go 1.26, Rust stable with `wasm32-wasip1`, Node ≥ 24.
|
|
|
624
633
|
- `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`, loads `protected.mjs` and `seven.mjs` with only `URL`, 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`, `tabs` (which holds no entry of `sevenTabs`), `six` and `auto` in each `expected.json`, and no port from 9222 to 9229.
|
|
625
634
|
- Every gate prints one verdict line when it passes: `validate: PASS …`, `corpus: PASS <n> cases` (`node scripts/corpus.mjs --check`), `major-guard: PASS v<version> fits <module>`, and `guard-sweep: PASS 14 targets` (`npm run test:guard-sweep`). CI runs each through `scripts/gate.mjs --expect <regex> -- <command>`, which fails when the command exits non-zero or prints no matching line, so a silent or early exit never reads as a pass. `gate.mjs --expect <regex> --in <file>` checks a log instead; the release job uses it on semantic-release's log for the major-guard line. `npm run test:gates` tests the gates, including runs through a symlinked checkout and a symlinked script.
|
|
626
635
|
- `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.
|
|
627
|
-
- `npm test`: `validate`, `test/integration/self-contained.test.mjs`, the Go tests in `test/integration/`, and `cargo test --all-features`.
|
|
636
|
+
- `npm test`: `validate`, `test/integration/self-contained.test.mjs`, `test/integration/git-retry.test.mjs`, the Go tests in `test/integration/`, and `cargo test --all-features`.
|
|
628
637
|
- `npm run build`: the Go module, the crate (also for `wasm32-wasip1`), and `npm pack --dry-run`.
|
|
629
638
|
|
|
630
639
|
Changing a table in `slicc-cli/internal/browser` means changing `catalog.json` here, releasing, and bumping the dependency; slicc-cli has no tables of its own. A new behaviour gets a corpus case.
|
|
@@ -637,6 +646,8 @@ semantic-release cuts one version from `main` for all three packages (`.github/w
|
|
|
637
646
|
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.
|
|
638
647
|
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.
|
|
639
648
|
|
|
649
|
+
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.
|
|
650
|
+
|
|
640
651
|
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.
|
|
641
652
|
|
|
642
653
|
**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.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function fixtureClient(base, { fetch: request = globalThis.fetch } = {}) {
|
|
2
|
+
const entries = [];
|
|
3
|
+
let pending;
|
|
4
|
+
return {
|
|
5
|
+
log: async () => {
|
|
6
|
+
if (!pending)
|
|
7
|
+
pending = (async () => {
|
|
8
|
+
const response = await request(new URL(`/log?since=${entries.length}`, base));
|
|
9
|
+
if (!response.ok) throw new Error(`fixture.log: HTTP ${response.status}`);
|
|
10
|
+
const batch = await response.json();
|
|
11
|
+
if (batch.from !== entries.length || !Array.isArray(batch.entries))
|
|
12
|
+
throw new Error('fixture.log: invalid append-only log response');
|
|
13
|
+
entries.push(...batch.entries);
|
|
14
|
+
return [...entries];
|
|
15
|
+
})().finally(() => {
|
|
16
|
+
pending = undefined;
|
|
17
|
+
});
|
|
18
|
+
return pending;
|
|
19
|
+
},
|
|
20
|
+
redirectSecret: async (registration) => {
|
|
21
|
+
try {
|
|
22
|
+
const response = await request(new URL('/redirect', base), {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
body: JSON.stringify(registration),
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`redirectSecret fixture registration required: POST /redirect failed: ${error.message}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function prepareFixture(set, options) {
|
|
37
|
+
const fixture =
|
|
38
|
+
options.fixture ?? (options.fixtureBase ? fixtureClient(options.fixtureBase) : undefined);
|
|
39
|
+
const needsRedirect = set.vectors.some(
|
|
40
|
+
(vector) =>
|
|
41
|
+
vector.id.startsWith('C2.redirect') || vector.trigger?.some((step) => step.redirectSecret)
|
|
42
|
+
);
|
|
43
|
+
if (!needsRedirect || typeof fixture?.redirectSecret === 'function' || !fixture?.log)
|
|
44
|
+
return fixture;
|
|
45
|
+
const control = fixtureClient(options.fixtureBase ?? fixture.origin ?? options.targets?.ordinary);
|
|
46
|
+
return {
|
|
47
|
+
...fixture,
|
|
48
|
+
log: (...args) => fixture.log(...args),
|
|
49
|
+
redirectSecret: control.redirectSecret,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/fixture-server.mjs
CHANGED
|
@@ -108,16 +108,22 @@ function requestURL(req, scheme) {
|
|
|
108
108
|
|
|
109
109
|
export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
|
|
110
110
|
let entries = [];
|
|
111
|
+
const history = [];
|
|
111
112
|
const redirects = new Map();
|
|
113
|
+
const redirectSecret = async ({ path, host = '', search = '', remove, ...value }) => {
|
|
114
|
+
if (
|
|
115
|
+
typeof path !== 'string' ||
|
|
116
|
+
!path.startsWith('/') ||
|
|
117
|
+
(!remove && typeof value.body !== 'string')
|
|
118
|
+
)
|
|
119
|
+
throw new Error('invalid redirect registration');
|
|
120
|
+
const key = `${host}${path}${search}`;
|
|
121
|
+
if (remove) redirects.delete(key);
|
|
122
|
+
else redirects.set(key, value);
|
|
123
|
+
};
|
|
112
124
|
const scheme = tls ? 'https' : 'http';
|
|
113
|
-
const handler = (req, res) => {
|
|
125
|
+
const handler = async (req, res) => {
|
|
114
126
|
const url = requestURL(req, scheme);
|
|
115
|
-
entries.push({
|
|
116
|
-
url: url.href,
|
|
117
|
-
method: req.method,
|
|
118
|
-
headers: { ...req.headers },
|
|
119
|
-
time: Date.now(),
|
|
120
|
-
});
|
|
121
127
|
const p = url.searchParams;
|
|
122
128
|
const cors = {
|
|
123
129
|
'access-control-allow-origin': req.headers.origin ?? '*',
|
|
@@ -136,6 +142,30 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
|
|
|
136
142
|
'text/html',
|
|
137
143
|
headers
|
|
138
144
|
);
|
|
145
|
+
if (req.method === 'GET' && url.pathname === '/log') {
|
|
146
|
+
const from = Number(p.get('since') ?? 0);
|
|
147
|
+
if (!Number.isInteger(from) || from < 0 || from > history.length)
|
|
148
|
+
return send(400, 'invalid log cursor', 'text/plain');
|
|
149
|
+
return send(200, JSON.stringify({ from, entries: history.slice(from) }), 'application/json');
|
|
150
|
+
}
|
|
151
|
+
if (req.method === 'POST' && url.pathname === '/redirect') {
|
|
152
|
+
try {
|
|
153
|
+
const chunks = [];
|
|
154
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
155
|
+
await redirectSecret(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
156
|
+
return send(204, '', 'text/plain');
|
|
157
|
+
} catch {
|
|
158
|
+
return send(400, 'invalid redirect registration', 'text/plain');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const entry = {
|
|
162
|
+
url: url.href,
|
|
163
|
+
method: req.method,
|
|
164
|
+
headers: { ...req.headers },
|
|
165
|
+
time: Date.now(),
|
|
166
|
+
};
|
|
167
|
+
entries.push(entry);
|
|
168
|
+
history.push(entry);
|
|
139
169
|
const wait = latency?.(url) ?? 0;
|
|
140
170
|
if (wait > 0) setTimeout(() => route(req, url, send, html, redirects), wait);
|
|
141
171
|
else route(req, url, send, html, redirects);
|
|
@@ -148,11 +178,7 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
|
|
|
148
178
|
const address = server.address();
|
|
149
179
|
return {
|
|
150
180
|
redirects,
|
|
151
|
-
redirectSecret
|
|
152
|
-
const key = `${host}${path}${search}`;
|
|
153
|
-
if (remove) redirects.delete(key);
|
|
154
|
-
else redirects.set(key, value);
|
|
155
|
-
},
|
|
181
|
+
redirectSecret,
|
|
156
182
|
port: address.port,
|
|
157
183
|
origin: `${scheme}://${host}:${address.port}`,
|
|
158
184
|
log: () => structuredClone(entries),
|
package/guard-runner.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { fixtureClient, prepareFixture } from './fixture-client.mjs';
|
|
2
|
+
|
|
3
|
+
export { fixtureClient };
|
|
4
|
+
|
|
1
5
|
import { extensionWorld } from './extension-world.mjs';
|
|
2
6
|
import {
|
|
3
7
|
beacon,
|
|
@@ -13,6 +17,7 @@ import {
|
|
|
13
17
|
result,
|
|
14
18
|
steps,
|
|
15
19
|
} from './guard-driver.mjs';
|
|
20
|
+
import { createObserverTarget, discoveryViolation } from './observer-target.mjs';
|
|
16
21
|
import { protectedErrorPage } from './protected-error-page.mjs';
|
|
17
22
|
import { redirectSecret } from './redirect-secret.mjs';
|
|
18
23
|
import { canonicalURL, unparseableURLs } from './unparseable-url.mjs';
|
|
@@ -153,11 +158,15 @@ class Execution {
|
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
async create(url, context = this.vars.context, client = this.client, background = true) {
|
|
156
|
-
const
|
|
161
|
+
const params = {
|
|
157
162
|
url: 'about:blank',
|
|
158
163
|
background,
|
|
159
164
|
...(context ? { browserContextId: context } : {}),
|
|
160
|
-
}
|
|
165
|
+
};
|
|
166
|
+
const { targetId } =
|
|
167
|
+
client === this.reader
|
|
168
|
+
? await createObserverTarget(this.reader, params, this.notes)
|
|
169
|
+
: await client.send('Target.createTarget', params);
|
|
161
170
|
this.targets.push(targetId);
|
|
162
171
|
this.pageTarget = targetId;
|
|
163
172
|
this.vars.pageTarget = targetId;
|
|
@@ -200,10 +209,14 @@ class Execution {
|
|
|
200
209
|
...(this.vars.context ? { browserContextId: this.vars.context } : {}),
|
|
201
210
|
});
|
|
202
211
|
if (!state.storage) return;
|
|
203
|
-
const { targetId } = await
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
212
|
+
const { targetId } = await createObserverTarget(
|
|
213
|
+
this.reader,
|
|
214
|
+
{
|
|
215
|
+
url: 'about:blank',
|
|
216
|
+
...(this.vars.context ? { browserContextId: this.vars.context } : {}),
|
|
217
|
+
},
|
|
218
|
+
this.notes
|
|
219
|
+
);
|
|
207
220
|
this.canaryTarget = targetId;
|
|
208
221
|
this.targets.push(targetId);
|
|
209
222
|
const { sessionId } = await this.reader.send('Target.attachToTarget', {
|
|
@@ -711,6 +724,8 @@ class Execution {
|
|
|
711
724
|
mode: step.errorPage,
|
|
712
725
|
side: this.side,
|
|
713
726
|
secret: step.secret,
|
|
727
|
+
discovery: supported(['discovery'], this.options.capabilities),
|
|
728
|
+
notes: this.notes,
|
|
714
729
|
})
|
|
715
730
|
);
|
|
716
731
|
if (step.unparseable)
|
|
@@ -724,6 +739,7 @@ class Execution {
|
|
|
724
739
|
tag: this.vars.tag,
|
|
725
740
|
fixture: this.options.fixture,
|
|
726
741
|
browserSession: supported(['browserSession'], this.options.capabilities),
|
|
742
|
+
notes: this.notes,
|
|
727
743
|
})
|
|
728
744
|
);
|
|
729
745
|
if (step.extensionWorld)
|
|
@@ -1219,6 +1235,16 @@ class Execution {
|
|
|
1219
1235
|
(this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
|
|
1220
1236
|
);
|
|
1221
1237
|
await Promise.allSettled(this.pending);
|
|
1238
|
+
if (
|
|
1239
|
+
this.side === 'guarded' &&
|
|
1240
|
+
this.vector.observe?.eventsRedacted?.requires?.includes('discovery')
|
|
1241
|
+
) {
|
|
1242
|
+
const violation = discoveryViolation(
|
|
1243
|
+
this.client.events.slice(this.eventStart),
|
|
1244
|
+
supported(['discovery'], this.options.capabilities)
|
|
1245
|
+
);
|
|
1246
|
+
if (violation) this.errors.push(violation);
|
|
1247
|
+
}
|
|
1222
1248
|
}
|
|
1223
1249
|
}
|
|
1224
1250
|
}
|
|
@@ -1613,6 +1639,7 @@ export function run(set, options) {
|
|
|
1613
1639
|
|
|
1614
1640
|
async function runAll(set, options, flights) {
|
|
1615
1641
|
const opts = { capabilities: {}, variant: Object.keys(set.variants), parallel: 1, ...options };
|
|
1642
|
+
opts.fixture = prepareFixture(set, opts);
|
|
1616
1643
|
opts.targets = {
|
|
1617
1644
|
...Object.fromEntries(
|
|
1618
1645
|
Object.entries(set.targets ?? {})
|
package/guard-vectors.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"setVersion": "1.13.
|
|
3
|
+
"setVersion": "1.13.2",
|
|
4
4
|
"sources": [
|
|
5
5
|
{
|
|
6
6
|
"repo": "ai-ecoverse/slicc-cli",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"browserSession": "CDP calls on the browser session",
|
|
92
92
|
"browserContexts": "Target.createBrowserContext and Target.disposeBrowserContext",
|
|
93
93
|
"extensionWorld": "a browser hosting a foreign content-script extension isolated world on the ordinary fixture page",
|
|
94
|
+
"discovery": "session-less Target.setDiscoverTargets and targetCreated/targetInfoChanged delivery; absent transports must emit neither event",
|
|
94
95
|
"chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't"
|
|
95
96
|
},
|
|
96
97
|
"placeholders": {
|
|
@@ -820,6 +821,7 @@
|
|
|
820
821
|
},
|
|
821
822
|
"eventsRedacted": {
|
|
822
823
|
"means": "protected error-page recover: eventsRedacted",
|
|
824
|
+
"requires": ["discovery"],
|
|
823
825
|
"check": {
|
|
824
826
|
"result": "errorPage",
|
|
825
827
|
"path": "result.eventsRedacted",
|
|
@@ -968,6 +970,7 @@
|
|
|
968
970
|
},
|
|
969
971
|
"eventsRedacted": {
|
|
970
972
|
"means": "protected error-page recover (query): eventsRedacted",
|
|
973
|
+
"requires": ["discovery"],
|
|
971
974
|
"check": {
|
|
972
975
|
"result": "errorPage",
|
|
973
976
|
"path": "result.eventsRedacted",
|
|
@@ -1116,6 +1119,7 @@
|
|
|
1116
1119
|
},
|
|
1117
1120
|
"eventsRedacted": {
|
|
1118
1121
|
"means": "protected error-page recover (fragment): eventsRedacted",
|
|
1122
|
+
"requires": ["discovery"],
|
|
1119
1123
|
"check": {
|
|
1120
1124
|
"result": "errorPage",
|
|
1121
1125
|
"path": "result.eventsRedacted",
|
|
@@ -1263,6 +1267,7 @@
|
|
|
1263
1267
|
},
|
|
1264
1268
|
"eventsRedacted": {
|
|
1265
1269
|
"means": "protected error-page restore: eventsRedacted",
|
|
1270
|
+
"requires": ["discovery"],
|
|
1266
1271
|
"check": {
|
|
1267
1272
|
"result": "errorPage",
|
|
1268
1273
|
"path": "result.eventsRedacted",
|
|
@@ -1443,6 +1448,7 @@
|
|
|
1443
1448
|
},
|
|
1444
1449
|
"eventsRedacted": {
|
|
1445
1450
|
"means": "protected error-page sticky: eventsRedacted",
|
|
1451
|
+
"requires": ["discovery"],
|
|
1446
1452
|
"check": {
|
|
1447
1453
|
"result": "errorPage",
|
|
1448
1454
|
"path": "result.eventsRedacted",
|
|
@@ -1635,6 +1641,7 @@
|
|
|
1635
1641
|
},
|
|
1636
1642
|
"eventsRedacted": {
|
|
1637
1643
|
"means": "protected error-page sticky (query): eventsRedacted",
|
|
1644
|
+
"requires": ["discovery"],
|
|
1638
1645
|
"check": {
|
|
1639
1646
|
"result": "errorPage",
|
|
1640
1647
|
"path": "result.eventsRedacted",
|
|
@@ -1827,6 +1834,7 @@
|
|
|
1827
1834
|
},
|
|
1828
1835
|
"eventsRedacted": {
|
|
1829
1836
|
"means": "protected error-page sticky (fragment): eventsRedacted",
|
|
1837
|
+
"requires": ["discovery"],
|
|
1830
1838
|
"check": {
|
|
1831
1839
|
"result": "errorPage",
|
|
1832
1840
|
"path": "result.eventsRedacted",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { outcome } from './guard-driver.mjs';
|
|
2
|
+
|
|
3
|
+
export async function createObserverTarget(reader, params, notes = []) {
|
|
4
|
+
const result = await reader.send('Target.createTarget', { ...params, newWindow: true });
|
|
5
|
+
try {
|
|
6
|
+
const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId: result.targetId });
|
|
7
|
+
if (!targetInfo || Object.hasOwn(targetInfo, 'openerId'))
|
|
8
|
+
throw new Error('observer target must have no openerId');
|
|
9
|
+
notes.push(`observer target ${result.targetId}: newWindow=true, openerId absent`);
|
|
10
|
+
return result;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
await outcome(reader.send('Target.closeTarget', { targetId: result.targetId }));
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function discoveryViolation(events, declared) {
|
|
18
|
+
if (declared) return null;
|
|
19
|
+
const event = events.find((entry) =>
|
|
20
|
+
['Target.targetCreated', 'Target.targetInfoChanged'].includes(entry.method)
|
|
21
|
+
);
|
|
22
|
+
return event ? `discovery not declared, but transport delivered ${event.method}` : null;
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"./package.json": "./package.json",
|
|
39
39
|
"./guard-runner.mjs": "./guard-runner.mjs",
|
|
40
40
|
"./guard-runner": "./guard-runner.mjs",
|
|
41
|
-
"./fixture": "./fixture-server.mjs"
|
|
41
|
+
"./fixture": "./fixture-server.mjs",
|
|
42
|
+
"./fixture-client": "./fixture-client.mjs"
|
|
42
43
|
},
|
|
43
44
|
"files": [
|
|
44
45
|
"catalog.json",
|
|
@@ -55,7 +56,9 @@
|
|
|
55
56
|
"unparseable-url.mjs",
|
|
56
57
|
"protected-error-page.mjs",
|
|
57
58
|
"redirect-secret.mjs",
|
|
58
|
-
"redirect-fragments.mjs"
|
|
59
|
+
"redirect-fragments.mjs",
|
|
60
|
+
"observer-target.mjs",
|
|
61
|
+
"fixture-client.mjs"
|
|
59
62
|
],
|
|
60
63
|
"scripts": {
|
|
61
64
|
"prepare": "lefthook install",
|
|
@@ -63,22 +66,22 @@
|
|
|
63
66
|
"validate": "node scripts/validate.mjs",
|
|
64
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",
|
|
65
68
|
"format": "biome check --write . && gofmt -w . && cargo fmt",
|
|
66
|
-
"test": "npm run validate && node --test test/integration/self-contained.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
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 && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
67
70
|
"test:unit": "go test -race -cover ./...",
|
|
68
71
|
"build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
|
|
69
72
|
"trust": "npx fledgling sync -y --skip-publish",
|
|
70
73
|
"trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
|
|
71
74
|
"release": "semantic-release",
|
|
72
75
|
"test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
|
|
73
|
-
"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-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/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/self-contained.test.mjs",
|
|
74
77
|
"test:guard-sweep": "node scripts/test-guards.mjs",
|
|
75
78
|
"test:gates": "node --test test/integration/gates.test.mjs",
|
|
76
79
|
"test:guard-target": "node --test test/integration/guard-runner.test.mjs",
|
|
77
80
|
"test:unit:redirect": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-coverage-include=redirect-secret.mjs --test-coverage-include=redirect-fragments.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/redirect.lcov test/unit/guard-redirect-*.test.mjs && slicc-diff-cover coverage/unit/redirect.lcov",
|
|
78
|
-
"test:unit:guard": "node --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-
|
|
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"
|
|
79
82
|
},
|
|
80
83
|
"devDependencies": {
|
|
81
|
-
"@ai-ecoverse/slicc-shared-web": "2.
|
|
84
|
+
"@ai-ecoverse/slicc-shared-web": "2.2.0",
|
|
82
85
|
"@biomejs/biome": "2.5.15",
|
|
83
86
|
"ajv": "8.20.0",
|
|
84
87
|
"ajv-formats": "3.0.1",
|
package/protected-error-page.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { outcome } from './guard-driver.mjs';
|
|
2
|
+
import { createObserverTarget } from './observer-target.mjs';
|
|
2
3
|
|
|
3
4
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
5
|
const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
|
|
@@ -43,7 +44,15 @@ async function lockChecks({ client, session, targetId, result }) {
|
|
|
43
44
|
result.closeRefused = refused(await outcome(client.send('Target.closeTarget', { targetId })));
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
async function redaction({
|
|
47
|
+
async function redaction({
|
|
48
|
+
client,
|
|
49
|
+
targetId,
|
|
50
|
+
failedURL,
|
|
51
|
+
protectedOrigin,
|
|
52
|
+
start,
|
|
53
|
+
result,
|
|
54
|
+
discovery,
|
|
55
|
+
}) {
|
|
47
56
|
const info = await outcome(client.send('Target.getTargetInfo', { targetId }));
|
|
48
57
|
const list = await outcome(client.send('Target.getTargets'));
|
|
49
58
|
const before = client.events.length;
|
|
@@ -71,11 +80,13 @@ async function redaction({ client, targetId, failedURL, protectedOrigin, start,
|
|
|
71
80
|
.slice(start)
|
|
72
81
|
.filter(
|
|
73
82
|
(e) =>
|
|
83
|
+
['Target.targetCreated', 'Target.targetInfoChanged'].includes(e.method) &&
|
|
74
84
|
e.params?.targetInfo?.targetId === targetId &&
|
|
75
85
|
e.params.targetInfo.url.includes(new URL(protectedOrigin).host)
|
|
76
86
|
);
|
|
77
|
-
result.eventsRedacted =
|
|
78
|
-
targetEvents.length > 0 && targetEvents.every((e) => only(e.params.targetInfo))
|
|
87
|
+
result.eventsRedacted = discovery
|
|
88
|
+
? targetEvents.length > 0 && targetEvents.every((e) => only(e.params.targetInfo))
|
|
89
|
+
: 'not-supported';
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
const secrets = {
|
|
@@ -94,12 +105,19 @@ export async function protectedErrorPage({
|
|
|
94
105
|
side,
|
|
95
106
|
internalSessions = new Set(),
|
|
96
107
|
secret = 'all',
|
|
108
|
+
discovery = true,
|
|
109
|
+
notes = [],
|
|
97
110
|
}) {
|
|
98
111
|
const initial = `${protectedOrigin}/doc?n=${tag}-history`;
|
|
99
112
|
const failedURL = `${protectedOrigin}${secrets[secret](tag)}`;
|
|
100
113
|
const result = {};
|
|
114
|
+
if (!discovery) notes.push('eventsRedacted: not-supported:discovery');
|
|
101
115
|
const start = client.events.length;
|
|
102
|
-
const { targetId } = await
|
|
116
|
+
const { targetId } = await createObserverTarget(
|
|
117
|
+
reader,
|
|
118
|
+
{ url: initial, background: true },
|
|
119
|
+
notes
|
|
120
|
+
);
|
|
103
121
|
let session;
|
|
104
122
|
let observer;
|
|
105
123
|
let failing = false;
|
|
@@ -142,7 +160,7 @@ export async function protectedErrorPage({
|
|
|
142
160
|
if (!session) return { live: false, attached: false };
|
|
143
161
|
await client.send('Page.enable', {}, session);
|
|
144
162
|
await client.send('Runtime.enable', {}, session);
|
|
145
|
-
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
163
|
+
if (discovery) await client.send('Target.setDiscoverTargets', { discover: true });
|
|
146
164
|
if (side === 'control') {
|
|
147
165
|
failing = true;
|
|
148
166
|
await reader.send(
|
|
@@ -172,7 +190,7 @@ export async function protectedErrorPage({
|
|
|
172
190
|
throw new Error(
|
|
173
191
|
`protected-error-page: missing error commit ${JSON.stringify(result.evidence)}`
|
|
174
192
|
);
|
|
175
|
-
await redaction({ client, targetId, failedURL, protectedOrigin, start, result });
|
|
193
|
+
await redaction({ client, targetId, failedURL, protectedOrigin, start, result, discovery });
|
|
176
194
|
const beforeReload = (await frame()).loaderId;
|
|
177
195
|
result.reload = (await outcome(client.send('Page.reload', {}, session))).ok;
|
|
178
196
|
if (result.reload) {
|
package/redirect-secret.mjs
CHANGED
|
@@ -2,6 +2,23 @@ import { load, outcome } from './guard-driver.mjs';
|
|
|
2
2
|
import { redirectFragments } from './redirect-fragments.mjs';
|
|
3
3
|
|
|
4
4
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
const decodeBase64 = (text) =>
|
|
6
|
+
new TextDecoder().decode(Uint8Array.from(atob(text), (char) => char.charCodeAt(0)));
|
|
7
|
+
const base64Slices = (value) => {
|
|
8
|
+
const normalized = value.replaceAll('-', '+').replaceAll('_', '/').replace(/=+$/, '');
|
|
9
|
+
const candidates = [];
|
|
10
|
+
for (let offset = 0; offset < 4; offset++) {
|
|
11
|
+
const part = normalized.slice(offset);
|
|
12
|
+
const usable = part.slice(0, part.length - (part.length % 4 === 1 ? 1 : 0));
|
|
13
|
+
candidates.push(usable.padEnd(Math.ceil(usable.length / 4) * 4, '='));
|
|
14
|
+
candidates.push(part.slice(0, Math.floor(part.length / 4) * 4));
|
|
15
|
+
}
|
|
16
|
+
return [...new Set(candidates)].filter(Boolean);
|
|
17
|
+
};
|
|
18
|
+
const decodedBase64 = (value) =>
|
|
19
|
+
[...value.matchAll(/[A-Za-z0-9+/_-]{12,}={0,2}/g)].flatMap(([text]) =>
|
|
20
|
+
base64Slices(text).map(decodeBase64)
|
|
21
|
+
);
|
|
5
22
|
const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
|
|
6
23
|
const redirects = [301, 302, 303, 307, 308];
|
|
7
24
|
const redirectHeaders = (to) => ({
|
|
@@ -45,8 +62,7 @@ export function containsSecret(value, marker) {
|
|
|
45
62
|
try {
|
|
46
63
|
decoded.push(JSON.parse(`"${value}"`));
|
|
47
64
|
} catch {}
|
|
48
|
-
|
|
49
|
-
decoded.push(Buffer.from(match[0], 'base64').toString('utf8'));
|
|
65
|
+
decoded.push(...decodedBase64(value));
|
|
50
66
|
return decoded.some((text) => text !== value && visit(text, depth + 1));
|
|
51
67
|
}
|
|
52
68
|
if (value && typeof value === 'object')
|
|
@@ -212,7 +228,7 @@ class RedirectProbe {
|
|
|
212
228
|
for (let i = 0; i < 32; i++) {
|
|
213
229
|
const read = await this.send('IO.read', { handle });
|
|
214
230
|
text += read.result?.base64Encoded
|
|
215
|
-
?
|
|
231
|
+
? decodeBase64(read.result.data)
|
|
216
232
|
: (read.result?.data ?? '');
|
|
217
233
|
if (!read.ok || read.result.eof) break;
|
|
218
234
|
}
|
|
@@ -86,7 +86,8 @@
|
|
|
86
86
|
"browserSession",
|
|
87
87
|
"browserContexts",
|
|
88
88
|
"chromeUrlsAttach",
|
|
89
|
-
"extensionWorld"
|
|
89
|
+
"extensionWorld",
|
|
90
|
+
"discovery"
|
|
90
91
|
],
|
|
91
92
|
"properties": {
|
|
92
93
|
"autoAttach": {
|
|
@@ -102,6 +103,7 @@
|
|
|
102
103
|
"minLength": 1
|
|
103
104
|
},
|
|
104
105
|
"extensionWorld": { "type": "string", "minLength": 1 },
|
|
106
|
+
"discovery": { "type": "string", "minLength": 1 },
|
|
105
107
|
"chromeUrlsAttach": {
|
|
106
108
|
"type": "string",
|
|
107
109
|
"minLength": 1
|
|
@@ -133,7 +135,8 @@
|
|
|
133
135
|
"browserSession",
|
|
134
136
|
"browserContexts",
|
|
135
137
|
"chromeUrlsAttach",
|
|
136
|
-
"extensionWorld"
|
|
138
|
+
"extensionWorld",
|
|
139
|
+
"discovery"
|
|
137
140
|
]
|
|
138
141
|
},
|
|
139
142
|
"requires": {
|
package/unparseable-url.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { outcome, owned, parseURL } from './guard-driver.mjs';
|
|
2
|
+
import { createObserverTarget } from './observer-target.mjs';
|
|
2
3
|
|
|
3
4
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
5
|
const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
|
|
@@ -151,6 +152,7 @@ export async function unparseableURLs({
|
|
|
151
152
|
tag,
|
|
152
153
|
fixture,
|
|
153
154
|
browserSession,
|
|
155
|
+
notes = [],
|
|
154
156
|
}) {
|
|
155
157
|
const root = new URL(origin);
|
|
156
158
|
const cookie = `seven-unparseable-${tag}`;
|
|
@@ -162,10 +164,14 @@ export async function unparseableURLs({
|
|
|
162
164
|
cookies: [{ name: cookie, value, domain: '.sliccy.ai', path: '/', secure: false }],
|
|
163
165
|
});
|
|
164
166
|
const url = rootURL(root, 'exa%20mple.sliccy.ai', `/doc?n=${tag}-observer`);
|
|
165
|
-
const { targetId: probeId } = await
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
const { targetId: probeId } = await createObserverTarget(
|
|
168
|
+
reader,
|
|
169
|
+
{
|
|
170
|
+
url,
|
|
171
|
+
background: true,
|
|
172
|
+
},
|
|
173
|
+
notes
|
|
174
|
+
);
|
|
169
175
|
targets.add(probeId);
|
|
170
176
|
const ground = await document(reader, probeId, url);
|
|
171
177
|
observer = (await reader.send('Target.attachToTarget', { targetId: probeId, flatten: true }))
|