@ai-ecoverse/slicc-browsers 1.7.2 → 1.7.3
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 +19 -5
- package/guard-driver.mjs +5 -1
- package/guard-runner.mjs +146 -34
- package/guard-vectors.json +2711 -701
- package/package.json +2 -2
- package/schema/guard-vectors.schema.json +75 -5
package/README.md
CHANGED
|
@@ -151,7 +151,7 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
|
|
|
151
151
|
- `setup` and `trigger`, both lists of steps;
|
|
152
152
|
- `targets`: the protected targets it runs against;
|
|
153
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);
|
|
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: …`.
|
|
@@ -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
|
|
|
@@ -291,7 +291,7 @@ Checks include: `beacon`, `served`, `header` with
|
|
|
291
291
|
`on`, `event` with `where`, `result` with `path` and `equals`, `cookie` with `from`,
|
|
292
292
|
`compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
|
|
293
293
|
`hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
|
|
294
|
-
`registration`, `state` and `filtered`. The diagnostic `executed` and `live`
|
|
294
|
+
`history`, `frameBlocked`, `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
|
|
295
295
|
checks are not valid liveness proofs.
|
|
296
296
|
Compose checks with `all`, `any` or `not`. Predicates support nested partial
|
|
297
297
|
objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
|
|
@@ -309,7 +309,20 @@ heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request an
|
|
|
309
309
|
an exact-URL block/error from the client or unguarded frame tree. An error for a
|
|
310
310
|
different URL is not evidence. `blocked` accepts either `{ url }` for one exact URL
|
|
311
311
|
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`.
|
|
312
|
+
string or `{ hops, to, index? }`. Both forms allow `session` and `frame`. With `frame`,
|
|
313
|
+
the unguarded observer also reads the page's out-of-process iframe targets (matched by
|
|
314
|
+
their parent frame): a site-isolated iframe isn't in the parent's frame tree, so its
|
|
315
|
+
chrome-error main frame with the exact `unreachableUrl` is the block. `history` (unguarded,
|
|
316
|
+
same URL forms) is for guards that replace the error page, such as with `about:blank#blocked`: it
|
|
317
|
+
needs the pre-action beacon, an entry with the exact URL in the observer's
|
|
318
|
+
`Page.getNavigationHistory` of the page or child, and no fixture request for that URL.
|
|
319
|
+
`frameBlocked` (unguarded) is that out-of-process chrome-error iframe target, and the
|
|
320
|
+
client must be unable to attach to it; the cross-site `C4` rows accept it as `errorFrame`.
|
|
321
|
+
A step that the guard refuses in every context, such as `Network.clearBrowserCookies`
|
|
322
|
+
in `C6.clear-cookies`, is refusable, so refusing it on both legs passes and allowing it
|
|
323
|
+
is judged on its effect. A child step that fails after its child session detached is
|
|
324
|
+
recorded as detached, not as a refusal, and the runner awaits a child's started
|
|
325
|
+
`children` steps before any later `session: "child"` step and before the trigger. Redirect
|
|
313
326
|
vectors list every nested hop URL and the final destination because Chromium
|
|
314
327
|
reports the URL intercepted by the guard. A matching block still requires the
|
|
315
328
|
pre-action beacon alongside it; an unrelated error cannot certify the chain.
|
|
@@ -491,8 +504,9 @@ semantic-release cuts one version from `main` for all three packages (`.github/w
|
|
|
491
504
|
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
505
|
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
506
|
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.
|
|
507
|
+
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
508
|
|
|
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.
|
|
509
|
+
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
510
|
|
|
497
511
|
**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
512
|
|
package/guard-driver.mjs
CHANGED
|
@@ -125,7 +125,11 @@ export function judge(vector, side, observed) {
|
|
|
125
125
|
(vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
|
|
126
126
|
);
|
|
127
127
|
const mismatches = Object.entries(want)
|
|
128
|
-
.filter(
|
|
128
|
+
.filter(
|
|
129
|
+
([k, v]) =>
|
|
130
|
+
!skipped.includes(k) &&
|
|
131
|
+
!matches(observed?.[`${k}.either`] === true ? 'either' : v, observed?.[k])
|
|
132
|
+
)
|
|
129
133
|
.map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
|
|
130
134
|
return { pass: mismatches.length === 0, refused, skipped, mismatches };
|
|
131
135
|
}
|
package/guard-runner.mjs
CHANGED
|
@@ -108,6 +108,7 @@ class Execution {
|
|
|
108
108
|
this.answers = [];
|
|
109
109
|
this.attachSettings = new Map();
|
|
110
110
|
this.childSteps = [];
|
|
111
|
+
this.childRuns = new Map();
|
|
111
112
|
this.processed = new Set();
|
|
112
113
|
this.eventStart = this.client.events.length;
|
|
113
114
|
this.actionEventStart = this.eventStart;
|
|
@@ -283,12 +284,99 @@ class Execution {
|
|
|
283
284
|
tree.frame.url.startsWith('chrome-error://')) ||
|
|
284
285
|
(tree.childFrames ?? []).some(visit))
|
|
285
286
|
);
|
|
286
|
-
|
|
287
|
+
if (
|
|
287
288
|
read.ok &&
|
|
288
289
|
(spec.frame
|
|
289
290
|
? (read.result.frameTree.childFrames ?? []).some(visit)
|
|
290
291
|
: visit(read.result.frameTree))
|
|
292
|
+
)
|
|
293
|
+
return true;
|
|
294
|
+
return spec.frame ? Boolean(await this.blockedFrameTarget(urls, targetId)) : false;
|
|
295
|
+
} finally {
|
|
296
|
+
await outcome(
|
|
297
|
+
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async frameIds(targetId) {
|
|
303
|
+
const attached = await outcome(
|
|
304
|
+
this.reader.send('Target.attachToTarget', { targetId, flatten: true })
|
|
305
|
+
);
|
|
306
|
+
if (!attached.ok) return [];
|
|
307
|
+
try {
|
|
308
|
+
const read = await outcome(
|
|
309
|
+
this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
|
|
310
|
+
);
|
|
311
|
+
const ids = (tree) => [tree.frame.id, ...(tree.childFrames ?? []).flatMap(ids)];
|
|
312
|
+
return read.ok ? ids(read.result.frameTree) : [];
|
|
313
|
+
} finally {
|
|
314
|
+
await outcome(
|
|
315
|
+
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async blockedFrameTarget(urls, parentTarget) {
|
|
321
|
+
const parents = await this.frameIds(parentTarget);
|
|
322
|
+
const listed = await outcome(this.reader.send('Target.getTargets', {}));
|
|
323
|
+
if (!listed.ok) return undefined;
|
|
324
|
+
for (const info of listed.result.targetInfos) {
|
|
325
|
+
if (info.type !== 'iframe' || !parents.includes(info.parentFrameId)) continue;
|
|
326
|
+
const attached = await outcome(
|
|
327
|
+
this.reader.send('Target.attachToTarget', { targetId: info.targetId, flatten: true })
|
|
328
|
+
);
|
|
329
|
+
if (!attached.ok) continue;
|
|
330
|
+
try {
|
|
331
|
+
const read = await outcome(
|
|
332
|
+
this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
|
|
333
|
+
);
|
|
334
|
+
const frame = read.result?.frameTree?.frame;
|
|
335
|
+
if (frame?.url.startsWith('chrome-error://') && urls.includes(frame.unreachableUrl))
|
|
336
|
+
return info.targetId;
|
|
337
|
+
} finally {
|
|
338
|
+
await outcome(
|
|
339
|
+
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async frameBlocked(spec) {
|
|
347
|
+
const urls = (spec.anyOf ?? [spec.url]).map(address);
|
|
348
|
+
const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
|
|
349
|
+
if (!targetId) return false;
|
|
350
|
+
const frame = await this.blockedFrameTarget(urls, targetId);
|
|
351
|
+
if (!frame) return false;
|
|
352
|
+
const attached = await outcome(
|
|
353
|
+
this.client.send('Target.attachToTarget', { targetId: frame, flatten: true })
|
|
354
|
+
);
|
|
355
|
+
if (attached.ok)
|
|
356
|
+
await outcome(
|
|
357
|
+
this.client.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
358
|
+
);
|
|
359
|
+
return !attached.ok;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async history(spec) {
|
|
363
|
+
const urls = (spec.anyOf ?? [spec.url]).map(address);
|
|
364
|
+
const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
|
|
365
|
+
if (!targetId) return false;
|
|
366
|
+
const pre = await Promise.all(urls.map((url) => this.attemptCheck({ pre: url })));
|
|
367
|
+
if (!pre.some(Boolean)) return false;
|
|
368
|
+
const loaded = await Promise.all(urls.map((url) => this.attemptCheck({ loaded: url })));
|
|
369
|
+
const unloaded = urls.filter((_, index) => !loaded[index]);
|
|
370
|
+
if (!unloaded.length) return false;
|
|
371
|
+
const attached = await outcome(
|
|
372
|
+
this.reader.send('Target.attachToTarget', { targetId, flatten: true })
|
|
373
|
+
);
|
|
374
|
+
if (!attached.ok) return false;
|
|
375
|
+
try {
|
|
376
|
+
const read = await outcome(
|
|
377
|
+
this.reader.send('Page.getNavigationHistory', {}, attached.result.sessionId)
|
|
291
378
|
);
|
|
379
|
+
return read.ok && read.result.entries.some((entry) => unloaded.includes(entry.url));
|
|
292
380
|
} finally {
|
|
293
381
|
await outcome(
|
|
294
382
|
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
@@ -349,34 +437,34 @@ class Execution {
|
|
|
349
437
|
continue;
|
|
350
438
|
this.sessions.child = event.params.sessionId;
|
|
351
439
|
if (info?.targetId) this.targets.push(info.targetId);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
await outcome(
|
|
360
|
-
this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
|
|
361
|
-
);
|
|
362
|
-
await this.poll(
|
|
363
|
-
() =>
|
|
364
|
-
this.client.events
|
|
365
|
-
.slice(start)
|
|
366
|
-
.some(
|
|
367
|
-
(e) =>
|
|
368
|
-
e.method === 'Page.loadEventFired' &&
|
|
369
|
-
e.sessionId === event.params.sessionId
|
|
370
|
-
),
|
|
371
|
-
1500
|
|
372
|
-
);
|
|
373
|
-
await this.execute(this.childSteps.slice(reload), event.params.sessionId);
|
|
374
|
-
}
|
|
440
|
+
const child = event.params.sessionId;
|
|
441
|
+
const started = (async () => {
|
|
442
|
+
const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
|
|
443
|
+
if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
|
|
444
|
+
else {
|
|
445
|
+
await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
|
|
446
|
+
const start = this.client.events.length;
|
|
375
447
|
await outcome(
|
|
376
448
|
this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
|
|
377
449
|
);
|
|
378
|
-
|
|
379
|
-
|
|
450
|
+
await this.poll(
|
|
451
|
+
() =>
|
|
452
|
+
this.client.events
|
|
453
|
+
.slice(start)
|
|
454
|
+
.some(
|
|
455
|
+
(e) =>
|
|
456
|
+
e.method === 'Page.loadEventFired' && e.sessionId === event.params.sessionId
|
|
457
|
+
),
|
|
458
|
+
1500
|
|
459
|
+
);
|
|
460
|
+
await this.execute(this.childSteps.slice(reload), event.params.sessionId);
|
|
461
|
+
}
|
|
462
|
+
await outcome(
|
|
463
|
+
this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
|
|
464
|
+
);
|
|
465
|
+
})().catch((error) => this.errors.push(String(error.message ?? error)));
|
|
466
|
+
this.childRuns.set(child, started);
|
|
467
|
+
this.pending.push(started);
|
|
380
468
|
}
|
|
381
469
|
}
|
|
382
470
|
await sleep(10);
|
|
@@ -410,23 +498,40 @@ class Execution {
|
|
|
410
498
|
this.calls[`answer.${p.requestId}.${event.method}`] = out;
|
|
411
499
|
}
|
|
412
500
|
|
|
501
|
+
async childrenSettled() {
|
|
502
|
+
await Promise.all([...this.childRuns.values()]);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
detached(session) {
|
|
506
|
+
return this.client.events
|
|
507
|
+
.slice(this.eventStart)
|
|
508
|
+
.some((e) => e.method === 'Target.detachedFromTarget' && e.params?.sessionId === session);
|
|
509
|
+
}
|
|
510
|
+
|
|
413
511
|
async execute(list, childSession, phase = 'child') {
|
|
512
|
+
if (phase === 'trigger') await this.childrenSettled();
|
|
414
513
|
for (const raw of list) {
|
|
415
514
|
const step = expand(raw, this.vars);
|
|
416
515
|
if (!supported(step.requires, this.options.capabilities) || step.text) continue;
|
|
516
|
+
if (!childSession && step.session === 'child') await this.childrenSettled();
|
|
417
517
|
const session = childSession ?? this.session(step.session);
|
|
418
518
|
const out = await this.operation(step, session, phase);
|
|
419
519
|
if (!out) continue;
|
|
520
|
+
const gone = Boolean(childSession) && !out.ok && this.detached(childSession);
|
|
521
|
+
const refused =
|
|
522
|
+
!gone &&
|
|
523
|
+
(step.refusable
|
|
524
|
+
? isRefusal(out, step)
|
|
525
|
+
: /refused/.test(String(out.error?.message ?? out.error)));
|
|
420
526
|
this.trace.push({
|
|
421
527
|
phase,
|
|
422
528
|
declared: Boolean(step.refusable),
|
|
423
529
|
session,
|
|
424
530
|
out,
|
|
425
|
-
refused
|
|
426
|
-
|
|
427
|
-
: /refused/.test(String(out.error?.message ?? out.error)),
|
|
531
|
+
refused,
|
|
532
|
+
...(gone ? { detached: true } : {}),
|
|
428
533
|
});
|
|
429
|
-
if (step.refusable) this.refused[step.refusal.name] =
|
|
534
|
+
if (step.refusable) this.refused[step.refusal.name] = refused;
|
|
430
535
|
if (step.as && !out.result?.pending) this.calls[step.as] = out;
|
|
431
536
|
if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
|
|
432
537
|
this.options.debug?.({ step, out, side: this.side });
|
|
@@ -671,6 +776,8 @@ class Execution {
|
|
|
671
776
|
);
|
|
672
777
|
if (c.state) return this.readState(c.state);
|
|
673
778
|
if (c.blocked) return this.blocked(c.blocked, c.observer);
|
|
779
|
+
if (c.history) return this.history(c.history);
|
|
780
|
+
if (c.frameBlocked) return this.frameBlocked(c.frameBlocked);
|
|
674
781
|
if (c.pre || c.loaded) return this.attemptCheck(c);
|
|
675
782
|
}
|
|
676
783
|
|
|
@@ -786,6 +893,8 @@ class Execution {
|
|
|
786
893
|
if (!supported(observation.requires, this.options.capabilities)) continue;
|
|
787
894
|
if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
|
|
788
895
|
observed[name] = await this.check(observation.check);
|
|
896
|
+
if (observation.eitherWhen && this.side === 'guarded')
|
|
897
|
+
observed[`${name}.either`] = await this.check(observation.eitherWhen);
|
|
789
898
|
}
|
|
790
899
|
this.options.debug?.({
|
|
791
900
|
events: this.client.events.slice(this.eventStart),
|
|
@@ -826,7 +935,9 @@ const observerRequired = (check) => {
|
|
|
826
935
|
const needsObserver = (vector) =>
|
|
827
936
|
vector.control.via === 'unguarded' ||
|
|
828
937
|
observerRequired(vector.liveCheck) ||
|
|
829
|
-
Object.values(vector.observe ?? {}).some(
|
|
938
|
+
Object.values(vector.observe ?? {}).some(
|
|
939
|
+
(observation) => observerRequired(observation.check) || observerRequired(observation.eitherWhen)
|
|
940
|
+
);
|
|
830
941
|
|
|
831
942
|
const inactive = (vector, target, capabilities) =>
|
|
832
943
|
vector.skip?.target === target ||
|
|
@@ -954,9 +1065,10 @@ const invalidBlocked = (check) =>
|
|
|
954
1065
|
function validateBlocked(vectors) {
|
|
955
1066
|
for (const vector of vectors)
|
|
956
1067
|
if (
|
|
957
|
-
[
|
|
958
|
-
|
|
959
|
-
|
|
1068
|
+
[
|
|
1069
|
+
vector.liveCheck,
|
|
1070
|
+
...Object.values(vector.observe ?? {}).flatMap((o) => [o.check, o.eitherWhen]),
|
|
1071
|
+
].some(invalidBlocked)
|
|
960
1072
|
)
|
|
961
1073
|
throw new TypeError(`${vector.id}: blocked needs exactly one url or nonempty anyOf`);
|
|
962
1074
|
}
|