@ai-ecoverse/slicc-browsers 1.7.2 → 1.7.4
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 +42 -12
- package/fixture-server.mjs +4 -2
- package/guard-driver.mjs +30 -8
- package/guard-runner.mjs +360 -84
- package/guard-vectors.json +2779 -686
- package/package.json +5 -3
- package/schema/guard-vectors.schema.json +105 -5
package/README.md
CHANGED
|
@@ -150,8 +150,8 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
|
|
|
150
150
|
- `scope`: `any`, or the one guard the vector is for;
|
|
151
151
|
- `setup` and `trigger`, both lists of steps;
|
|
152
152
|
- `targets`: the protected targets it runs against;
|
|
153
|
-
- `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability
|
|
154
|
-
- `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"? }`. `dependsOn` lists the refusals the observation depends on (see Refusals);
|
|
153
|
+
- `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability>`. A row with a `page-or-browser` cookie step that would carry a `browserContextId` reports `not-supported:browserSession` without a browser session, because that parameter is only allowed on the browser target;
|
|
154
|
+
- `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"?, "eitherWhen"? }`. `dependsOn` lists the refusals the observation depends on (see Refusals). `eitherWhen` is a check: when it holds on the guarded leg, that observation's expectation becomes `"either"` there (see Design-dependent outcomes);
|
|
155
155
|
- `live`: the liveness check;
|
|
156
156
|
- `control` (`origin: "ordinary"`, `via`, `expected`) and `expected`: an object of names and booleans, or text;
|
|
157
157
|
- `skip?` (`target`, `reason`): `reason` starts with a code, as in `not-applicable: …`.
|
|
@@ -170,7 +170,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
|
|
|
170
170
|
- `eval`: evaluate `expression`.
|
|
171
171
|
`hops` routes `url` through `/hop` on each `origin`, with its `code`.
|
|
172
172
|
- `{ "answer": { "url", "stage"?, "cdp"?, "params"?, "hold"? } }`: answer each paused request or auth challenge for `url` with that call; `hold` leaves it paused. Every other paused request continues unchanged.
|
|
173
|
-
- `{ "children": steps }`: run these steps on each target auto-attached from then on, then resume it.
|
|
173
|
+
- `{ "children": steps, "where"? }`: run these steps on each target auto-attached from then on, then resume it. `where` scopes them to the row's own target: `{ "openerId": "page" }` (opened by the row's page) or `{ "urlPrefix": url }`; other attached targets are resumed and otherwise left alone.
|
|
174
174
|
- `{ "settle": { "beacon"?, "title"?, "ms"?, "variant"?, "session"? } }`: wait for a beacon from `beacon`, for the page `title`, or for the session to detach. It waits up to `ms` (10000 by default), then another 600 ms. `variant` waits the run's detach variant instead.
|
|
175
175
|
- `{ "wait": ms }`.
|
|
176
176
|
- `{ "guess": { "between", "cdp", "params" }, "as" }`: the call for each request id between the ids paused for the two URLs.
|
|
@@ -194,7 +194,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
194
194
|
|
|
195
195
|
**Liveness.** A runner checks `live` through its own CDP, never through the guard path: either the protected document loaded (title, marker or URL), or the guard returned its own refusal for a refusable step. A failing `live` fails the id.
|
|
196
196
|
|
|
197
|
-
**Design-dependent outcomes.** A key listed in `designDependent` may expect `"either"`, which `true` and `false` both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. The hard negatives stay required.
|
|
197
|
+
**Design-dependent outcomes.** A key listed in `designDependent` may expect `"either"`, which `true` and `false` both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. `detached` is design-dependent on the rows whose guarded leg proves the block (the pre-action beacon plus an exact-URL block or the history form): its `eitherWhen` holds the same proof, so a guard that blocks without detaching passes, and one that neither blocks nor detaches still fails. The hard negatives stay required.
|
|
198
198
|
|
|
199
199
|
**Observability.** `observable.own: false` marks a vector the guard host's own origin can't show (headers, auth, redirects); a runner whose `own` is a static server reports it as `skip` with `not-observable`.
|
|
200
200
|
|
|
@@ -253,7 +253,10 @@ error as `error.cdp`, or an error message. `client` carries the guarded leg;
|
|
|
253
253
|
`unguarded`, when supplied, carries unguarded controls and observation reads.
|
|
254
254
|
There is no fallback from observer reads to the guarded client. A bare client
|
|
255
255
|
means guarded transport only; rows requiring an observer report
|
|
256
|
-
`skip` with `not-supported:unguarded-observer`, counted in `summary().observerSkipped`.
|
|
256
|
+
`skip` with `not-supported:unguarded-observer`, counted in `summary().observerSkipped`. The run's `summary()` also reports
|
|
257
|
+
`skips`, the number of skipped rows per reason, and appends
|
|
258
|
+
`skips <n> (<reason>=<count>, ...)` to `line` whenever any row was skipped, so a
|
|
259
|
+
certification line never reads skipped rows as coverage.
|
|
257
260
|
`clientFactory({ variant, detachDelay, vector, target, slot })` is called per row
|
|
258
261
|
and may return either a client or `{ client, unguarded, close, transportVariant }`.
|
|
259
262
|
The factory can configure the guard's own transport delay from this metadata;
|
|
@@ -270,13 +273,16 @@ of supported names. `capabilities.scope` selects owner-specific rows.
|
|
|
270
273
|
Map `ordinary`, `ordinary-same-site`, `seven`, `vault`, `vault-x`, `apex`, `own`
|
|
271
274
|
and `client` to origins. Optionally map `plain`, `cross` and `log`; their defaults
|
|
272
275
|
are `ordinary`, `client` and `ordinary`. Origins must preserve the site
|
|
273
|
-
relationships of the data.
|
|
276
|
+
relationships of the data. Map an unavailable target to `null`, or leave it out; a
|
|
277
|
+
missing or `undefined` origin is treated exactly like `null`: every
|
|
274
278
|
row needing that origin is reported as `skip` for control, guarded and live, with
|
|
275
279
|
reason `not-applicable:no-<target>-origin`; no substitute origin is used and the
|
|
276
280
|
row never passes. A guard whose whole site is protected, such as the extension
|
|
277
281
|
where every `sliccy.ai` host is protected, passes `ordinary-same-site: null`.
|
|
278
|
-
This also applies to origins referenced in setup
|
|
279
|
-
|
|
282
|
+
This also applies to origins referenced in setup and triggers, not only the row
|
|
283
|
+
target. In observations and liveness checks, an `any` alternative that references
|
|
284
|
+
a `null` origin is dropped and the rest are evaluated; the row skips only when
|
|
285
|
+
every alternative of an `any`, or a required (`all`, `not` or plain) check, needs it. Tags and cookie names are unique per leg. `variant`
|
|
280
286
|
selects one variant or an array; omission selects all. `parallel` defaults to 1.
|
|
281
287
|
`log(row)` receives completed rows. A variant supplies the wait before an
|
|
282
288
|
explicit `Target.detachFromTarget` step and for a `settle.variant` step.
|
|
@@ -291,7 +297,7 @@ Checks include: `beacon`, `served`, `header` with
|
|
|
291
297
|
`on`, `event` with `where`, `result` with `path` and `equals`, `cookie` with `from`,
|
|
292
298
|
`compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
|
|
293
299
|
`hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
|
|
294
|
-
`registration`, `state` and `filtered`. The diagnostic `executed` and `live`
|
|
300
|
+
`history`, `frameBlocked`, `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
|
|
295
301
|
checks are not valid liveness proofs.
|
|
296
302
|
Compose checks with `all`, `any` or `not`. Predicates support nested partial
|
|
297
303
|
objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
|
|
@@ -309,7 +315,30 @@ heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request an
|
|
|
309
315
|
an exact-URL block/error from the client or unguarded frame tree. An error for a
|
|
310
316
|
different URL is not evidence. `blocked` accepts either `{ url }` for one exact URL
|
|
311
317
|
or `{ anyOf: [url, ...] }` for a nonempty list of exact URLs; each URL can be a
|
|
312
|
-
string or `{ hops, to, index? }`. Both forms allow `session` and `frame`.
|
|
318
|
+
string or `{ hops, to, index? }`. Both forms allow `session` and `frame`. With `frame`,
|
|
319
|
+
the unguarded observer also reads the page's out-of-process iframe targets (matched by
|
|
320
|
+
their parent frame): a site-isolated iframe isn't in the parent's frame tree, so its
|
|
321
|
+
chrome-error main frame with the exact `unreachableUrl` is the block. `history` (unguarded,
|
|
322
|
+
same URL forms) is for guards that replace the error page, such as with `about:blank#blocked`: it
|
|
323
|
+
needs the pre-action beacon, an entry with the exact URL in the observer's
|
|
324
|
+
`Page.getNavigationHistory` of the page or child, and no fixture request for that URL.
|
|
325
|
+
`frameBlocked` (unguarded) is that out-of-process chrome-error iframe target, and the
|
|
326
|
+
client must be unable to attach to it; the cross-site `C4` rows accept it as `errorFrame`.
|
|
327
|
+
A step that the guard refuses in every context, such as `Network.clearBrowserCookies`
|
|
328
|
+
in `C6.clear-cookies`, is refusable, so refusing it on both legs passes and allowing it
|
|
329
|
+
is judged on its effect. A child step that fails after its child session detached is
|
|
330
|
+
recorded as detached, not as a refusal, and the runner awaits a child's started
|
|
331
|
+
`children` steps before any later `session: "child"` step and before the trigger. A page
|
|
332
|
+
navigation waits for the load of its own `loaderId` (its `Page.frameNavigated` and then a
|
|
333
|
+
load event), and a settle waits for a main-frame commit followed by its load, so a stale
|
|
334
|
+
load event never ends a wait. After a child's `children` steps that include `Page.enable`,
|
|
335
|
+
the runner waits, bounded at 5 s, for the child's first document to commit and load before
|
|
336
|
+
any later child step. `{ "settle": { "beacon", "required": true, "dependsOn"? } }` waits
|
|
337
|
+
for that beacon at the fixture and fails the row with `settle: no beacon from <origin>
|
|
338
|
+
within <ms>ms` when it never arrives, unless a refusal it `dependsOn` was recorded; the rows
|
|
339
|
+
whose `allowedBeacon` comes from an earlier document settle on it before their trigger.
|
|
340
|
+
Beacons use `navigator.sendBeacon`, with a `fetch(…, { keepalive: true })` fallback. An observation that reads a background step's `as` result
|
|
341
|
+
waits for that step, up to 10 s, and fails with `<as> did not complete within …` past it. Redirect
|
|
313
342
|
vectors list every nested hop URL and the final destination because Chromium
|
|
314
343
|
reports the URL intercepted by the guard. A matching block still requires the
|
|
315
344
|
pre-action beacon alongside it; an unrelated error cannot certify the chain.
|
|
@@ -331,7 +360,7 @@ fixture log. A setup beacon on the same origin and tag cannot settle an unfinish
|
|
|
331
360
|
redirect chain. This does not change the observations used to judge either leg.
|
|
332
361
|
|
|
333
362
|
The separately exported Node module `@ai-ecoverse/slicc-browsers/fixture` exposes
|
|
334
|
-
`fixtureServer({ host, port, tls })`, defaulting to a random loopback port.
|
|
363
|
+
`fixtureServer({ host, port, tls, latency })`, defaulting to a random loopback port. `latency(url)` returns milliseconds to hold that response, for timing tests.
|
|
335
364
|
Pass `tls: { key, cert }` to serve HTTPS using Node TLS key and certificate
|
|
336
365
|
values. The returned origin, request logs, redirect URLs and document heartbeats
|
|
337
366
|
use `https://<Host>` with TLS, so `served`, `hops` and `liveCheck` compare against
|
|
@@ -491,8 +520,9 @@ semantic-release cuts one version from `main` for all three packages (`.github/w
|
|
|
491
520
|
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.
|
|
492
521
|
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.
|
|
493
522
|
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.
|
|
523
|
+
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.
|
|
494
524
|
|
|
495
|
-
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.
|
|
525
|
+
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.
|
|
496
526
|
|
|
497
527
|
**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.
|
|
498
528
|
|
package/fixture-server.mjs
CHANGED
|
@@ -76,7 +76,7 @@ function route(req, url, send, html) {
|
|
|
76
76
|
return send(404, 'missing', 'text/plain');
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export async function fixtureServer({ host = '127.0.0.1', port = 0, tls } = {}) {
|
|
79
|
+
export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
|
|
80
80
|
let entries = [];
|
|
81
81
|
const scheme = tls ? 'https' : 'http';
|
|
82
82
|
const handler = (req, res) => {
|
|
@@ -105,7 +105,9 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls } = {})
|
|
|
105
105
|
'text/html',
|
|
106
106
|
headers
|
|
107
107
|
);
|
|
108
|
-
|
|
108
|
+
const wait = latency?.(url) ?? 0;
|
|
109
|
+
if (wait > 0) setTimeout(() => route(req, url, send, html), wait);
|
|
110
|
+
else route(req, url, send, html);
|
|
109
111
|
};
|
|
110
112
|
const server = tls ? createSecureServer(tls, handler) : createServer(handler);
|
|
111
113
|
await new Promise((resolve, reject) => {
|
package/guard-driver.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const q = JSON.stringify;
|
|
2
2
|
|
|
3
3
|
export const beacon = (log, tag) =>
|
|
4
|
-
`try {
|
|
4
|
+
`try { const u = ${q(log)} + '/beacon?tag=' + ${q(tag)} + '&origin=' + encodeURIComponent(location.origin); if (!navigator.sendBeacon(u)) fetch(u, { keepalive: true, mode: 'no-cors' }).catch(() => {}); } catch {}`;
|
|
5
5
|
|
|
6
6
|
export const probe = (log, tag) =>
|
|
7
7
|
`(() => { ${beacon(log, tag)} const p = btoa(location.origin + location.pathname); if (window.probeBinding) window.probeBinding(p); console.log('probe:' + p); })()`;
|
|
@@ -91,13 +91,31 @@ export async function instrument(client, sessionId, log, tag) {
|
|
|
91
91
|
await client.send('Page.setBypassCSP', { enabled: true }, sessionId);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
export
|
|
95
|
-
const
|
|
96
|
-
(e) =>
|
|
97
|
-
|
|
94
|
+
export function loadedAfter(events, sessionId, loaderId) {
|
|
95
|
+
const committed = events.findIndex(
|
|
96
|
+
(e) =>
|
|
97
|
+
e.method === 'Page.frameNavigated' &&
|
|
98
|
+
e.sessionId === sessionId &&
|
|
99
|
+
!e.params?.frame?.parentId &&
|
|
100
|
+
e.params?.frame?.loaderId === loaderId
|
|
101
|
+
);
|
|
102
|
+
return (
|
|
103
|
+
committed >= 0 &&
|
|
104
|
+
events
|
|
105
|
+
.slice(committed + 1)
|
|
106
|
+
.some((e) => e.method === 'Page.loadEventFired' && e.sessionId === sessionId)
|
|
98
107
|
);
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function load(client, sessionId, url, ms = 20000) {
|
|
111
|
+
const start = client.events.length;
|
|
112
|
+
const { loaderId } = await client.send('Page.navigate', { url }, sessionId);
|
|
113
|
+
if (!loaderId) return;
|
|
114
|
+
const deadline = Date.now() + ms;
|
|
115
|
+
while (!loadedAfter(client.events.slice(start), sessionId, loaderId)) {
|
|
116
|
+
if (Date.now() > deadline) throw new Error(`load of ${url} timed out`);
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
118
|
+
}
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
export function popup(client, openerId, ms = 10000) {
|
|
@@ -125,7 +143,11 @@ export function judge(vector, side, observed) {
|
|
|
125
143
|
(vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
|
|
126
144
|
);
|
|
127
145
|
const mismatches = Object.entries(want)
|
|
128
|
-
.filter(
|
|
146
|
+
.filter(
|
|
147
|
+
([k, v]) =>
|
|
148
|
+
!skipped.includes(k) &&
|
|
149
|
+
!matches(observed?.[`${k}.either`] === true ? 'either' : v, observed?.[k])
|
|
150
|
+
)
|
|
129
151
|
.map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
|
|
130
152
|
return { pass: mismatches.length === 0, refused, skipped, mismatches };
|
|
131
153
|
}
|