scenescout 3.7.0 → 3.8.0
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 +6 -0
- package/dist/engine/browser.js +326 -15
- package/dist/engine/collector.js +53 -0
- package/dist/engine/policy.js +240 -2
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 09a1ff0: Snapshots list the page's frames (same- or cross-origin, title, size, hidden ones as a count) and say their contents were not explored, and a page whose content is all in frames is no longer called a dead end. A write that a cross-origin frame, such as an embedded third-party form, sends outside the app is refused in every mode except destructive, a cross-origin frame's document is sandboxed against popups and moving the whole page, and once an embed has moved the session's page to its own site, that page's writes to another site are refused unless they are a sign-in request. On the app's own sign-in pages (a last path segment such as `login` or `sign-in`) a captcha frame's writes still go out, outside observe.
|
|
8
|
+
|
|
3
9
|
## 3.7.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/dist/engine/browser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chromium, firefox, webkit } from "playwright";
|
|
1
|
+
import { chromium, firefox, webkit, } from "playwright";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
@@ -7,7 +7,7 @@ import { normalizeTask } from "./task.js";
|
|
|
7
7
|
import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
|
|
8
8
|
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
9
9
|
import { AuthLossTracker } from "./authloss.js";
|
|
10
|
-
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, displayName, missingName, } from "./collector.js";
|
|
10
|
+
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, displayName, missingName, } from "./collector.js";
|
|
11
11
|
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
12
12
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
13
13
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
@@ -20,7 +20,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
|
20
20
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
21
21
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
22
22
|
import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
|
|
23
|
-
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, isAuthExempt, } from "./policy.js";
|
|
23
|
+
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, } from "./policy.js";
|
|
24
24
|
import { scanProject } from "../scan.js";
|
|
25
25
|
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
26
26
|
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
@@ -52,6 +52,33 @@ const CHOOSER_GRACE_MS = 2000;
|
|
|
52
52
|
const HOVER_REVEAL_WINDOW_MS = 2500;
|
|
53
53
|
/** Non-GET traffic that is auth/telemetry plumbing, not tester-caused state mutation. */
|
|
54
54
|
const BENIGN_MUTATION_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/collect\b|\/logs?\b|\/metrics\b/i;
|
|
55
|
+
/**
|
|
56
|
+
* What the policy needs to know about where a request came from: the URLs of
|
|
57
|
+
* the frame that sent it and of its parents, up to but not including the top
|
|
58
|
+
* document, and that frame's own URL (null when the browser attributes it to no
|
|
59
|
+
* frame, as for a new window or a service worker).
|
|
60
|
+
*/
|
|
61
|
+
function requestSource(req) {
|
|
62
|
+
const frameChain = [];
|
|
63
|
+
let frame;
|
|
64
|
+
try {
|
|
65
|
+
frame = req.frame();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { frameChain, frameUrl: null };
|
|
69
|
+
}
|
|
70
|
+
const frameUrl = frame.url();
|
|
71
|
+
const top = frame.page().mainFrame();
|
|
72
|
+
while (frame && frame !== top) {
|
|
73
|
+
frameChain.push(frame.url());
|
|
74
|
+
frame = frame.parentFrame();
|
|
75
|
+
}
|
|
76
|
+
return { frameChain, frameUrl };
|
|
77
|
+
}
|
|
78
|
+
/** The `why` of a top-window navigation refused as a possible frame escape; the notice words it on its own. */
|
|
79
|
+
const ESCAPE_REFUSAL = "a possible frame escape";
|
|
80
|
+
/** How long a snapshot waits for its frames' elements to answer. */
|
|
81
|
+
const FRAME_READ_MS = 1500;
|
|
55
82
|
/** In-page XPath lookup fragment for string-expression evaluates. */
|
|
56
83
|
function xpathLookup(xpath) {
|
|
57
84
|
return `document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
|
|
@@ -616,6 +643,7 @@ export class BrowserEngine {
|
|
|
616
643
|
this.contradictionsReported = new Set();
|
|
617
644
|
this.pendingCreations = new Set();
|
|
618
645
|
this.baseUrl = opts.url.replace(/\/$/, "");
|
|
646
|
+
this.embedMoves = new EmbedMoveTracker(this.baseUrl);
|
|
619
647
|
// Ownership (ownedIds/createdResources) deliberately NOT reset here: it
|
|
620
648
|
// lives on the shared MemoryStore for the whole run, so re-attaching one
|
|
621
649
|
// role must not discard what another role already created — otherwise
|
|
@@ -688,6 +716,17 @@ export class BrowserEngine {
|
|
|
688
716
|
this.watchResponse(res.request(), res.status());
|
|
689
717
|
});
|
|
690
718
|
this.context.on("request", (req) => {
|
|
719
|
+
// Who moved the driven page, decided on its navigation's first request
|
|
720
|
+
// (this event fires before the route handler judges that page's writes).
|
|
721
|
+
if (req.isNavigationRequest() && !req.redirectedFrom()) {
|
|
722
|
+
try {
|
|
723
|
+
if (this.page && req.frame() === this.page.mainFrame())
|
|
724
|
+
this.embedMoves.navigationStarted(req.url(), req.headers()["referer"], this.embeddedSites());
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
/* no frame: not the driven page */
|
|
728
|
+
}
|
|
729
|
+
}
|
|
691
730
|
this.inFlight += 1;
|
|
692
731
|
this.lastRequestStart = Date.now();
|
|
693
732
|
const type = req.resourceType();
|
|
@@ -716,6 +755,13 @@ export class BrowserEngine {
|
|
|
716
755
|
return;
|
|
717
756
|
if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
|
|
718
757
|
return;
|
|
758
|
+
// A foreign frame's write is refused in every mode the route handler runs in.
|
|
759
|
+
if (this.mode !== "destructive" && this.foreignWriteOf(req))
|
|
760
|
+
return;
|
|
761
|
+
if (this.mode !== "destructive" &&
|
|
762
|
+
offAppPageWrite(this.baseUrl, this.page?.url(), req.url(), this.embedMoves.movedTo) &&
|
|
763
|
+
!isAuthExempt(this.mode, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
|
|
764
|
+
return;
|
|
719
765
|
const pageUrl = this.page?.url();
|
|
720
766
|
if (pageUrl && this.memory) {
|
|
721
767
|
try {
|
|
@@ -731,12 +777,111 @@ export class BrowserEngine {
|
|
|
731
777
|
await this.context.route("**/*", async (route) => {
|
|
732
778
|
const req = route.request();
|
|
733
779
|
const method = req.method();
|
|
780
|
+
// A redirect is followed by the browser without asking, so its target
|
|
781
|
+
// would load unsandboxed: a frame's redirect is answered with a
|
|
782
|
+
// sandboxed page that navigates there itself, and the next hop comes
|
|
783
|
+
// back here. Built from scratch: a redirect's framing or script
|
|
784
|
+
// headers never applied to a redirect, and would block this page.
|
|
785
|
+
const standIn = async (location, setCookie) => {
|
|
786
|
+
const headers = {
|
|
787
|
+
"content-type": "text/html; charset=utf-8",
|
|
788
|
+
"content-security-policy": withForeignFrameSandbox(undefined),
|
|
789
|
+
"cache-control": "no-store",
|
|
790
|
+
};
|
|
791
|
+
if (setCookie)
|
|
792
|
+
headers["set-cookie"] = setCookie;
|
|
793
|
+
await route.fulfill({ status: 200, headers, body: sandboxedRedirectPage(new URL(location, req.url()).href) });
|
|
794
|
+
};
|
|
795
|
+
// WebKit drops the sandbox for a frame that loads a data: URL in its own
|
|
796
|
+
// place. A top-window navigation out of the app, with no Referer, while
|
|
797
|
+
// a frame that held another site sits on a non-web URL, is that frame's
|
|
798
|
+
// escape: refused, judged on the page as it is when the request arrives.
|
|
799
|
+
if (method === "GET" && req.resourceType() === "document" && this.embedEscapeNavigation(req)) {
|
|
800
|
+
const why = ESCAPE_REFUSAL;
|
|
801
|
+
// Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
|
|
802
|
+
if (this.blockedRequests.length < 20)
|
|
803
|
+
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why });
|
|
804
|
+
this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
|
|
805
|
+
this.refusedByPolicy.add(req);
|
|
806
|
+
this.oracles.notePolicyBlock();
|
|
807
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const frameDoc = method === "GET" && req.resourceType() === "document" ? this.frameDocumentKind(req) : null;
|
|
811
|
+
// A document loading into a frame of another origin gets the sandbox
|
|
812
|
+
// that forbids popups and top-window navigation (policy.ts says why).
|
|
813
|
+
if (frameDoc === "foreign") {
|
|
814
|
+
try {
|
|
815
|
+
const res = await route.fetch({ maxRedirects: 0 });
|
|
816
|
+
const headers = res.headers();
|
|
817
|
+
const location = res.status() >= 300 && res.status() < 400 ? headers["location"] : undefined;
|
|
818
|
+
if (location) {
|
|
819
|
+
await standIn(location, headers["set-cookie"]);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
headers["content-security-policy"] = withForeignFrameSandbox(headers["content-security-policy"]);
|
|
823
|
+
await route.fulfill({ response: res, headers });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
// Fail closed: a frame that cannot be sandboxed is not loaded.
|
|
828
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// The app's own frame document can redirect into another site, whose
|
|
833
|
+
// page would then load unsandboxed. Asked first, one hop at a time; a
|
|
834
|
+
// document that does not redirect loads as it would have, sent on
|
|
835
|
+
// rather than served from here — a served document counts as public in
|
|
836
|
+
// Chromium and could no longer reach an app on localhost. The price is
|
|
837
|
+
// a second GET of the app's frame documents that are not redirects.
|
|
838
|
+
if (frameDoc === "app") {
|
|
839
|
+
try {
|
|
840
|
+
const res = await route.fetch({ maxRedirects: 0 });
|
|
841
|
+
const location = res.status() >= 300 && res.status() < 400 ? res.headers()["location"] : undefined;
|
|
842
|
+
// Every hop, not only one out of the app: Playwright does not route the
|
|
843
|
+
// later hops of a redirect, so a chain through the app and then out
|
|
844
|
+
// would go unseen, and each hop is then fetched once, not twice.
|
|
845
|
+
if (location) {
|
|
846
|
+
await standIn(location, res.headers()["set-cookie"]);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
/* could not ask: load it as it would have loaded */
|
|
852
|
+
}
|
|
853
|
+
await route.continue().catch(() => { });
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
734
856
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
735
857
|
return route.continue();
|
|
736
858
|
const url = req.url();
|
|
737
859
|
const pathname = pathnameOf(url);
|
|
860
|
+
const refuse = (why) => {
|
|
861
|
+
const answered = answersWithRefusal(req.resourceType());
|
|
862
|
+
if (this.blockedRequests.length < 20)
|
|
863
|
+
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why });
|
|
864
|
+
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
|
|
865
|
+
this.refusedByPolicy.add(req);
|
|
866
|
+
this.oracles.notePolicyBlock();
|
|
867
|
+
// A script's request is answered with a refusal, so the page's handling
|
|
868
|
+
// of one actually runs; a navigation is dropped (policy.ts says why).
|
|
869
|
+
// Caught: a request the page has already cancelled rejects these, and an unhandled rejection ends the process.
|
|
870
|
+
if (answered)
|
|
871
|
+
return route.fulfill(policyRefusal(this.mode, method, pathname, req.headers()["origin"], why)).catch(() => { });
|
|
872
|
+
return route.abort("blockedbyclient").catch(() => { });
|
|
873
|
+
};
|
|
874
|
+
// An embedded widget from another site writes to that site, not to the
|
|
875
|
+
// app under test: refused before any other rule, login included.
|
|
876
|
+
const foreign = this.foreignWriteOf(req);
|
|
877
|
+
if (foreign)
|
|
878
|
+
return refuse(`sent from a frame of ${foreign}`);
|
|
738
879
|
this.rememberAuthHeader(req.headers());
|
|
739
880
|
const destructiveWire = isDestructiveWire(pathname, req.postData());
|
|
881
|
+
// An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
|
|
882
|
+
const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
|
|
883
|
+
if (offApp && !isAuthExempt(this.mode, method, pathname, destructiveWire))
|
|
884
|
+
return refuse(`sent from a page of ${offApp}, outside the app`);
|
|
740
885
|
// Auth/session flows must work in every mode — but never a destructive
|
|
741
886
|
// one, and in observe only the requests a login itself needs.
|
|
742
887
|
if (isAuthExempt(this.mode, method, pathname, destructiveWire))
|
|
@@ -776,17 +921,7 @@ export class BrowserEngine {
|
|
|
776
921
|
}
|
|
777
922
|
return route.continue();
|
|
778
923
|
}
|
|
779
|
-
|
|
780
|
-
if (this.blockedRequests.length < 20)
|
|
781
|
-
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered });
|
|
782
|
-
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}`, url: this.page?.url() ?? "" });
|
|
783
|
-
this.refusedByPolicy.add(req);
|
|
784
|
-
this.oracles.notePolicyBlock();
|
|
785
|
-
// A script's request is answered with a refusal, so the page's handling
|
|
786
|
-
// of one actually runs; a navigation is dropped (policy.ts says why).
|
|
787
|
-
if (answered)
|
|
788
|
-
return route.fulfill(policyRefusal(this.mode, method, pathname, req.headers()["origin"]));
|
|
789
|
-
return route.abort("blockedbyclient");
|
|
924
|
+
return refuse();
|
|
790
925
|
});
|
|
791
926
|
}
|
|
792
927
|
// Popups / target=_blank: adopt same-origin pages as the active page (with
|
|
@@ -807,6 +942,7 @@ export class BrowserEngine {
|
|
|
807
942
|
if (sameOrigin) {
|
|
808
943
|
this.oracles.attach(newPage);
|
|
809
944
|
this.wireDialogHandler(newPage);
|
|
945
|
+
this.wireEmbedMoves(newPage);
|
|
810
946
|
this.page = newPage;
|
|
811
947
|
this.refs.clear();
|
|
812
948
|
this.snapshotUrl = "";
|
|
@@ -819,6 +955,7 @@ export class BrowserEngine {
|
|
|
819
955
|
.catch(() => { });
|
|
820
956
|
});
|
|
821
957
|
this.wireDialogHandler(this.page);
|
|
958
|
+
this.wireEmbedMoves(this.page);
|
|
822
959
|
try {
|
|
823
960
|
await this.page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
824
961
|
}
|
|
@@ -862,6 +999,48 @@ export class BrowserEngine {
|
|
|
862
999
|
`${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""} Call scout_snapshot to see the current state.` +
|
|
863
1000
|
authWarning);
|
|
864
1001
|
}
|
|
1002
|
+
/** Whether the page was moved off the app by one of its embeds (policy.ts EmbedMoveTracker). */
|
|
1003
|
+
embedMoves = new EmbedMoveTracker("");
|
|
1004
|
+
/**
|
|
1005
|
+
* Frames that have held a document of another origin, with the first such
|
|
1006
|
+
* origin. A frame that has since moved itself to a data: URL is still that
|
|
1007
|
+
* site's; it leaves the record when it leaves the page, as every frame of a
|
|
1008
|
+
* replaced document does, so nothing has to be cleared at the right moment.
|
|
1009
|
+
* Sticky on purpose: a frame that went back to the app (a silent-renew frame
|
|
1010
|
+
* landing on the app's callback) keeps counting as an embed, which refuses
|
|
1011
|
+
* more, never less.
|
|
1012
|
+
*/
|
|
1013
|
+
foreignFrames = new WeakMap();
|
|
1014
|
+
/** The other sites the driven page embeds right now, by the frames still attached to it. */
|
|
1015
|
+
embeddedSites() {
|
|
1016
|
+
const out = new Set();
|
|
1017
|
+
const page = this.page;
|
|
1018
|
+
if (!page)
|
|
1019
|
+
return out;
|
|
1020
|
+
const top = page.mainFrame();
|
|
1021
|
+
for (const frame of page.frames()) {
|
|
1022
|
+
if (frame === top)
|
|
1023
|
+
continue;
|
|
1024
|
+
const origin = this.foreignFrames.get(frame) ?? foreignFrameOrigin(this.baseUrl, [frame.url()]);
|
|
1025
|
+
if (origin)
|
|
1026
|
+
out.add(origin);
|
|
1027
|
+
}
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
/** Feed the page's navigations to the embed-move tracker and the foreign-frame record. Wired on every page we drive, like the dialog handler. */
|
|
1031
|
+
wireEmbedMoves(page) {
|
|
1032
|
+
page.on("framenavigated", (frame) => {
|
|
1033
|
+
if (page !== this.page)
|
|
1034
|
+
return;
|
|
1035
|
+
if (frame === page.mainFrame()) {
|
|
1036
|
+
this.embedMoves.pageLoaded(frame.url());
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const origin = foreignFrameOrigin(this.baseUrl, [frame.url()]);
|
|
1040
|
+
if (origin && !this.foreignFrames.has(frame))
|
|
1041
|
+
this.foreignFrames.set(frame, origin);
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
865
1044
|
/** Dialogs (confirm/alert): dismiss in read-only mode, accept otherwise. Must be wired on every page we drive, including adopted popups. */
|
|
866
1045
|
wireDialogHandler(page) {
|
|
867
1046
|
page.on("dialog", (dialog) => {
|
|
@@ -1159,6 +1338,7 @@ export class BrowserEngine {
|
|
|
1159
1338
|
geometry.push(...(await probeOverlays(page)));
|
|
1160
1339
|
const hiddenFileInputs = await this.hiddenFileInputs(page);
|
|
1161
1340
|
const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
|
|
1341
|
+
const { frames, nested: nestedFrames } = await this.frameInventory(page);
|
|
1162
1342
|
const cov = memory.coverage();
|
|
1163
1343
|
const unvisited = this.unvisitedKnownRoutes();
|
|
1164
1344
|
const title = await page.title();
|
|
@@ -1169,13 +1349,20 @@ export class BrowserEngine {
|
|
|
1169
1349
|
body +
|
|
1170
1350
|
(geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
|
|
1171
1351
|
(brokenImages.length > 0 ? `\nBROKEN IMAGES:\n` + brokenImages.map((b) => ` ⚠ ${b}`).join("\n") : "") +
|
|
1352
|
+
(frames.length > 0 || nestedFrames > 0
|
|
1353
|
+
? `\n` + frameLines(this.baseUrl, frames, { nested: nestedFrames, writesRefused: this.mode !== "destructive" }).join("\n")
|
|
1354
|
+
: "") +
|
|
1172
1355
|
(hiddenFileInputs.length > 0
|
|
1173
1356
|
? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
|
|
1174
1357
|
`scout_upload {ref} on the control that opens one, or scout_upload {} when it is the page's only file input.`
|
|
1175
1358
|
: "") +
|
|
1176
1359
|
this.socketNotice() +
|
|
1177
1360
|
formatViolations(this.oracles.drain()) +
|
|
1178
|
-
(elements.length === 0
|
|
1361
|
+
(elements.length === 0
|
|
1362
|
+
? hasVisibleFrame(frames)
|
|
1363
|
+
? "\n⚠ No interactable elements in the page itself: what it shows is inside the frames listed above, which were not explored."
|
|
1364
|
+
: "\n⚠ DEAD END: no interactable elements found on this page."
|
|
1365
|
+
: ""));
|
|
1179
1366
|
}
|
|
1180
1367
|
/**
|
|
1181
1368
|
* Resolve a ref and re-verify the live element at action time. Refs are
|
|
@@ -1314,6 +1501,121 @@ export class BrowserEngine {
|
|
|
1314
1501
|
lateMark(entry) {
|
|
1315
1502
|
return entry.at < this.actionStartedAt ? `${entry.sig} (late — likely from a previous action)` : entry.sig;
|
|
1316
1503
|
}
|
|
1504
|
+
/**
|
|
1505
|
+
* The frames directly under the page, read from their <iframe> elements in
|
|
1506
|
+
* one pass each, all at once. Each read gets FRAME_READ_MS: a frame that
|
|
1507
|
+
* cannot answer in time (a busy ad loop in its own process) is left out on
|
|
1508
|
+
* its own rather than stalling the snapshot or dropping the others. Frames
|
|
1509
|
+
* inside frames, and direct frames past the first 30, are only counted.
|
|
1510
|
+
*/
|
|
1511
|
+
async frameInventory(page) {
|
|
1512
|
+
const top = page.mainFrame();
|
|
1513
|
+
const all = page.frames().filter((f) => f !== top);
|
|
1514
|
+
const direct = all.filter((f) => f.parentFrame() === top);
|
|
1515
|
+
const read = async (frame) => {
|
|
1516
|
+
let timer;
|
|
1517
|
+
const box = await Promise.race([
|
|
1518
|
+
frame
|
|
1519
|
+
.frameElement()
|
|
1520
|
+
.then((el) => el.evaluate((node) => {
|
|
1521
|
+
const r = node.getBoundingClientRect();
|
|
1522
|
+
return {
|
|
1523
|
+
title: (node.getAttribute("title") || node.getAttribute("name") || "").trim(),
|
|
1524
|
+
width: Math.round(r.width),
|
|
1525
|
+
height: Math.round(r.height),
|
|
1526
|
+
};
|
|
1527
|
+
}))
|
|
1528
|
+
.catch(() => null),
|
|
1529
|
+
new Promise((resolve) => {
|
|
1530
|
+
timer = setTimeout(() => resolve(null), FRAME_READ_MS);
|
|
1531
|
+
}),
|
|
1532
|
+
]).finally(() => clearTimeout(timer));
|
|
1533
|
+
return box ? { url: frame.url(), ...box, foreign: foreignFrameOrigin(this.baseUrl, [frame.url()]) !== null } : null;
|
|
1534
|
+
};
|
|
1535
|
+
const frames = (await Promise.all(direct.slice(0, 30).map(read))).filter((f) => f !== null);
|
|
1536
|
+
return { frames, nested: all.length - Math.min(direct.length, 30) };
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Whether a document request is the top window leaving the app, with no
|
|
1540
|
+
* Referer, while a frame that held another site's document now sits on a
|
|
1541
|
+
* data: or blob: URL — where WebKit no longer applies its sandbox.
|
|
1542
|
+
*/
|
|
1543
|
+
embedEscapeNavigation(req) {
|
|
1544
|
+
const page = this.page;
|
|
1545
|
+
if (!page || !req.isNavigationRequest() || req.redirectedFrom())
|
|
1546
|
+
return false;
|
|
1547
|
+
try {
|
|
1548
|
+
if (req.frame() !== page.mainFrame())
|
|
1549
|
+
return false;
|
|
1550
|
+
}
|
|
1551
|
+
catch {
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
if (req.headers()["referer"])
|
|
1555
|
+
return false;
|
|
1556
|
+
if (foreignFrameOrigin(this.baseUrl, [req.url()]) === null)
|
|
1557
|
+
return false;
|
|
1558
|
+
const top = page.mainFrame();
|
|
1559
|
+
// data: and blob: only: where WebKit drops the sandbox. A frame the app set back to about:blank is not an escape.
|
|
1560
|
+
return page.frames().some((f) => f !== top && this.foreignFrames.has(f) && /^(data|blob):/i.test(f.url()));
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* What a document request loads into: a frame (not the top window) of
|
|
1564
|
+
* another origin than the app's, a frame of the app's own origin, or
|
|
1565
|
+
* neither (null). On the app's own sign-in pages frames are left alone: a
|
|
1566
|
+
* "sign in with" button is a foreign frame that has to open its popup.
|
|
1567
|
+
*/
|
|
1568
|
+
frameDocumentKind(req) {
|
|
1569
|
+
let frame;
|
|
1570
|
+
try {
|
|
1571
|
+
frame = req.frame();
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
if (frame === frame.page().mainFrame())
|
|
1577
|
+
return null;
|
|
1578
|
+
if (allowsForeignWriteOnSignIn(this.mode, this.page?.url() ?? "", this.baseUrl))
|
|
1579
|
+
return null;
|
|
1580
|
+
let url;
|
|
1581
|
+
try {
|
|
1582
|
+
url = new URL(req.url());
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
return null;
|
|
1586
|
+
}
|
|
1587
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
1588
|
+
return null;
|
|
1589
|
+
return foreignFrameOrigin(this.baseUrl, [req.url()]) ? "foreign" : "app";
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* The foreign origin behind a write headed outside the app (policy.ts
|
|
1593
|
+
* foreignWrite), or null — also null on the app's own sign-in page, where a
|
|
1594
|
+
* captcha frame's writes must go out for a login to work.
|
|
1595
|
+
*/
|
|
1596
|
+
foreignWriteOf(req) {
|
|
1597
|
+
let unadoptedPageUrl = null;
|
|
1598
|
+
try {
|
|
1599
|
+
const from = req.frame().page();
|
|
1600
|
+
if (this.page && from !== this.page)
|
|
1601
|
+
unadoptedPageUrl = from.url();
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
/* no frame: a new window's first request, or a service worker */
|
|
1605
|
+
}
|
|
1606
|
+
// Frames still attached that hold, or held, another site: one that moved itself to data: is no longer foreign by its URL.
|
|
1607
|
+
const pageHasForeignFrame = this.embeddedSites().size > 0;
|
|
1608
|
+
const foreign = foreignWrite(this.baseUrl, {
|
|
1609
|
+
url: req.url(),
|
|
1610
|
+
originHeader: req.headers()["origin"],
|
|
1611
|
+
unadoptedPageUrl,
|
|
1612
|
+
pageHasForeignFrame,
|
|
1613
|
+
...requestSource(req),
|
|
1614
|
+
});
|
|
1615
|
+
if (foreign && allowsForeignWriteOnSignIn(this.mode, this.page?.url() ?? "", this.baseUrl))
|
|
1616
|
+
return null;
|
|
1617
|
+
return foreign;
|
|
1618
|
+
}
|
|
1317
1619
|
/** Report (and clear) write-policy blocks since the last action. */
|
|
1318
1620
|
drainBlocked() {
|
|
1319
1621
|
this.lastActionBlocked = this.blockedRequests.length;
|
|
@@ -1325,9 +1627,18 @@ export class BrowserEngine {
|
|
|
1325
1627
|
.join("; ");
|
|
1326
1628
|
const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
|
|
1327
1629
|
const answered = this.blockedRequests.some((e) => e.answered);
|
|
1630
|
+
const reasons = new Set(this.blockedRequests.map((e) => e.why).filter((w) => !!w));
|
|
1631
|
+
const escaped = reasons.delete(ESCAPE_REFUSAL);
|
|
1632
|
+
const foreign = [...reasons];
|
|
1328
1633
|
this.blockedRequests = [];
|
|
1329
1634
|
return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
|
|
1330
1635
|
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
1636
|
+
(foreign.length > 0
|
|
1637
|
+
? `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. `
|
|
1638
|
+
: "") +
|
|
1639
|
+
(escaped
|
|
1640
|
+
? `A move of the whole page off the app, with no Referer, was refused: a frame that held another site now sits on a data: or blob: URL, where WebKit drops the frame's sandbox, so the move may be that frame's. No mode but destructive allows it. `
|
|
1641
|
+
: "") +
|
|
1331
1642
|
(answered
|
|
1332
1643
|
? `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. `
|
|
1333
1644
|
: "") +
|
package/dist/engine/collector.js
CHANGED
|
@@ -402,6 +402,59 @@ export const BROKEN_IMAGES_SCRIPT = `(() => {
|
|
|
402
402
|
}
|
|
403
403
|
return { images, total };
|
|
404
404
|
})()`;
|
|
405
|
+
/** A frame smaller than this in both directions is plumbing (a tracking pixel, a messaging bridge), not something a user sees. */
|
|
406
|
+
const VISIBLE_FRAME_PX = 2;
|
|
407
|
+
/**
|
|
408
|
+
* The snapshot's account of the page's frames. Nothing inside a frame is
|
|
409
|
+
* collected or can be acted on yet, and a page that shows its form in an
|
|
410
|
+
* embed used to look like a page with no form at all: say what is there,
|
|
411
|
+
* where it comes from, and that it was not looked inside. `nested` counts
|
|
412
|
+
* frames that are not read: nested inside others, or past the first 30; `writesRefused` is false only in
|
|
413
|
+
* destructive mode, where a foreign frame's writes do go out.
|
|
414
|
+
*/
|
|
415
|
+
export function frameLines(appUrl, frames, opts = {}) {
|
|
416
|
+
const visible = frames.filter((f) => f.width >= VISIBLE_FRAME_PX && f.height >= VISIBLE_FRAME_PX);
|
|
417
|
+
const hidden = frames.length - visible.length;
|
|
418
|
+
const nested = opts.nested ?? 0;
|
|
419
|
+
if (frames.length === 0 && nested === 0)
|
|
420
|
+
return [];
|
|
421
|
+
let app = "";
|
|
422
|
+
try {
|
|
423
|
+
app = new URL(appUrl).origin;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
/* no origin: paths are shown in full */
|
|
427
|
+
}
|
|
428
|
+
const lines = visible.slice(0, 10).map((f) => {
|
|
429
|
+
let where = f.url || "(no address)";
|
|
430
|
+
try {
|
|
431
|
+
const u = new URL(f.url);
|
|
432
|
+
if (u.origin === app)
|
|
433
|
+
where = u.pathname + u.search;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
/* about:blank, srcdoc: shown as they are */
|
|
437
|
+
}
|
|
438
|
+
const label = f.title ? ` "${f.title.slice(0, 60)}"` : "";
|
|
439
|
+
const writes = f.foreign
|
|
440
|
+
? opts.writesRefused === false
|
|
441
|
+
? " — its writes go out (destructive mode)"
|
|
442
|
+
: " — writes it sends outside the app are refused"
|
|
443
|
+
: "";
|
|
444
|
+
return ` ${f.foreign ? "cross-origin" : "same-origin"} ${where.slice(0, 120)}${label} ${f.width}×${f.height}${writes}`;
|
|
445
|
+
});
|
|
446
|
+
if (visible.length > 10)
|
|
447
|
+
lines.push(` … +${visible.length - 10} more`);
|
|
448
|
+
if (hidden > 0)
|
|
449
|
+
lines.push(` (+${hidden} hidden frame${hidden === 1 ? "" : "s"})`);
|
|
450
|
+
if (nested > 0)
|
|
451
|
+
lines.push(` (+${nested} more frame${nested === 1 ? "" : "s"}, nested inside those or past the first 30, not read)`);
|
|
452
|
+
return [`FRAMES not explored — their controls are not listed above and cannot be acted on:`, ...lines];
|
|
453
|
+
}
|
|
454
|
+
/** Whether any frame on the page is one a user can see. */
|
|
455
|
+
export function hasVisibleFrame(frames) {
|
|
456
|
+
return frames.some((f) => f.width >= VISIBLE_FRAME_PX && f.height >= VISIBLE_FRAME_PX);
|
|
457
|
+
}
|
|
405
458
|
/** Snapshot lines for images that failed to load. The origin is dropped when it is the page's own, to keep the line short. */
|
|
406
459
|
export function brokenImageIssues(scan, pageUrl) {
|
|
407
460
|
let origin = "";
|
package/dist/engine/policy.js
CHANGED
|
@@ -174,6 +174,244 @@ export function isAuthExempt(mode, method, pathname, destructiveWire) {
|
|
|
174
174
|
return (segments.slice(-2).some((seg) => OBSERVE_AUTH_SEGMENT_RE.test(seg)) &&
|
|
175
175
|
!/^(users?|accounts?|members?|password|signup|sign-up|register|verify|invite|invitations?)$/i.test(last));
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The origin of a request's frame when that frame belongs to another site than
|
|
179
|
+
* the app: an embedded widget, such as a form, chat or payment box served by a
|
|
180
|
+
* third party. A write from one reaches that third party, not the app under
|
|
181
|
+
* test, so no mode short of destructive lets it out.
|
|
182
|
+
*
|
|
183
|
+
* `frameChain` lists the URLs of the frame that issued the request and each of
|
|
184
|
+
* its parents, stopping before the top document. A frame with no address of
|
|
185
|
+
* its own (about:blank, srcdoc) belongs to whoever created it, so it is skipped
|
|
186
|
+
* and its parent decides. Any foreign frame in the chain makes the request
|
|
187
|
+
* foreign: an app page nested inside a widget is still being driven by it.
|
|
188
|
+
* Null for the top document, same-origin frames, and requests with no frame.
|
|
189
|
+
*/
|
|
190
|
+
export function foreignFrameOrigin(appUrl, frameChain) {
|
|
191
|
+
let app;
|
|
192
|
+
try {
|
|
193
|
+
app = new URL(appUrl).origin;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
for (const url of frameChain) {
|
|
199
|
+
let frame;
|
|
200
|
+
try {
|
|
201
|
+
frame = new URL(url);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (frame.protocol !== "http:" && frame.protocol !== "https:")
|
|
207
|
+
continue;
|
|
208
|
+
if (frame.origin !== app)
|
|
209
|
+
return frame.origin;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The origin to name when a write started by another site is headed outside
|
|
215
|
+
* the app, or null when the write is the app's own or lands in the app.
|
|
216
|
+
*
|
|
217
|
+
* The source is foreign when the frame that sent it (or a parent) is of
|
|
218
|
+
* another origin, or when the request's Origin header names another origin
|
|
219
|
+
* than both the app and the frame it is attributed to. The second catches a
|
|
220
|
+
* foreign frame's form aimed at `_top` or `_blank`, and a popup it opens: the
|
|
221
|
+
* browser reports those against the top page or no frame at all, but the
|
|
222
|
+
* Origin header still names the frame's site. A sign-in page loaded as the
|
|
223
|
+
* whole page is not caught by it, since there the header and the page agree.
|
|
224
|
+
*
|
|
225
|
+
* A foreign write whose destination is the app itself — a sign-in provider's
|
|
226
|
+
* frame posting its reply back to the app's callback — is the app's business
|
|
227
|
+
* and is left to the ordinary rules.
|
|
228
|
+
*/
|
|
229
|
+
export function foreignWrite(appUrl, req) {
|
|
230
|
+
let app;
|
|
231
|
+
try {
|
|
232
|
+
app = new URL(appUrl).origin;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
const originOf = (url) => {
|
|
238
|
+
if (!url)
|
|
239
|
+
return null;
|
|
240
|
+
try {
|
|
241
|
+
const u = new URL(url);
|
|
242
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
if (originOf(req.url) === app)
|
|
249
|
+
return null;
|
|
250
|
+
const fromFrame = foreignFrameOrigin(appUrl, req.frameChain);
|
|
251
|
+
if (fromFrame)
|
|
252
|
+
return fromFrame;
|
|
253
|
+
const header = originOf(req.originHeader);
|
|
254
|
+
if (header && header !== app && header !== originOf(req.frameUrl))
|
|
255
|
+
return header;
|
|
256
|
+
// A popup a foreign frame opened on its own site posts from its own script
|
|
257
|
+
// before it can be closed, and there the header and the page agree. The
|
|
258
|
+
// session never drives a page it did not adopt, so its writes out are not
|
|
259
|
+
// the app's.
|
|
260
|
+
if (req.unadoptedPageUrl !== undefined && req.unadoptedPageUrl !== null)
|
|
261
|
+
return originOf(req.unadoptedPageUrl) ?? "a page the session did not open";
|
|
262
|
+
// A frame with a no-referrer policy sends "Origin: null". Out of the app,
|
|
263
|
+
// on a page that embeds another site, that is taken to be the embed.
|
|
264
|
+
if (req.originHeader === "null" && req.pageHasForeignFrame)
|
|
265
|
+
return "an embedded frame (Origin: null)";
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Whether the session's page was moved off the app by one of its embeds. The
|
|
270
|
+
* sandbox forbids a frame to move the page, but WebKit drops it for a frame
|
|
271
|
+
* that loads a `data:` URL in its own place, and a Chromium service worker can
|
|
272
|
+
* serve a frame's document unseen.
|
|
273
|
+
*
|
|
274
|
+
* Decided on the navigation's first request, given the other sites the page
|
|
275
|
+
* embeds at that moment (the engine asks the frames still attached, so a route
|
|
276
|
+
* change, a 204 or a download changes nothing). A move from a page with no
|
|
277
|
+
* embeds, or one carrying the app as its Referer — a click on the app's page —
|
|
278
|
+
* is the tester's: a hosted sign-in page, even one the app also embeds for
|
|
279
|
+
* silent sign-in, keeps the ordinary rules. With another site's Referer, it is
|
|
280
|
+
* an embed's. With no Referer at all (an app that sends none, or a frame that
|
|
281
|
+
* hides its origin) it is an embed's only when it goes to one of the embedded
|
|
282
|
+
* sites.
|
|
283
|
+
*/
|
|
284
|
+
export class EmbedMoveTracker {
|
|
285
|
+
appUrl;
|
|
286
|
+
pending = null;
|
|
287
|
+
/** The origin the page was moved to by an embed, while it stays there. */
|
|
288
|
+
movedTo = null;
|
|
289
|
+
constructor(appUrl) {
|
|
290
|
+
this.appUrl = appUrl;
|
|
291
|
+
}
|
|
292
|
+
/** The top window's navigation to `url` sent its first request, with this Referer, from a page embedding these other sites. */
|
|
293
|
+
navigationStarted(url, referer, embedded) {
|
|
294
|
+
const target = foreignFrameOrigin(this.appUrl, [url]);
|
|
295
|
+
if (!target || embedded.size === 0) {
|
|
296
|
+
this.pending = null;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const refererIsHttp = !!referer && /^https?:/i.test(referer);
|
|
300
|
+
if (refererIsHttp && foreignFrameOrigin(this.appUrl, [referer]) === null)
|
|
301
|
+
this.pending = null;
|
|
302
|
+
else if (refererIsHttp)
|
|
303
|
+
this.pending = target;
|
|
304
|
+
else
|
|
305
|
+
this.pending = embedded.has(target) ? target : null;
|
|
306
|
+
}
|
|
307
|
+
/** The top window now shows `url`: a new document, or a same-document route change. */
|
|
308
|
+
pageLoaded(url) {
|
|
309
|
+
let origin;
|
|
310
|
+
try {
|
|
311
|
+
const u = new URL(url);
|
|
312
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
313
|
+
return;
|
|
314
|
+
origin = u.origin;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (foreignFrameOrigin(this.appUrl, [url]) === null) {
|
|
320
|
+
this.movedTo = null;
|
|
321
|
+
this.pending = null;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (this.pending === origin)
|
|
325
|
+
this.movedTo = origin;
|
|
326
|
+
else if (this.movedTo !== origin)
|
|
327
|
+
this.movedTo = null;
|
|
328
|
+
this.pending = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The origin to name when the session's page was moved off the app by one of
|
|
333
|
+
* its embeds (EmbedMoveTracker) and writes to another site from there, or
|
|
334
|
+
* null. Refused unless it is a sign-in request.
|
|
335
|
+
*/
|
|
336
|
+
export function offAppPageWrite(appUrl, pageUrl, destinationUrl, movedByEmbed) {
|
|
337
|
+
if (!movedByEmbed)
|
|
338
|
+
return null;
|
|
339
|
+
const originOf = (url) => {
|
|
340
|
+
if (!url)
|
|
341
|
+
return null;
|
|
342
|
+
try {
|
|
343
|
+
const u = new URL(url);
|
|
344
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const app = originOf(appUrl);
|
|
351
|
+
const page = originOf(pageUrl);
|
|
352
|
+
if (!app || !page || page === app || page !== movedByEmbed)
|
|
353
|
+
return null;
|
|
354
|
+
if (originOf(destinationUrl) === app)
|
|
355
|
+
return null;
|
|
356
|
+
return page;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The page a sandboxed frame is given in place of a redirect: it navigates to
|
|
360
|
+
* the redirect's target itself, so the next hop is a navigation the policy
|
|
361
|
+
* routes and sandboxes again. A redirect answered as a redirect is followed by
|
|
362
|
+
* the browser without asking, and the page it lands on was not sandboxed.
|
|
363
|
+
*/
|
|
364
|
+
export function sandboxedRedirectPage(target) {
|
|
365
|
+
const json = JSON.stringify(target).replace(/</g, "\\u003c");
|
|
366
|
+
// No referrer: the next hop would otherwise name this stand-in page, where a real redirect names the app.
|
|
367
|
+
return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><script>location.replace(${json});</script>`;
|
|
368
|
+
}
|
|
369
|
+
/** The last path segment of a page that is a sign-in page, and nothing else: not a verification step, where a payment provider's frame sits. */
|
|
370
|
+
const SIGN_IN_SEGMENT_RE = /^(login|log-in|signin|sign-in|signup|sign-up|sso|oauth)$/i;
|
|
371
|
+
/**
|
|
372
|
+
* Whether a foreign frame's writes out may go on this page after all: a
|
|
373
|
+
* captcha on the app's own sign-in page is a cross-origin frame that posts to
|
|
374
|
+
* its own site, and refusing it would make every login fail. Only on the app's
|
|
375
|
+
* own origin, only when the page's last path segment is a sign-in word (a
|
|
376
|
+
* trailing file extension ignored, `_` read as `-`) — not "auth", which is
|
|
377
|
+
* also the last step of a card payment's verification — and not in observe, where only the login
|
|
378
|
+
* request itself goes out.
|
|
379
|
+
*/
|
|
380
|
+
export function allowsForeignWriteOnSignIn(mode, topPageUrl, appUrl) {
|
|
381
|
+
if (mode === "observe" || mode === "destructive")
|
|
382
|
+
return false;
|
|
383
|
+
let page;
|
|
384
|
+
try {
|
|
385
|
+
page = new URL(topPageUrl);
|
|
386
|
+
if (page.origin !== new URL(appUrl).origin)
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
const segments = page.pathname.split("/").filter(Boolean);
|
|
393
|
+
// "sign_in" is "sign-in": underscores are how some frameworks spell it.
|
|
394
|
+
const last = (segments[segments.length - 1] ?? "").replace(/\.[a-z0-9]+$/i, "").replace(/_/g, "-");
|
|
395
|
+
return SIGN_IN_SEGMENT_RE.test(last);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The sandbox given to every document a frame of another origin loads, outside
|
|
399
|
+
* destructive mode: scripts, forms and its own origin keep working, and no
|
|
400
|
+
* popups or top-window navigation are allowed. A browser applies it to every
|
|
401
|
+
* realm the document makes, nested frames and blank ones included, which a
|
|
402
|
+
* script patch cannot reach: in Firefox a detached link's click, a
|
|
403
|
+
* `<base target>` or a borrowed `window.open` each opened a popup whose first
|
|
404
|
+
* requests never reached the policy.
|
|
405
|
+
*/
|
|
406
|
+
export const FOREIGN_FRAME_SANDBOX = "sandbox allow-scripts allow-forms allow-same-origin";
|
|
407
|
+
/**
|
|
408
|
+
* A response's Content-Security-Policy with the foreign-frame sandbox added. A
|
|
409
|
+
* second policy joined with a comma is enforced alongside the first, so the
|
|
410
|
+
* document's own policy still holds.
|
|
411
|
+
*/
|
|
412
|
+
export function withForeignFrameSandbox(existing) {
|
|
413
|
+
return existing && existing.trim() ? `${existing}, ${FOREIGN_FRAME_SANDBOX}` : FOREIGN_FRAME_SANDBOX;
|
|
414
|
+
}
|
|
177
415
|
export function allowsWrite(mode, method, destructiveWire, owned) {
|
|
178
416
|
if (mode === "destructive")
|
|
179
417
|
return true;
|
|
@@ -209,7 +447,7 @@ export const POLICY_REFUSAL_HEADER = "x-scenescout-policy";
|
|
|
209
447
|
* drop it all over again. `origin` is the request's own Origin header, echoed
|
|
210
448
|
* only when there is one.
|
|
211
449
|
*/
|
|
212
|
-
export function policyRefusal(mode, method, pathname, origin) {
|
|
450
|
+
export function policyRefusal(mode, method, pathname, origin, why) {
|
|
213
451
|
const headers = { "content-type": "application/json", [POLICY_REFUSAL_HEADER]: `refused; mode=${mode}` };
|
|
214
452
|
if (origin) {
|
|
215
453
|
headers["access-control-allow-origin"] = origin;
|
|
@@ -221,7 +459,7 @@ export function policyRefusal(mode, method, pathname, origin) {
|
|
|
221
459
|
headers,
|
|
222
460
|
body: JSON.stringify({
|
|
223
461
|
error: "Forbidden",
|
|
224
|
-
message: `${method} ${pathname} was refused by the tester's ${mode} write policy. The server never received it.`,
|
|
462
|
+
message: `${method} ${pathname} was refused by the tester's ${mode} write policy${why ? ` (${why})` : ""}. The server never received it.`,
|
|
225
463
|
}),
|
|
226
464
|
};
|
|
227
465
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|
|
@@ -34,7 +34,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
|
|
|
34
34
|
1. **`scout_crawl` first, always.** One call visits every known route (pass `paths` to sweep a specific subset instead), records coverage, and returns per-route health. This is the whole breadth pass — do not visit routes one-by-one with navigate+snapshot.
|
|
35
35
|
2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
|
|
36
36
|
3. **Run journeys with `scout_run_plan {steps}`.** Mechanical sequences (fill form → submit → check) go in ONE plan call — `steps` is an ordered list of `{action, target, value}` — with `testid=`/`text=`/`label=` targets — not one LLM turn per click. The plan aborts at the first violation and tells you where; that's your cue to investigate interactively.
|
|
37
|
-
4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line.
|
|
37
|
+
4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line. A FRAMES line means part of the page is an embed (`<iframe>`) whose controls are not listed and cannot be acted on yet: say in your summary that its contents were not explored, and never file its absence as a missing feature. Writes a cross-origin frame sends outside the app are refused in every mode but destructive, because they reach the third party serving it.
|
|
38
38
|
5. **Native-user behaviours.** `scout_type {ref, textValue}` (or its alias `value`, matching `scout_select` and a plan step) APPENDS when a field already has content (menu clicks often insert @-mention chips or commands into composers — appending preserves them; the result reports what was already there); pass `replace=true` only to deliberately clear, and `pressEnter=true` to submit from the field the way a user would. Before concluding a badge, icon, or "N errors" indicator *does nothing*, `scout_hover` it — tooltips and hover cards are invisible to snapshots and clicks, and hover output includes what appeared. In HEADED mode (`scout_attach {headed:true}`, which the user asks for when they want to watch) the user's physical mouse competes with the synthetic pointer: if a hover reveals nothing and the finding matters, ask the user to move their mouse off the browser window and retry before filing. **Scroll long pages with `scout_scroll`** — the design audit and snapshot measure at the current scroll position, so judge deep sections by scrolling then re-auditing; it refuses to scroll where a real user couldn't and reports SCROLL LOCKED (the leaked modal scroll-lock that silently amputates everything below the fold — snapshots also flag it passively as an OVERLAY line), and scrolling triggers lazy-loaded content whose failures surface as fresh oracle violations. Elements fully clipped inside an overflow-hidden container are flagged UNREACHABLE in GEOMETRY issues — no amount of scrolling reveals them; that's a high-value layout bug, distinct from merely below-the-fold content. **A page can hold SEVERAL independent scroll regions** and plain `scout_scroll` moves the largest one, so a sidebar nav beside a taller main pane never budges: pass `scout_scroll {target:"testid=…"}` to scroll one region. Never report a nav item, tab or list row as missing/truncated until you have scrolled ITS container — content scrolled out of a secondary pane looks exactly like content that was cut off.
|
|
39
39
|
6. **The rest of the input vocabulary.** `scout_select` sets a `<select>` option by value or visible label — use it rather than clicking a native dropdown open, which does not render as page DOM. `scout_press` sends a real key to the focused element (`Escape` to dismiss a modal, `Tab` to walk focus order, `Enter` to submit from a field); it is also how the keyboard-only pass at `extensive` is performed, and it vets the focused control first so a destructive action cannot be triggered blind in read-only mode. **`scout_upload {ref}` attaches a file the way a user does** — `ref` is a visible `<input type=file>` (snapshots list these with role `file`; `scout_type` on one redirects here) OR the button/label/dropzone that opens the file chooser (the chooser is intercepted and answered — that is how the hidden input behind a styled "Choose file" control is reached); omit `ref` when the page has exactly one file input, hidden or not (snapshots disclose hidden ones on a FILE INPUTS line). Nothing needs to exist on disk: a small VALID fixture is generated in memory, its kind inferred from the input's `accept` attribute or chosen with `fixture` (`pdf`, `png`, `txt`, `csv`, `json`); `filePath` uploads a real file but must live inside the attached project (fenced, like navigation is fenced to the origin); `name` overrides the filename. The result flags a file that violates `accept` (a mismatch the app then ACCEPTS is a validation finding), warns when the app cleared the input after selection, and says whether a state-changing request fired on selection — if none did, either click the form's submit or read the next snapshot for a client-side rejection. Plans take `{action:"upload", target, value:"pdf"}` steps (`target` required). When the input or its trigger was addressed by `ref`, the gap ledger counts an attached-but-unsent file as filled-never-submitted; the ref-less path has no listed element to mark.
|
|
40
40
|
7. **Say what you are doing: `task` is required before a tool acts.** A session shows two lines to whoever is watching. Its **objective** is the whole remit you were given, set once at `scout_attach {objective}` ("Admin lane: §2 registers, §7 plan gating", "Approve and reject orders as a manager"). Its **task** is what you are doing *right now*, and every tool that changes the app or the page — `scout_navigate`, `scout_back`, `scout_click`, `scout_type`, `scout_select`, `scout_press`, `scout_upload`, `scout_run_plan` — takes it: a few words for the batch in front of you ("Filtering the documents register by status", "Filling the deviation form with invalid dates", "Signing in as QA_Team"). Say what you are DOING, not what you are checking — "§2.4 filtering narrows the set and is reflected in the URL" is the acceptance criteria, which is the result you will judge, not the batch you are running; naming the item is fine ("§2.4: filtering the documents register"). The task STAYS SET until you pass a different one, so a batch costs a few words, not one per call — pass a fresh one whenever you move on. Acting with none standing is refused: the person watching would otherwise see a session clicking through their app with nothing to say why. `scout_journey {action:"start", goal:…}` sets the task too while it runs — use a journey when you are MEASURING a whole user task, the parameter for everything else.
|