@ai-ecoverse/slicc-browsers 1.14.0 → 1.14.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 +11 -8
- package/guard-driver.mjs +31 -4
- package/guard-runner.mjs +123 -34
- package/guard-vectors.json +1 -1
- package/package.json +4 -4
- package/protected-error-page.mjs +35 -34
- package/redirect-secret.mjs +63 -32
package/README.md
CHANGED
|
@@ -264,9 +264,9 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
264
264
|
|
|
265
265
|
`{ "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.
|
|
266
266
|
|
|
267
|
-
`{ "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.
|
|
267
|
+
`{ "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. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer’s history seed or allowed document did not commit (or its setup failed), the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
|
|
268
268
|
|
|
269
|
-
`{ "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.
|
|
269
|
+
`{ "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. The leak scan memoizes each decoded string and yields to the event loop between items, so concurrent rows in the same process are not starved. When the probe’s own unguarded observer setup (attach, `Page.enable` and the seven cookie) fails before the client is used, the probe returns `incomplete` with `redirect-secret: observer setup failed: <reason>` instead of throwing.
|
|
270
270
|
|
|
271
271
|
**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).
|
|
272
272
|
|
|
@@ -288,7 +288,7 @@ For remote/page harnesses, `fixtureClient(base)` is exported by `./guard-runner`
|
|
|
288
288
|
|
|
289
289
|
**Variants.** `variants` gives the delay in milliseconds before the client's `Target.detachFromTarget`: `prompt` 0, `lagging` 8000. A runner may add its own variant names.
|
|
290
290
|
|
|
291
|
-
**Reports.** There is one row per id, target, variant and transport. `control`, `guarded` and `live` are each `pass`, `fail`, `skip` (with the skip's `reason`) or `not-run` (for text rows):
|
|
291
|
+
**Reports.** There is one row per id, target, variant and transport. `control`, `guarded` and `live` are each `pass`, `fail`, `incomplete` (missing probe evidence, listed in the row's `incomplete`), `skip` (with the skip's `reason`) or `not-run` (for text rows):
|
|
292
292
|
|
|
293
293
|
```json
|
|
294
294
|
{ "id": "C1.script-nav", "target": "vault", "variant": "lagging", "transport": "cli", "via": "guard", "control": "pass", "guarded": "pass", "live": "pass" }
|
|
@@ -306,6 +306,7 @@ It exports:
|
|
|
306
306
|
- `refusals(vector)`: the vector's refusal names;
|
|
307
307
|
- `outcome`, `call(client, step, sessionId)` and `isRefusal(outcome, step)`;
|
|
308
308
|
- `instrument(client, sessionId, log, tag)`, `load(client, sessionId, url)` and `popup(client, openerId)`;
|
|
309
|
+
- `waitFor(check, ms, step?)`: polls `check` every `step` ms (default 25) until it holds, checking once more after the bound. A timer heartbeat every `step` runs for the whole wait, including while `check` is awaiting a reply, and any gap between beats longer than a second (or four steps, if longer) extends the bound by that gap, so a synchronous event-loop stall elsewhere in the process does not consume it. The extensions add up to at most `max(4 × ms, 30000)` ms, so a throttled page cannot stretch a wait without limit. `load`, the runner's waits and the error-page and redirect-body probes use it;
|
|
309
310
|
- `judge(vector, side, observed)`: `{ pass, refused, skipped, mismatches }`, where `refused` lists the leg's refusals and `skipped` the observations that depend on them or explicitly record unavailable observation capabilities;
|
|
310
311
|
- `result(vector, run)`: a report row from `{ target, variant, transport, via?, capabilities?, control, guarded, live }`. `capabilities` is the transport's; without it, the transport has them all;
|
|
311
312
|
- `parseURL(value, base?)`: a `URL`, or `null` for anything `new URL` rejects or a non-string; every parse of a fixture log entry or a Chrome-reported URL in the runner and the fixture goes through it (`protected.mjs` keeps its own copy, since it imports nothing), so an unparseable entry never throws;
|
|
@@ -330,7 +331,7 @@ It exports:
|
|
|
330
331
|
`guard-runner.mjs` is dependency-free browser ESM. `run(set, options)` returns
|
|
331
332
|
`{ results, summary(metadata) }`; the module also exports `summary`. Results have
|
|
332
333
|
`id`, `target`, `variant`, `control`, `guarded`, `live`, boolean observations and
|
|
333
|
-
errors. Statuses are `pass`, `fail`, `skip` and `not-run`. Text vectors are reported
|
|
334
|
+
errors. Statuses are `pass`, `fail`, `incomplete`, `skip` and `not-run`. Text vectors are reported
|
|
334
335
|
as `not-run`; they are not silently certified. A missing observation check is an
|
|
335
336
|
error before execution.
|
|
336
337
|
|
|
@@ -351,7 +352,7 @@ The factory can configure the guard's own transport delay from this metadata;
|
|
|
351
352
|
the runner never reaches into the guard. `transportVariant: true` acknowledges
|
|
352
353
|
that configuration, and every result records that boolean (false by default).
|
|
353
354
|
`slot` allows pooling browsers while keeping each row's clients independent.
|
|
354
|
-
The optional `close` releases the row's clients; release pooled browsers after
|
|
355
|
+
A factory that rejects makes only that row `incomplete` for control, guarded and live, with `clientFactory: <reason>` in its `incomplete`; the run goes on with the next row instead of rejecting. Such a row is labelled with the transport of the last client the factory made (`cdp` if none), and is passed to `log` once the run completes, so `summary()` never counts a placeholder transport. The optional `close` releases the row's clients; release pooled browsers after
|
|
355
356
|
`run` completes. Each parallel slot needs an independent browser for
|
|
356
357
|
browser-wide settings. Without a factory execution is serial. Cookie and storage
|
|
357
358
|
rows use fresh browser contexts when `capabilities.browserContexts` is true;
|
|
@@ -389,6 +390,8 @@ Every target created by the runner’s own unguarded observer uses `Target.creat
|
|
|
389
390
|
|
|
390
391
|
`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`.
|
|
391
392
|
|
|
393
|
+
**Incomplete.** A probe that returns `incomplete` (only `errorPage` and `redirectSecret` steps) records the reason in the row's `incomplete` as `<leg> <probe>: <reason>`. Its unmeasured observations read `"incomplete"`, and so does guarded liveness when the probe never measured it. A leg whose measured observations all match is then `incomplete`, never `pass`, and `live` is `incomplete` when only its evidence is missing; a measured mismatch still makes the leg `fail`, and any row error still fails both legs. `summary()` counts `incomplete` per leg, returns the number of rows with any `incomplete` status as `incomplete`, appends `incomplete <n>` to `line`, and never passes such a run. A leg that throws keeps the errors it recorded before the throw.
|
|
394
|
+
|
|
392
395
|
`onStart(row)` receives each row's `id`, `target`, `variant`, `transport` and `via` as the row starts. `run()` returns a promise that also has `inFlight()`, and its result has `inFlight` too. `inFlight()` lists the rows that are running now, each as `{ id, target, variant, leg, phase, step, clients }`, where `step` is the index of the current step in its `phase` (`setup`, `trigger` or `child`), and `clients` lists each client's pending CDP calls as `{ method, session, ageMs }` (`client`, plus `unguarded` when it is a separate client). With `rowTimeoutMs`, a row still running after that many milliseconds fails on its own. Its `errors` hold `row timed out after <ms>ms: <the in-flight view>`, `inFlight` holds the view, and the clients that `clientFactory` made for it are closed. The run then goes on to the next row. It is off by default, and a client passed as `client` is never closed.
|
|
393
396
|
|
|
394
397
|
`log(row)` receives completed rows. Besides `control`, `guarded`, `live`, `why` and `errors`, a row may carry `notSupported` (unavailable observation fields), `ran` (steps that ran on one leg only) and `notes` (settles skipped because their registration was refused). Both are informational: they explain a row's verdict, and adopters (bios, slicc-cli, slicc-cdp) must not count them as failures, or as passes. The verdict is `control`, `guarded` and `live`. A variant supplies the wait before an
|
|
@@ -643,15 +646,15 @@ Changing a table in `slicc-cli/internal/browser` means changing `catalog.json` h
|
|
|
643
646
|
|
|
644
647
|
## Releases
|
|
645
648
|
|
|
646
|
-
semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`)
|
|
649
|
+
semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`). The maintenance branch `1.13.x` (`.releaserc.json`: range `1.13.x`, channel `release-1.13.x`) cuts `1.13.N` patches the same way; it takes only `fix:` commits, cherry-picked from `main` after they merge there, through pull requests that merge on green CI.
|
|
647
650
|
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.
|
|
648
|
-
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.
|
|
651
|
+
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. `scripts/dist-tag.mjs <version> <ref> <latest>` picks the dist-tag: `latest` for a run from `main` whose version is not below the current `latest`, otherwise `release-X.Y.x`; a run from `main` fails when it cannot read `latest`. So a `1.13.x` release publishes to `release-1.13.x` and never moves `latest`. The GitHub release of a maintenance version is not marked latest, and the Go proxy and crates.io order by version.
|
|
649
652
|
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.
|
|
650
653
|
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.
|
|
651
654
|
|
|
652
655
|
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.
|
|
653
656
|
|
|
654
|
-
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.
|
|
657
|
+
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. On the maintenance line, use `--ref 1.13.x -f tag=v1.13.N`.
|
|
655
658
|
|
|
656
659
|
**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.
|
|
657
660
|
|
package/guard-driver.mjs
CHANGED
|
@@ -111,10 +111,37 @@ export async function load(client, sessionId, url, ms = 20000) {
|
|
|
111
111
|
const start = client.events.length;
|
|
112
112
|
const { loaderId } = await client.send('Page.navigate', { url }, sessionId);
|
|
113
113
|
if (!loaderId) return;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
if (!(await waitFor(() => loadedAfter(client.events.slice(start), sessionId, loaderId), ms)))
|
|
115
|
+
throw new Error(`load of ${url} timed out`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function waitFor(check, ms, step = 25) {
|
|
119
|
+
const start = Date.now();
|
|
120
|
+
const limit = start + ms + Math.max(ms * 4, 30000);
|
|
121
|
+
let deadline = start + ms;
|
|
122
|
+
let last = start;
|
|
123
|
+
const beat = () => {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const gap = now - last;
|
|
126
|
+
if (gap > Math.max(step * 4, 1000)) deadline = Math.min(limit, deadline + gap - step);
|
|
127
|
+
last = now;
|
|
128
|
+
};
|
|
129
|
+
let timer;
|
|
130
|
+
const tick = () => {
|
|
131
|
+
beat();
|
|
132
|
+
timer = setTimeout(tick, step);
|
|
133
|
+
};
|
|
134
|
+
timer = setTimeout(tick, step);
|
|
135
|
+
try {
|
|
136
|
+
for (;;) {
|
|
137
|
+
if (await check()) return true;
|
|
138
|
+
beat();
|
|
139
|
+
if (Date.now() > deadline) return false;
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, step));
|
|
141
|
+
beat();
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
118
145
|
}
|
|
119
146
|
}
|
|
120
147
|
|
package/guard-runner.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
summary as report,
|
|
17
17
|
result,
|
|
18
18
|
steps,
|
|
19
|
+
waitFor,
|
|
19
20
|
} from './guard-driver.mjs';
|
|
20
21
|
import { createObserverTarget, discoveryViolation } from './observer-target.mjs';
|
|
21
22
|
import { protectedErrorPage } from './protected-error-page.mjs';
|
|
@@ -120,6 +121,7 @@ class Execution {
|
|
|
120
121
|
this.contexts = [];
|
|
121
122
|
this.pending = [];
|
|
122
123
|
this.errors = [];
|
|
124
|
+
this.incomplete = [];
|
|
123
125
|
this.preSent = [];
|
|
124
126
|
this.notes = [];
|
|
125
127
|
this.answers = [];
|
|
@@ -193,12 +195,7 @@ class Execution {
|
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
async poll(predicate, ms = 1500, absent = false) {
|
|
196
|
-
|
|
197
|
-
do {
|
|
198
|
-
if (await predicate()) return true;
|
|
199
|
-
await sleep(25);
|
|
200
|
-
} while (Date.now() < deadline);
|
|
201
|
-
return false;
|
|
198
|
+
return waitFor(predicate, this.bound(ms, absent));
|
|
202
199
|
}
|
|
203
200
|
|
|
204
201
|
async prepareState(spec) {
|
|
@@ -641,6 +638,8 @@ class Execution {
|
|
|
641
638
|
(key) => step[key]
|
|
642
639
|
);
|
|
643
640
|
if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
|
|
641
|
+
if (['errorPage', 'redirectSecret'].includes(probe) && out.ok && out.result?.incomplete)
|
|
642
|
+
this.incomplete.push(`${probe}: ${out.result.incomplete}`);
|
|
644
643
|
if (step.as && !out.result?.pending) this.calls[step.as] = out;
|
|
645
644
|
if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
|
|
646
645
|
}
|
|
@@ -726,6 +725,7 @@ class Execution {
|
|
|
726
725
|
secret: step.secret,
|
|
727
726
|
discovery: supported(['discovery'], this.options.capabilities),
|
|
728
727
|
notes: this.notes,
|
|
728
|
+
budget: this.bound(5000),
|
|
729
729
|
})
|
|
730
730
|
);
|
|
731
731
|
if (step.unparseable)
|
|
@@ -956,7 +956,10 @@ class Execution {
|
|
|
956
956
|
const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
|
|
957
957
|
name
|
|
958
958
|
];
|
|
959
|
-
if (
|
|
959
|
+
if (
|
|
960
|
+
check?.result &&
|
|
961
|
+
(at(this.calls[check.result], check.path) === 'incomplete' || this.unmeasured(check))
|
|
962
|
+
)
|
|
960
963
|
return 'incomplete';
|
|
961
964
|
if (want !== true || check?.observer !== 'fixture') return this.check(check);
|
|
962
965
|
let seen = false;
|
|
@@ -967,6 +970,11 @@ class Execution {
|
|
|
967
970
|
return seen;
|
|
968
971
|
}
|
|
969
972
|
|
|
973
|
+
unmeasured(check) {
|
|
974
|
+
const out = check?.result ? this.calls[check.result] : undefined;
|
|
975
|
+
return Boolean(out?.result?.incomplete) && at(out, check.path) === undefined;
|
|
976
|
+
}
|
|
977
|
+
|
|
970
978
|
async check(check) {
|
|
971
979
|
const c = expand(check, this.vars);
|
|
972
980
|
if (!(await this.ready(c))) return false;
|
|
@@ -1186,6 +1194,8 @@ class Execution {
|
|
|
1186
1194
|
if (this.side === 'control') return { live: true };
|
|
1187
1195
|
const alive = () => this.check(this.vector.liveCheck);
|
|
1188
1196
|
let live = await alive();
|
|
1197
|
+
if (!live && this.unmeasured(this.vector.liveCheck))
|
|
1198
|
+
return { live: 'incomplete', liveWhy: 'incomplete' };
|
|
1189
1199
|
if (!live && !unseen.length) live = await this.poll(alive, 1500);
|
|
1190
1200
|
if (!unseen.length || (live && !incomplete.length)) return { live };
|
|
1191
1201
|
const detail = incomplete.length ? ` (${incomplete.join(', ')} incomplete)` : '';
|
|
@@ -1216,7 +1226,14 @@ class Execution {
|
|
|
1216
1226
|
side: this.side,
|
|
1217
1227
|
});
|
|
1218
1228
|
const { live, liveWhy } = await this.liveness(unseen, incomplete);
|
|
1219
|
-
return {
|
|
1229
|
+
return {
|
|
1230
|
+
observed,
|
|
1231
|
+
live,
|
|
1232
|
+
liveWhy,
|
|
1233
|
+
errors: this.errors,
|
|
1234
|
+
notes: this.notes,
|
|
1235
|
+
incomplete: this.incomplete,
|
|
1236
|
+
};
|
|
1220
1237
|
} finally {
|
|
1221
1238
|
this.stopped = true;
|
|
1222
1239
|
await pumping;
|
|
@@ -1517,18 +1534,17 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1517
1534
|
opts.onStart?.(base);
|
|
1518
1535
|
const sides = (async () => {
|
|
1519
1536
|
for (const side of ['control', 'guarded']) {
|
|
1537
|
+
const vars = variables(opts.targets, target, side, slot, index);
|
|
1538
|
+
const execution = new Execution(vector, side, opts, clients, vars, set.variants[variant]);
|
|
1520
1539
|
try {
|
|
1521
|
-
|
|
1522
|
-
legs[side] = await new Execution(
|
|
1523
|
-
vector,
|
|
1524
|
-
side,
|
|
1525
|
-
opts,
|
|
1526
|
-
clients,
|
|
1527
|
-
vars,
|
|
1528
|
-
set.variants[variant]
|
|
1529
|
-
).run();
|
|
1540
|
+
legs[side] = await execution.run();
|
|
1530
1541
|
} catch (error) {
|
|
1531
|
-
legs[side] = {
|
|
1542
|
+
legs[side] = {
|
|
1543
|
+
observed: {},
|
|
1544
|
+
live: false,
|
|
1545
|
+
errors: [...execution.errors, String(error.message ?? error)],
|
|
1546
|
+
notes: execution.notes,
|
|
1547
|
+
};
|
|
1532
1548
|
}
|
|
1533
1549
|
}
|
|
1534
1550
|
})();
|
|
@@ -1559,6 +1575,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1559
1575
|
...(legs.guarded.notes ?? []).map((note) => `guarded ${note}`),
|
|
1560
1576
|
];
|
|
1561
1577
|
if (notes.length) row.notes = notes;
|
|
1578
|
+
settleIncomplete(row, vector, base, legs);
|
|
1562
1579
|
if (row.errors.length) {
|
|
1563
1580
|
row.control = 'fail';
|
|
1564
1581
|
row.guarded = 'fail';
|
|
@@ -1567,6 +1584,37 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1567
1584
|
return row;
|
|
1568
1585
|
}
|
|
1569
1586
|
|
|
1587
|
+
function settleIncomplete(row, vector, base, legs) {
|
|
1588
|
+
const incomplete = ['control', 'guarded'].flatMap((side) =>
|
|
1589
|
+
(legs[side].incomplete ?? []).map((reason) => `${side} ${reason}`)
|
|
1590
|
+
);
|
|
1591
|
+
if (!incomplete.length) return;
|
|
1592
|
+
row.incomplete = incomplete;
|
|
1593
|
+
if (row.errors.length) return;
|
|
1594
|
+
const fill = (side) => {
|
|
1595
|
+
const want = side === 'control' ? vector.control.expected : vector.expected;
|
|
1596
|
+
return Object.fromEntries(
|
|
1597
|
+
Object.entries(legs[side].observed).map(([name, value]) => [
|
|
1598
|
+
name,
|
|
1599
|
+
value === 'incomplete' && Object.hasOwn(want, name)
|
|
1600
|
+
? want[name] === 'either' || want[name]
|
|
1601
|
+
: value,
|
|
1602
|
+
])
|
|
1603
|
+
);
|
|
1604
|
+
};
|
|
1605
|
+
const filled = result(vector, {
|
|
1606
|
+
...base,
|
|
1607
|
+
control: fill('control'),
|
|
1608
|
+
guarded: fill('guarded'),
|
|
1609
|
+
live: legs.control.live === true && [true, 'incomplete'].includes(legs.guarded.live),
|
|
1610
|
+
});
|
|
1611
|
+
for (const side of ['control', 'guarded'])
|
|
1612
|
+
if (legs[side].incomplete?.length && row[side] === 'fail' && filled[side] === 'pass')
|
|
1613
|
+
row[side] = 'incomplete';
|
|
1614
|
+
if (legs.guarded.live === 'incomplete' && filled.live === 'pass') row.live = 'incomplete';
|
|
1615
|
+
row.why = [filled.why, `incomplete: ${incomplete.join('; ')}`].filter(Boolean).join('; ');
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1570
1618
|
const invalidBlocked = (check) =>
|
|
1571
1619
|
Boolean(
|
|
1572
1620
|
check?.blocked &&
|
|
@@ -1628,6 +1676,37 @@ function validate(set, opts) {
|
|
|
1628
1676
|
return variants;
|
|
1629
1677
|
}
|
|
1630
1678
|
|
|
1679
|
+
function factoryClients(opts, set, job, slot) {
|
|
1680
|
+
return outcome(
|
|
1681
|
+
opts.clientFactory
|
|
1682
|
+
? opts.clientFactory({
|
|
1683
|
+
slot,
|
|
1684
|
+
variant: job.variant,
|
|
1685
|
+
detachDelay: set.variants[job.variant],
|
|
1686
|
+
vector: job.vector,
|
|
1687
|
+
target: job.target,
|
|
1688
|
+
})
|
|
1689
|
+
: Promise.resolve(opts.client)
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function unmade(job, message) {
|
|
1694
|
+
const reason = `clientFactory: ${message}`;
|
|
1695
|
+
return {
|
|
1696
|
+
id: job.vector.id,
|
|
1697
|
+
target: job.target,
|
|
1698
|
+
variant: job.variant,
|
|
1699
|
+
via: job.vector.control.via,
|
|
1700
|
+
transportVariant: false,
|
|
1701
|
+
control: 'incomplete',
|
|
1702
|
+
guarded: 'incomplete',
|
|
1703
|
+
live: 'incomplete',
|
|
1704
|
+
why: `incomplete: ${reason}`,
|
|
1705
|
+
errors: [],
|
|
1706
|
+
incomplete: [reason],
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1631
1710
|
export function run(set, options) {
|
|
1632
1711
|
const flights = new Set();
|
|
1633
1712
|
const inFlight = () =>
|
|
@@ -1655,20 +1734,21 @@ async function runAll(set, options, flights) {
|
|
|
1655
1734
|
const results = new Array(jobs.length);
|
|
1656
1735
|
let next = 0;
|
|
1657
1736
|
let serial = Promise.resolve();
|
|
1737
|
+
let transport = 'cdp';
|
|
1738
|
+
const unmadeRows = [];
|
|
1658
1739
|
const worker = async (slot) => {
|
|
1659
1740
|
for (;;) {
|
|
1660
1741
|
const index = next++;
|
|
1661
1742
|
if (index >= jobs.length) break;
|
|
1662
1743
|
const job = jobs[index];
|
|
1663
|
-
const
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
: opts.client;
|
|
1744
|
+
const factory = await factoryClients(opts, set, job, slot);
|
|
1745
|
+
if (!factory.ok) {
|
|
1746
|
+
results[index] = unmade(job, factory.error.message);
|
|
1747
|
+
unmadeRows.push(results[index]);
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
const made = factory.result;
|
|
1751
|
+
transport = (typeof made?.send === 'function' ? made : made?.client)?.transport ?? transport;
|
|
1672
1752
|
const clients =
|
|
1673
1753
|
typeof made?.send === 'function'
|
|
1674
1754
|
? {
|
|
@@ -1702,6 +1782,10 @@ async function runAll(set, options, flights) {
|
|
|
1702
1782
|
};
|
|
1703
1783
|
const width = opts.clientFactory ? opts.parallel : 1;
|
|
1704
1784
|
await Promise.all(Array.from({ length: width }, (_, slot) => worker(slot)));
|
|
1785
|
+
for (const row of unmadeRows) {
|
|
1786
|
+
row.transport = transport;
|
|
1787
|
+
opts.log?.(row);
|
|
1788
|
+
}
|
|
1705
1789
|
return { results, summary: (metadata = {}) => summary({ set, results, ...metadata }) };
|
|
1706
1790
|
}
|
|
1707
1791
|
|
|
@@ -1725,23 +1809,28 @@ export function summary({ set, results, guard = 'reference', sha = 'unknown' })
|
|
|
1725
1809
|
if (row.control === 'skip')
|
|
1726
1810
|
skips[row.reason ?? 'unknown'] = (skips[row.reason ?? 'unknown'] ?? 0) + 1;
|
|
1727
1811
|
const skipped = Object.values(skips).reduce((sum, count) => sum + count, 0);
|
|
1812
|
+
const incomplete = results.filter((row) =>
|
|
1813
|
+
[row.control, row.guarded, row.live].includes('incomplete')
|
|
1814
|
+
).length;
|
|
1728
1815
|
const line = observerSkipped ? value.line.replace('GUARD-PASS', 'GUARD-FAIL') : value.line;
|
|
1816
|
+
const skipLine = skipped
|
|
1817
|
+
? `${line} skips ${skipped} (${Object.entries(skips)
|
|
1818
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1819
|
+
.map(([reason, count]) => `${reason}=${count}`)
|
|
1820
|
+
.join(', ')})`
|
|
1821
|
+
: line;
|
|
1729
1822
|
return {
|
|
1730
1823
|
...value,
|
|
1731
1824
|
pass: value.pass && observerSkipped === 0,
|
|
1732
|
-
line:
|
|
1733
|
-
? `${line} skips ${skipped} (${Object.entries(skips)
|
|
1734
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
1735
|
-
.map(([reason, count]) => `${reason}=${count}`)
|
|
1736
|
-
.join(', ')})`
|
|
1737
|
-
: line,
|
|
1825
|
+
line: incomplete ? `${skipLine} incomplete ${incomplete}` : skipLine,
|
|
1738
1826
|
skips,
|
|
1739
1827
|
observerSkipped,
|
|
1828
|
+
incomplete,
|
|
1740
1829
|
counts: Object.fromEntries(
|
|
1741
1830
|
['control', 'guarded', 'live'].map((side) => [
|
|
1742
1831
|
side,
|
|
1743
1832
|
Object.fromEntries(
|
|
1744
|
-
['pass', 'fail', 'skip', 'not-run'].map((status) => [
|
|
1833
|
+
['pass', 'fail', 'incomplete', 'skip', 'not-run'].map((status) => [
|
|
1745
1834
|
status,
|
|
1746
1835
|
results.filter((r) => r[side] === status).length,
|
|
1747
1836
|
])
|
package/guard-vectors.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.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",
|
|
@@ -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 && 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 test/integration/dist-tag.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.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/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/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.3.
|
|
84
|
+
"@ai-ecoverse/slicc-shared-web": "2.3.5",
|
|
85
85
|
"@biomejs/biome": "2.5.15",
|
|
86
86
|
"ajv": "8.20.0",
|
|
87
87
|
"ajv-formats": "3.0.1",
|
package/protected-error-page.mjs
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
|
-
import { outcome } from './guard-driver.mjs';
|
|
1
|
+
import { outcome, waitFor } from './guard-driver.mjs';
|
|
2
2
|
import { createObserverTarget } from './observer-target.mjs';
|
|
3
3
|
|
|
4
4
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
5
|
const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
|
|
6
6
|
|
|
7
|
-
async function until
|
|
8
|
-
const deadline = Date.now() + 5000;
|
|
9
|
-
do {
|
|
10
|
-
if (await check()) return true;
|
|
11
|
-
await pause(25);
|
|
12
|
-
} while (Date.now() < deadline);
|
|
13
|
-
return false;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async function loadedDocument(reader, observer, url) {
|
|
7
|
+
async function loadedDocument(reader, observer, url, until) {
|
|
17
8
|
return until(async () => {
|
|
18
9
|
const { frameTree } = await reader.send('Page.getFrameTree', {}, observer);
|
|
19
10
|
if (frameTree.frame.url !== url || frameTree.frame.unreachableUrl) return false;
|
|
@@ -89,6 +80,18 @@ async function redaction({
|
|
|
89
80
|
: 'not-supported';
|
|
90
81
|
}
|
|
91
82
|
|
|
83
|
+
async function seed(reader, observer, loaded, initial, allowed) {
|
|
84
|
+
const out = await outcome(
|
|
85
|
+
(async () => {
|
|
86
|
+
await reader.send('Page.enable', {}, observer);
|
|
87
|
+
if (!(await loaded(initial))) return 'history seed did not commit';
|
|
88
|
+
await reader.send('Page.navigate', { url: allowed }, observer);
|
|
89
|
+
if (!(await loaded(allowed))) return 'allowed document did not commit';
|
|
90
|
+
})()
|
|
91
|
+
);
|
|
92
|
+
return out.ok ? out.result : `observer setup failed: ${out.error.message}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
92
95
|
const secrets = {
|
|
93
96
|
all: (tag) => `/doc/path-secret-${tag}?query-secret=${tag}#fragment-secret-${tag}`,
|
|
94
97
|
query: (tag) => `/doc?query-secret=${tag}`,
|
|
@@ -107,10 +110,13 @@ export async function protectedErrorPage({
|
|
|
107
110
|
secret = 'all',
|
|
108
111
|
discovery = true,
|
|
109
112
|
notes = [],
|
|
113
|
+
budget = 5000,
|
|
110
114
|
}) {
|
|
111
115
|
const initial = `${protectedOrigin}/doc?n=${tag}-history`;
|
|
112
116
|
const failedURL = `${protectedOrigin}${secrets[secret](tag)}`;
|
|
113
117
|
const result = {};
|
|
118
|
+
const until = (check) => waitFor(check, budget);
|
|
119
|
+
const missing = (reason) => ({ ...result, incomplete: `protected-error-page: ${reason}` });
|
|
114
120
|
if (!discovery) notes.push('eventsRedacted: not-supported:discovery');
|
|
115
121
|
const start = client.events.length;
|
|
116
122
|
const { targetId } = await createObserverTarget(
|
|
@@ -126,7 +132,7 @@ export async function protectedErrorPage({
|
|
|
126
132
|
observer = sessionId;
|
|
127
133
|
internalSessions.add(observer);
|
|
128
134
|
const frame = async () => (await reader.send('Page.getFrameTree', {}, observer)).frameTree.frame;
|
|
129
|
-
const loaded = (url) => loadedDocument(reader, observer, url);
|
|
135
|
+
const loaded = (url) => loadedDocument(reader, observer, url, until);
|
|
130
136
|
let stopped = false;
|
|
131
137
|
const fail = (async () => {
|
|
132
138
|
while (!stopped) {
|
|
@@ -147,12 +153,8 @@ export async function protectedErrorPage({
|
|
|
147
153
|
}
|
|
148
154
|
})();
|
|
149
155
|
try {
|
|
150
|
-
await reader
|
|
151
|
-
if (
|
|
152
|
-
throw new Error('protected-error-page: history seed did not commit');
|
|
153
|
-
await reader.send('Page.navigate', { url: `${ordinary}/blank` }, observer);
|
|
154
|
-
if (!(await loaded(`${ordinary}/blank`)))
|
|
155
|
-
throw new Error('protected-error-page: allowed document did not commit');
|
|
156
|
+
const unseeded = await seed(reader, observer, loaded, initial, `${ordinary}/blank`);
|
|
157
|
+
if (unseeded) return missing(unseeded);
|
|
156
158
|
const attached = await outcome(
|
|
157
159
|
client.send('Target.attachToTarget', { targetId, flatten: true })
|
|
158
160
|
);
|
|
@@ -174,10 +176,11 @@ export async function protectedErrorPage({
|
|
|
174
176
|
{ expression: `setTimeout(()=>location.href=${JSON.stringify(failedURL)},0)` },
|
|
175
177
|
session
|
|
176
178
|
);
|
|
177
|
-
|
|
179
|
+
let evidence;
|
|
180
|
+
const live = await until(async () => {
|
|
178
181
|
const f = await frame();
|
|
179
182
|
const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId });
|
|
180
|
-
|
|
183
|
+
evidence = { frame: f, targetInfo };
|
|
181
184
|
return (
|
|
182
185
|
f.url === 'chrome-error://chromewebdata/' &&
|
|
183
186
|
f.securityOrigin === '://' &&
|
|
@@ -185,11 +188,11 @@ export async function protectedErrorPage({
|
|
|
185
188
|
targetInfo.url === failedURL
|
|
186
189
|
);
|
|
187
190
|
});
|
|
188
|
-
|
|
189
|
-
if (!
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
result.evidence = evidence;
|
|
192
|
+
if (!live && side === 'control')
|
|
193
|
+
return missing(`missing error commit ${JSON.stringify(evidence)}`);
|
|
194
|
+
result.live = live;
|
|
195
|
+
if (!live) return result;
|
|
193
196
|
await redaction({ client, targetId, failedURL, protectedOrigin, start, result, discovery });
|
|
194
197
|
const beforeReload = (await frame()).loaderId;
|
|
195
198
|
result.reload = (await outcome(client.send('Page.reload', {}, session))).ok;
|
|
@@ -227,14 +230,14 @@ export async function protectedErrorPage({
|
|
|
227
230
|
failing = false;
|
|
228
231
|
const { entries, currentIndex } = await reader.send('Page.getNavigationHistory', {}, observer);
|
|
229
232
|
const index = entries.findIndex((e) => e.url === initial);
|
|
230
|
-
if (index < 0)
|
|
233
|
+
if (index < 0) return missing('missing protected history');
|
|
231
234
|
const beforeRestore = reader.events.length;
|
|
232
235
|
await reader.send(
|
|
233
236
|
'Runtime.evaluate',
|
|
234
237
|
{ expression: `setTimeout(() => history.go(${index - currentIndex}), 0)` },
|
|
235
238
|
observer
|
|
236
239
|
);
|
|
237
|
-
|
|
240
|
+
const restored = await until(() =>
|
|
238
241
|
reader.events
|
|
239
242
|
.slice(beforeRestore)
|
|
240
243
|
.some(
|
|
@@ -245,8 +248,8 @@ export async function protectedErrorPage({
|
|
|
245
248
|
e.params.frame.url === initial
|
|
246
249
|
)
|
|
247
250
|
);
|
|
248
|
-
if (!
|
|
249
|
-
|
|
251
|
+
if (!restored) return missing('real protected BFCache restore required');
|
|
252
|
+
result.restored = true;
|
|
250
253
|
await pause(100);
|
|
251
254
|
if (mode === 'sticky') {
|
|
252
255
|
await reader.send('Target.detachFromTarget', { sessionId: observer });
|
|
@@ -260,11 +263,9 @@ export async function protectedErrorPage({
|
|
|
260
263
|
observer
|
|
261
264
|
);
|
|
262
265
|
await reader.send('Page.navigate', { url: failedURL }, observer);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
`protected-error-page: missing sticky error ${JSON.stringify(await frame())}`
|
|
267
|
-
);
|
|
266
|
+
if (!(await until(async () => (await frame()).unreachableUrl === failedURL)))
|
|
267
|
+
return missing(`missing sticky error ${JSON.stringify(await frame())}`);
|
|
268
|
+
result.stickyError = true;
|
|
268
269
|
await pause(100);
|
|
269
270
|
}
|
|
270
271
|
await lockChecks({ client, session, targetId, result });
|
package/redirect-secret.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { load, outcome } from './guard-driver.mjs';
|
|
1
|
+
import { load, outcome, waitFor } 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));
|
|
@@ -47,28 +47,33 @@ const headersTextMatch = (text, to) =>
|
|
|
47
47
|
)
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
-
export function containsSecret(value, marker) {
|
|
50
|
+
export function containsSecret(value, marker, seen = new Map()) {
|
|
51
51
|
const visit = (value, depth = 0) => {
|
|
52
52
|
if (typeof value === 'string') {
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
decoded.push(decodeURIComponent(value));
|
|
58
|
-
} catch {}
|
|
59
|
-
try {
|
|
60
|
-
decoded.push(JSON.parse(value));
|
|
61
|
-
} catch {}
|
|
62
|
-
try {
|
|
63
|
-
decoded.push(JSON.parse(`"${value}"`));
|
|
64
|
-
} catch {}
|
|
65
|
-
decoded.push(...decodedBase64(value));
|
|
66
|
-
return decoded.some((text) => text !== value && visit(text, depth + 1));
|
|
53
|
+
const key = `${depth}:${value}`;
|
|
54
|
+
if (!seen.has(key)) seen.set(key, text(value, depth));
|
|
55
|
+
return seen.get(key);
|
|
67
56
|
}
|
|
68
57
|
if (value && typeof value === 'object')
|
|
69
58
|
return Object.values(value).some((v) => visit(v, depth));
|
|
70
59
|
return false;
|
|
71
60
|
};
|
|
61
|
+
const text = (value, depth) => {
|
|
62
|
+
if (value.includes(marker)) return true;
|
|
63
|
+
if (depth >= 8) return false;
|
|
64
|
+
const decoded = [];
|
|
65
|
+
try {
|
|
66
|
+
decoded.push(decodeURIComponent(value));
|
|
67
|
+
} catch {}
|
|
68
|
+
try {
|
|
69
|
+
decoded.push(JSON.parse(value));
|
|
70
|
+
} catch {}
|
|
71
|
+
try {
|
|
72
|
+
decoded.push(JSON.parse(`"${value}"`));
|
|
73
|
+
} catch {}
|
|
74
|
+
decoded.push(...decodedBase64(value));
|
|
75
|
+
return decoded.some((text) => text !== value && visit(text, depth + 1));
|
|
76
|
+
};
|
|
72
77
|
return visit(value);
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -85,6 +90,7 @@ class RedirectProbe {
|
|
|
85
90
|
this.jobs = new Set();
|
|
86
91
|
this.networkBodies = new Set();
|
|
87
92
|
this.registrations = [];
|
|
93
|
+
this.seen = new Map();
|
|
88
94
|
this.protectedURLs = [
|
|
89
95
|
`https://seven.sliccy.ai/auth/callback?code=${encodeURIComponent(this.marker)}&state=${encodeURIComponent(this.marker)}`,
|
|
90
96
|
`https://vault.sliccy.ai/oauth/mcp-callback?code=${encodeURIComponent(this.marker)}`,
|
|
@@ -158,7 +164,23 @@ class RedirectProbe {
|
|
|
158
164
|
await load(this.reader, this.observer, `${this.ordinary}/doc?n=${this.tag}-${suffix}`);
|
|
159
165
|
}
|
|
160
166
|
|
|
161
|
-
|
|
167
|
+
contains(value) {
|
|
168
|
+
return containsSecret(value, this.marker, this.seen);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async scan(items) {
|
|
172
|
+
const flags = [];
|
|
173
|
+
let slice = Date.now();
|
|
174
|
+
for (const item of items) {
|
|
175
|
+
flags.push(this.contains(item));
|
|
176
|
+
if (Date.now() - slice < 10) continue;
|
|
177
|
+
await pause(0);
|
|
178
|
+
slice = Date.now();
|
|
179
|
+
}
|
|
180
|
+
return flags;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async observe() {
|
|
162
184
|
this.observer = (
|
|
163
185
|
await this.reader.send('Target.attachToTarget', { targetId: this.targetId, flatten: true })
|
|
164
186
|
).sessionId;
|
|
@@ -176,6 +198,9 @@ class RedirectProbe {
|
|
|
176
198
|
},
|
|
177
199
|
],
|
|
178
200
|
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async setup() {
|
|
179
204
|
for (const domain of ['Page', 'Network', 'Runtime', 'Log', 'Audits'])
|
|
180
205
|
await this.send(`${domain}.enable`);
|
|
181
206
|
await this.send('Fetch.enable', {
|
|
@@ -249,9 +274,9 @@ class RedirectProbe {
|
|
|
249
274
|
resourceContentAttempted: true,
|
|
250
275
|
snapshotAttempted: true,
|
|
251
276
|
domSnapshotAttempted: true,
|
|
252
|
-
ordinaryResource:
|
|
253
|
-
ordinarySnapshot:
|
|
254
|
-
ordinaryDOM:
|
|
277
|
+
ordinaryResource: this.contains(resource),
|
|
278
|
+
ordinarySnapshot: this.contains(snapshot),
|
|
279
|
+
ordinaryDOM: this.contains(dom),
|
|
255
280
|
});
|
|
256
281
|
}
|
|
257
282
|
|
|
@@ -262,9 +287,8 @@ class RedirectProbe {
|
|
|
262
287
|
await this.send('Runtime.evaluate', {
|
|
263
288
|
expression: `fetch(${JSON.stringify(this.holdURL)}).catch(()=>{});void 0`,
|
|
264
289
|
});
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (!this.held) throw new Error('redirect body: Fetch override pause missing');
|
|
290
|
+
if (!(await waitFor(() => Boolean(this.held), 5000, 10)))
|
|
291
|
+
throw new Error('redirect body: Fetch override pause missing');
|
|
268
292
|
const requestId = this.held.params.requestId;
|
|
269
293
|
const out = await this.send('Fetch.continueRequest', { requestId, url });
|
|
270
294
|
this.responses.push({
|
|
@@ -330,8 +354,8 @@ class RedirectProbe {
|
|
|
330
354
|
const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
|
|
331
355
|
this.responses.push({
|
|
332
356
|
iframeSnapshotAttempted: true,
|
|
333
|
-
ordinaryIframeSnapshot:
|
|
334
|
-
ordinaryIframeDOM:
|
|
357
|
+
ordinaryIframeSnapshot: this.contains(snapshot),
|
|
358
|
+
ordinaryIframeDOM: this.contains(dom),
|
|
335
359
|
});
|
|
336
360
|
}
|
|
337
361
|
|
|
@@ -345,11 +369,13 @@ class RedirectProbe {
|
|
|
345
369
|
});
|
|
346
370
|
const events = this.client.events.slice(this.eventsStart);
|
|
347
371
|
const messages = this.client.messages?.slice(this.messagesStart) ?? [];
|
|
372
|
+
const eventLeaks = await this.scan(events);
|
|
348
373
|
const ordinaryEvents = events.filter(
|
|
349
|
-
(event) =>
|
|
350
|
-
|
|
351
|
-
JSON.stringify(event).includes(new URL(this.plainURL).host)
|
|
374
|
+
(event, index) =>
|
|
375
|
+
eventLeaks[index] && JSON.stringify(event).includes(new URL(this.plainURL).host)
|
|
352
376
|
);
|
|
377
|
+
const replies = [...messages, ...this.responses];
|
|
378
|
+
const replyLeaks = await this.scan(replies);
|
|
353
379
|
const extra = events.filter(
|
|
354
380
|
(event) =>
|
|
355
381
|
event.sessionId === this.session &&
|
|
@@ -377,14 +403,14 @@ class RedirectProbe {
|
|
|
377
403
|
entry.url.includes(`/redirect-secret/${this.tag}`)
|
|
378
404
|
),
|
|
379
405
|
rawRecorded: this.messagesStart !== undefined && messages.length > events.length,
|
|
380
|
-
leakChannels:
|
|
381
|
-
.filter((
|
|
406
|
+
leakChannels: replies
|
|
407
|
+
.filter((_, index) => replyLeaks[index])
|
|
382
408
|
.map((message) =>
|
|
383
409
|
typeof message === 'string'
|
|
384
410
|
? (JSON.parse(message).method ?? 'reply')
|
|
385
411
|
: (message.method ?? 'reply')
|
|
386
412
|
),
|
|
387
|
-
secretHidden: !
|
|
413
|
+
secretHidden: !replyLeaks.includes(true),
|
|
388
414
|
sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
|
|
389
415
|
fetchBodyAttempted: attempted('Fetch.getResponseBody'),
|
|
390
416
|
networkBodyAttempted: attempted('Network.getResponseBody'),
|
|
@@ -396,7 +422,7 @@ class RedirectProbe {
|
|
|
396
422
|
events,
|
|
397
423
|
replies: this.responses,
|
|
398
424
|
protectedOrigins: this.protectedURLs.map((url) => new URL(url).origin),
|
|
399
|
-
contains: (value) =>
|
|
425
|
+
contains: (value) => this.contains(value),
|
|
400
426
|
}),
|
|
401
427
|
redirectLocation: ordinaryEvents.some(
|
|
402
428
|
(event) =>
|
|
@@ -472,6 +498,11 @@ export async function redirectSecret(options) {
|
|
|
472
498
|
throw new Error('redirectSecret fixture registration required');
|
|
473
499
|
const probe = new RedirectProbe(options);
|
|
474
500
|
try {
|
|
501
|
+
const observed = await outcome(probe.observe());
|
|
502
|
+
if (!observed.ok)
|
|
503
|
+
return {
|
|
504
|
+
incomplete: `redirect-secret: observer setup failed: ${observed.error.message}`,
|
|
505
|
+
};
|
|
475
506
|
await probe.setup();
|
|
476
507
|
if (options.mode === 'body') await probe.body();
|
|
477
508
|
else await probe.headers();
|