scenescout 3.12.0 → 3.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +24 -2
- package/dist/ci-run.js +366 -0
- package/dist/cli.js +69 -0
- package/dist/engine/browser.js +63 -34
- package/dist/engine/check.js +3 -1
- package/dist/engine/ci.js +633 -0
- package/dist/engine/dedup.js +237 -0
- package/dist/engine/memory.js +18 -3
- package/dist/engine/policy.js +42 -0
- package/dist/engine/provider.js +193 -0
- package/package.json +7 -3
package/dist/engine/browser.js
CHANGED
|
@@ -22,7 +22,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
|
22
22
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
23
23
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
24
24
|
import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
|
|
25
|
-
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, } from "./policy.js";
|
|
25
|
+
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
|
|
26
26
|
import { scanProject } from "../scan.js";
|
|
27
27
|
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
28
28
|
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
@@ -201,7 +201,13 @@ export class BrowserEngine {
|
|
|
201
201
|
/** Set when the project's real path could not be resolved — named in fence refusals, which it may then cause. */
|
|
202
202
|
projectDirNote = "";
|
|
203
203
|
memory = null;
|
|
204
|
-
|
|
204
|
+
currentMode = "read-only";
|
|
205
|
+
/** The rule the write policy judges by: `mode`, and for a moment after a flow hands back, the flow's stricter one. */
|
|
206
|
+
writeRule = new WriteRule("read-only");
|
|
207
|
+
/** The session's write mode. Read-only from outside: it changes only through `setMode`, which keeps `writeRule` in step. */
|
|
208
|
+
get mode() {
|
|
209
|
+
return this.currentMode;
|
|
210
|
+
}
|
|
205
211
|
/** Origins named as trusted embeds (policy.ts trustsEmbedWrite decides when that counts). */
|
|
206
212
|
trustedEmbeds = new Set();
|
|
207
213
|
trustNotice = "";
|
|
@@ -665,7 +671,8 @@ export class BrowserEngine {
|
|
|
665
671
|
if (opts.storageStatePath && !fs.existsSync(opts.storageStatePath)) {
|
|
666
672
|
throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
|
|
667
673
|
}
|
|
668
|
-
this.
|
|
674
|
+
this.currentMode = opts.mode ?? "read-only";
|
|
675
|
+
this.writeRule = new WriteRule(this.currentMode);
|
|
669
676
|
const trust = trustedEmbedOrigins(opts.trustedEmbeds);
|
|
670
677
|
this.trustedEmbeds = new Set(trust.origins);
|
|
671
678
|
this.trustNotice =
|
|
@@ -815,16 +822,17 @@ export class BrowserEngine {
|
|
|
815
822
|
// that was never submitted reading as tested, in read-only mode where by
|
|
816
823
|
// definition nothing is.
|
|
817
824
|
// Same test the route handler uses: in observe only an exempt auth request goes out.
|
|
818
|
-
|
|
825
|
+
const rule = this.writeRule.at();
|
|
826
|
+
if (rule === "observe" && !isAuthExempt(rule, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
|
|
819
827
|
return;
|
|
820
|
-
if (
|
|
828
|
+
if ((rule === "read-only" || rule === "observe") && isDestructiveWire(pathnameOf(req.url()), req.postData()))
|
|
821
829
|
return;
|
|
822
830
|
// A foreign frame's write is refused in every mode the route handler runs in.
|
|
823
|
-
if (
|
|
831
|
+
if (rule !== "destructive" && this.foreignWriteOf(req))
|
|
824
832
|
return;
|
|
825
|
-
if (
|
|
833
|
+
if (rule !== "destructive" &&
|
|
826
834
|
offAppPageWrite(this.baseUrl, this.page?.url(), req.url(), this.embedMoves.movedTo) &&
|
|
827
|
-
!isAuthExempt(
|
|
835
|
+
!isAuthExempt(rule, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
|
|
828
836
|
return;
|
|
829
837
|
const pageUrl = this.page?.url();
|
|
830
838
|
if (pageUrl && this.memory) {
|
|
@@ -841,6 +849,8 @@ export class BrowserEngine {
|
|
|
841
849
|
await this.context.route("**/*", async (route) => {
|
|
842
850
|
const req = route.request();
|
|
843
851
|
const method = req.method();
|
|
852
|
+
// Read once, as the request arrives: the rule the page sent it under (policy.ts WriteRule), not whatever holds after an await below.
|
|
853
|
+
const rule = this.writeRule.at();
|
|
844
854
|
// A redirect is followed by the browser without asking, so its target
|
|
845
855
|
// would load unsandboxed: a frame's redirect is answered with a
|
|
846
856
|
// sandboxed page that navigates there itself, and the next hop comes
|
|
@@ -932,7 +942,7 @@ export class BrowserEngine {
|
|
|
932
942
|
// of one actually runs; a navigation is dropped (policy.ts says why).
|
|
933
943
|
// Caught: a request the page has already cancelled rejects these, and an unhandled rejection ends the process.
|
|
934
944
|
if (answered)
|
|
935
|
-
return route.fulfill(policyRefusal(
|
|
945
|
+
return route.fulfill(policyRefusal(rule, method, pathname, req.headers()["origin"], why)).catch(() => { });
|
|
936
946
|
return route.abort("blockedbyclient").catch(() => { });
|
|
937
947
|
};
|
|
938
948
|
// An embedded widget from another site writes to that site, not to the
|
|
@@ -944,12 +954,12 @@ export class BrowserEngine {
|
|
|
944
954
|
const destructiveWire = isDestructiveWire(pathname, req.postData());
|
|
945
955
|
// An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
|
|
946
956
|
const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
|
|
947
|
-
if (offApp && !isAuthExempt(
|
|
957
|
+
if (offApp && !isAuthExempt(rule, method, pathname, destructiveWire))
|
|
948
958
|
return refuse(`sent from a page of ${offApp}, outside the app`);
|
|
949
959
|
// Auth/session flows must work in every mode — but never a destructive
|
|
950
960
|
// one, and in observe only the requests a login itself needs.
|
|
951
|
-
if (isAuthExempt(
|
|
952
|
-
this.routedWrites.note(
|
|
961
|
+
if (isAuthExempt(rule, method, pathname, destructiveWire)) {
|
|
962
|
+
this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
|
|
953
963
|
return route.continue();
|
|
954
964
|
}
|
|
955
965
|
let owned = this.isOwnedResource(pathname);
|
|
@@ -962,13 +972,13 @@ export class BrowserEngine {
|
|
|
962
972
|
// guaranteed visible — give it a bounded moment to land before the
|
|
963
973
|
// mutation is judged not-owned, otherwise the session's own,
|
|
964
974
|
// just-created resource gets wrongly blocked by a timing accident.
|
|
965
|
-
if (!owned &&
|
|
975
|
+
if (!owned && rule === "safe-write" && method !== "POST" && this.pendingCreations.size > 0) {
|
|
966
976
|
await BrowserEngine.settleWithin(Promise.allSettled([...this.pendingCreations]), 1500);
|
|
967
977
|
owned = this.isOwnedResource(pathname);
|
|
968
978
|
}
|
|
969
979
|
// POST: creation/RPC passes unless it smells destructive and isn't ours.
|
|
970
980
|
// PUT/PATCH/DELETE: only in safe-write, only on our own resources.
|
|
971
|
-
const allow = allowsWrite(
|
|
981
|
+
const allow = allowsWrite(rule, method, destructiveWire, owned);
|
|
972
982
|
if (allow) {
|
|
973
983
|
// Ownership tracking (safe-write): register the creation-tracking
|
|
974
984
|
// task BEFORE the POST goes out. Registering from a context
|
|
@@ -977,7 +987,7 @@ export class BrowserEngine {
|
|
|
977
987
|
// chained directly off the POST's json() could be judged before
|
|
978
988
|
// the listener ever ran. Here the registration is synchronous with
|
|
979
989
|
// request dispatch, which closes that window completely.
|
|
980
|
-
if (
|
|
990
|
+
if (rule === "safe-write" && method === "POST" && !BENIGN_MUTATION_RE.test(url)) {
|
|
981
991
|
const task = req
|
|
982
992
|
.response()
|
|
983
993
|
.then((res) => (res && res.ok() ? this.recordCreation(res) : undefined))
|
|
@@ -985,7 +995,7 @@ export class BrowserEngine {
|
|
|
985
995
|
.finally(() => this.pendingCreations.delete(task));
|
|
986
996
|
this.pendingCreations.add(task);
|
|
987
997
|
}
|
|
988
|
-
this.routedWrites.note(
|
|
998
|
+
this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
|
|
989
999
|
return route.continue();
|
|
990
1000
|
}
|
|
991
1001
|
return refuse();
|
|
@@ -1806,7 +1816,7 @@ export class BrowserEngine {
|
|
|
1806
1816
|
}
|
|
1807
1817
|
if (frame === frame.page().mainFrame())
|
|
1808
1818
|
return null;
|
|
1809
|
-
if (allowsForeignWriteOnSignIn(this.
|
|
1819
|
+
if (allowsForeignWriteOnSignIn(this.writeRule.at(), this.page?.url() ?? "", this.baseUrl))
|
|
1810
1820
|
return null;
|
|
1811
1821
|
let url;
|
|
1812
1822
|
try {
|
|
@@ -1839,10 +1849,10 @@ export class BrowserEngine {
|
|
|
1839
1849
|
const source = requestSource(req);
|
|
1840
1850
|
const originHeader = req.headers()["origin"];
|
|
1841
1851
|
const foreign = foreignWrite(this.baseUrl, { url: req.url(), originHeader, unadoptedPageUrl, pageHasForeignFrame, ...source });
|
|
1842
|
-
if (foreign && allowsForeignWriteOnSignIn(this.
|
|
1852
|
+
if (foreign && allowsForeignWriteOnSignIn(this.writeRule.at(), this.page?.url() ?? "", this.baseUrl))
|
|
1843
1853
|
return null;
|
|
1844
1854
|
// Frames of origins the user named as trusted, in safe-write, every one involved: the ordinary rules.
|
|
1845
|
-
if (foreign && trustsForeignWrite(this.
|
|
1855
|
+
if (foreign && trustsForeignWrite(this.writeRule.at(), this.trustedEmbeds, this.baseUrl, { frameChain: source.frameChain, originHeader, unadoptedPageUrl }))
|
|
1846
1856
|
return null;
|
|
1847
1857
|
return foreign;
|
|
1848
1858
|
}
|
|
@@ -1870,11 +1880,13 @@ export class BrowserEngine {
|
|
|
1870
1880
|
});
|
|
1871
1881
|
};
|
|
1872
1882
|
try {
|
|
1883
|
+
// A request paused here was sent a moment ago, possibly by a page a flow has just left: WriteRule still holds the flow's rule.
|
|
1884
|
+
const rule = this.writeRule.at();
|
|
1873
1885
|
// Reads, and everything in destructive, go on. A later hop of a redirect is judged like any write: the route handler never sees one.
|
|
1874
|
-
if (isReadMethod(method) ||
|
|
1886
|
+
if (isReadMethod(method) || rule === "destructive")
|
|
1875
1887
|
return await answer(true);
|
|
1876
1888
|
const bytes = pausedRequestBytes(event.request);
|
|
1877
|
-
if (this.routedWrites.claim(
|
|
1889
|
+
if (this.routedWrites.claim(rule, method, url, bodyDigest(bytes)))
|
|
1878
1890
|
return await answer(true);
|
|
1879
1891
|
let verdict;
|
|
1880
1892
|
let pathname = url;
|
|
@@ -1882,7 +1894,7 @@ export class BrowserEngine {
|
|
|
1882
1894
|
pathname = pathnameOf(url);
|
|
1883
1895
|
const { foreign, offApp } = unseenWriteSource({
|
|
1884
1896
|
appUrl: this.baseUrl,
|
|
1885
|
-
mode:
|
|
1897
|
+
mode: rule,
|
|
1886
1898
|
url,
|
|
1887
1899
|
originHeader: headerOf(headers, "origin"),
|
|
1888
1900
|
referer: headerOf(headers, "referer"),
|
|
@@ -1891,7 +1903,7 @@ export class BrowserEngine {
|
|
|
1891
1903
|
movedByEmbed: this.embedMoves.movedTo,
|
|
1892
1904
|
});
|
|
1893
1905
|
verdict = judgeUnseenWrite({
|
|
1894
|
-
mode:
|
|
1906
|
+
mode: rule,
|
|
1895
1907
|
method,
|
|
1896
1908
|
pathname,
|
|
1897
1909
|
destructiveWire: isDestructiveWire(pathname, bytes?.toString("utf8")),
|
|
@@ -1976,7 +1988,9 @@ export class BrowserEngine {
|
|
|
1976
1988
|
const escaped = reasons.delete(ESCAPE_REFUSAL);
|
|
1977
1989
|
const foreign = [...reasons];
|
|
1978
1990
|
this.blockedRequests = [];
|
|
1979
|
-
|
|
1991
|
+
// The rule that judged them, which just after a flow hands back is still the flow's (WriteRule).
|
|
1992
|
+
const rule = this.writeRule.at();
|
|
1993
|
+
return (`\n🛡 WRITE-POLICY blocked (${rule}): ${list}${extra}. ` +
|
|
1980
1994
|
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
1981
1995
|
(foreign.length > 0
|
|
1982
1996
|
? `Refused because it was ${foreign.join("; ")}: it would reach a site embedded in the page rather than the app, which no mode but destructive allows. `
|
|
@@ -1987,9 +2001,9 @@ export class BrowserEngine {
|
|
|
1987
2001
|
(answered
|
|
1988
2002
|
? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
|
|
1989
2003
|
: "") +
|
|
1990
|
-
(
|
|
2004
|
+
(rule === "observe"
|
|
1991
2005
|
? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target.`
|
|
1992
|
-
:
|
|
2006
|
+
: rule === "read-only"
|
|
1993
2007
|
? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
|
|
1994
2008
|
: `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
|
|
1995
2009
|
}
|
|
@@ -3318,9 +3332,9 @@ export class BrowserEngine {
|
|
|
3318
3332
|
*/
|
|
3319
3333
|
async replayFlow(steps, mode = this.mode === "observe" ? "observe" : "read-only") {
|
|
3320
3334
|
const page = this.requirePage();
|
|
3321
|
-
// The write policy
|
|
3335
|
+
// The write policy judges by writeRule, so the flow's rule holds from here until the flow has handed back (finally).
|
|
3322
3336
|
const crawlMode = this.mode;
|
|
3323
|
-
this.mode
|
|
3337
|
+
this.setMode(mode);
|
|
3324
3338
|
const context = this.context;
|
|
3325
3339
|
const violations = [];
|
|
3326
3340
|
const here = () => {
|
|
@@ -3472,7 +3486,7 @@ export class BrowserEngine {
|
|
|
3472
3486
|
// What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
|
|
3473
3487
|
const blocked = ownRefusals();
|
|
3474
3488
|
if (!refusal && blocked.length > 0)
|
|
3475
|
-
refusal = `the ${this.
|
|
3489
|
+
refusal = `the ${this.writeRule.at()} write policy refused ${blocked.join(", ")}`;
|
|
3476
3490
|
collect();
|
|
3477
3491
|
if (refusal)
|
|
3478
3492
|
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
@@ -3495,18 +3509,24 @@ export class BrowserEngine {
|
|
|
3495
3509
|
status: "refused",
|
|
3496
3510
|
step: steps.length,
|
|
3497
3511
|
did: describeStep(last),
|
|
3498
|
-
reason: `after the last step, the ${this.
|
|
3512
|
+
reason: `after the last step, the ${this.writeRule.at()} write policy refused ${late.join(", ")}`,
|
|
3499
3513
|
path: here(),
|
|
3500
3514
|
});
|
|
3501
3515
|
}
|
|
3502
3516
|
return done({ status: "passed" });
|
|
3503
3517
|
}
|
|
3504
3518
|
finally {
|
|
3505
|
-
//
|
|
3506
|
-
|
|
3519
|
+
// The hand-back, in order. Every page in the context is left while the flow's rule holds, the one a step opened
|
|
3520
|
+
// a tab from included (a popup adopted as the session's page leaves it running), so nothing a page the flow used
|
|
3521
|
+
// does runs on under the crawl's rule. Only then is the crawl's rule restored, and WriteRule keeps the flow's for
|
|
3522
|
+
// a moment longer, for a write a page sent as it was left that the engine hears of late.
|
|
3523
|
+
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => BrowserEngine.leave(p))), 5000);
|
|
3507
3524
|
this.blockedRequests = [];
|
|
3508
3525
|
this.oracles.drain(false);
|
|
3509
|
-
this.
|
|
3526
|
+
this.setMode(crawlMode);
|
|
3527
|
+
// Pages the session no longer drives are closed only now, at about:blank and under the held rule: in Firefox a
|
|
3528
|
+
// page closed straight after it is left sent its leaving beacon unjudged about one time in forty.
|
|
3529
|
+
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => (p === this.page ? undefined : p.close().catch(() => { })))), 5000);
|
|
3510
3530
|
page.off("websocket", onSocket);
|
|
3511
3531
|
context.off("response", onResponse);
|
|
3512
3532
|
this.refs.clear();
|
|
@@ -3514,6 +3534,11 @@ export class BrowserEngine {
|
|
|
3514
3534
|
this.snapshotUrl = "";
|
|
3515
3535
|
}
|
|
3516
3536
|
}
|
|
3537
|
+
/** Change the write mode; a looser one takes over the judging only after WriteRule's hold (policy.ts). */
|
|
3538
|
+
setMode(mode) {
|
|
3539
|
+
this.currentMode = mode;
|
|
3540
|
+
this.writeRule.set(mode);
|
|
3541
|
+
}
|
|
3517
3542
|
/** Computed-style design audit of the current page — visual judgment material without pixels. */
|
|
3518
3543
|
async designAudit() {
|
|
3519
3544
|
const { url, report } = await this.auditPage();
|
|
@@ -3769,7 +3794,11 @@ export class BrowserEngine {
|
|
|
3769
3794
|
return;
|
|
3770
3795
|
await page.goto("about:blank", { timeout: 3000 }).catch(() => { });
|
|
3771
3796
|
}
|
|
3772
|
-
/**
|
|
3797
|
+
/**
|
|
3798
|
+
* Close a page the engine will not drive, after leaving it (`leave`), so what it sends on its way out meets the policy.
|
|
3799
|
+
* Known limit: in Firefox, a close straight after the leave has let the page's leaving beacon out unjudged (seen 2 times
|
|
3800
|
+
* in 80 under load). replayFlow's hand-back closes pages only once every page is at about:blank; this and `close` do not yet.
|
|
3801
|
+
*/
|
|
3773
3802
|
static async leaveAndClose(page) {
|
|
3774
3803
|
await BrowserEngine.leave(page);
|
|
3775
3804
|
await page.close().catch(() => { });
|
package/dist/engine/check.js
CHANGED
|
@@ -630,8 +630,10 @@ function cell(text) {
|
|
|
630
630
|
return text
|
|
631
631
|
.replace(/\\/g, "\\\\")
|
|
632
632
|
.replace(/\|/g, "\\|")
|
|
633
|
-
.replace(/\s
|
|
633
|
+
.replace(/\s*[\r\n]\s*/g, " ");
|
|
634
634
|
}
|
|
635
|
+
/** The same escaping, for other markdown tables: the CI run's summary. */
|
|
636
|
+
export const markdownCell = cell;
|
|
635
637
|
/** A code span that the text cannot close: its fence is one backtick longer than any run of backticks inside it. */
|
|
636
638
|
function code(text) {
|
|
637
639
|
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|