scenescout 3.6.1 → 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 +18 -0
- package/dist/engine/browser.js +380 -24
- package/dist/engine/calibration.js +36 -3
- package/dist/engine/collector.js +53 -0
- package/dist/engine/live-page.js +33 -2
- package/dist/engine/memory.js +75 -1
- package/dist/engine/policy.js +240 -2
- package/dist/engine/report.js +15 -0
- package/dist/mcp-server.js +4 -3
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
9
|
+
## 3.7.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 1b4a688: The live view's close-up has its own Stream button, the same one as the session's card: switch streaming off or on without leaving the close-up. Opening a close-up still streams its session, and closing it without touching the button leaves the card as it was.
|
|
14
|
+
- a11c96e: `scout_coverage` lists the options of each dropdown used in the run that no session has chosen, since a filter counts as exercised after one choice. Disabled and hidden options, and an empty-value placeholder or "all" option, are never listed; dropdowns with more than 20 options are pickers and are not listed either.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- b726af1: Two findings that quote the same control no longer merge when their evidence names different requests, and a filing merged into an existing finding now names it, with its severity and title.
|
|
19
|
+
- 56382db: The lane-report fold no longer lists a judged defect as unfiled when its evidence appears word for word inside a filed finding's evidence.
|
|
20
|
+
|
|
3
21
|
## 3.6.1
|
|
4
22
|
|
|
5
23
|
### Patch 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,10 +52,62 @@ 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`;
|
|
58
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Runs in the page against one dropdown, BEFORE a choice: its options' values
|
|
88
|
+
* and labels. Read first because a dropdown may reset itself (a bulk-action or
|
|
89
|
+
* "jump to" menu) or remove itself on change. Skips disabled and hidden
|
|
90
|
+
* options, and a placeholder or "all" option with an empty value — the state
|
|
91
|
+
* the page loads in, which is not an option anyone owes a choice.
|
|
92
|
+
*/
|
|
93
|
+
function describeSelect(node) {
|
|
94
|
+
// A plan may target the dropdown by its <label>; selectOption follows a label to its control, so this does too.
|
|
95
|
+
const target = node instanceof HTMLLabelElement ? (node.control ?? node.querySelector("select")) : node;
|
|
96
|
+
const select = target;
|
|
97
|
+
if (!select || !select.options)
|
|
98
|
+
return null;
|
|
99
|
+
return Array.from(select.options)
|
|
100
|
+
.filter((o) => !o.disabled && !o.hidden && o.value !== "")
|
|
101
|
+
.map((o) => ({ value: o.value, label: (o.label || o.textContent || "").trim().slice(0, 80) }))
|
|
102
|
+
.filter((o) => o.label !== "");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A dropdown's options, read without waiting: a select that is not there to
|
|
106
|
+
* read is not worth stalling the action for.
|
|
107
|
+
*/
|
|
108
|
+
async function readSelectOptions(loc) {
|
|
109
|
+
return loc.evaluate(describeSelect, undefined, { timeout: 1000 }).catch(() => null);
|
|
110
|
+
}
|
|
59
111
|
/** Runs in the page against one file input (or the one a chooser belongs to). */
|
|
60
112
|
function describeFileInput(node) {
|
|
61
113
|
const input = node;
|
|
@@ -591,6 +643,7 @@ export class BrowserEngine {
|
|
|
591
643
|
this.contradictionsReported = new Set();
|
|
592
644
|
this.pendingCreations = new Set();
|
|
593
645
|
this.baseUrl = opts.url.replace(/\/$/, "");
|
|
646
|
+
this.embedMoves = new EmbedMoveTracker(this.baseUrl);
|
|
594
647
|
// Ownership (ownedIds/createdResources) deliberately NOT reset here: it
|
|
595
648
|
// lives on the shared MemoryStore for the whole run, so re-attaching one
|
|
596
649
|
// role must not discard what another role already created — otherwise
|
|
@@ -663,6 +716,17 @@ export class BrowserEngine {
|
|
|
663
716
|
this.watchResponse(res.request(), res.status());
|
|
664
717
|
});
|
|
665
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
|
+
}
|
|
666
730
|
this.inFlight += 1;
|
|
667
731
|
this.lastRequestStart = Date.now();
|
|
668
732
|
const type = req.resourceType();
|
|
@@ -691,6 +755,13 @@ export class BrowserEngine {
|
|
|
691
755
|
return;
|
|
692
756
|
if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
|
|
693
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;
|
|
694
765
|
const pageUrl = this.page?.url();
|
|
695
766
|
if (pageUrl && this.memory) {
|
|
696
767
|
try {
|
|
@@ -706,12 +777,111 @@ export class BrowserEngine {
|
|
|
706
777
|
await this.context.route("**/*", async (route) => {
|
|
707
778
|
const req = route.request();
|
|
708
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
|
+
}
|
|
709
856
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
710
857
|
return route.continue();
|
|
711
858
|
const url = req.url();
|
|
712
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}`);
|
|
713
879
|
this.rememberAuthHeader(req.headers());
|
|
714
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`);
|
|
715
885
|
// Auth/session flows must work in every mode — but never a destructive
|
|
716
886
|
// one, and in observe only the requests a login itself needs.
|
|
717
887
|
if (isAuthExempt(this.mode, method, pathname, destructiveWire))
|
|
@@ -751,17 +921,7 @@ export class BrowserEngine {
|
|
|
751
921
|
}
|
|
752
922
|
return route.continue();
|
|
753
923
|
}
|
|
754
|
-
|
|
755
|
-
if (this.blockedRequests.length < 20)
|
|
756
|
-
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered });
|
|
757
|
-
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}`, url: this.page?.url() ?? "" });
|
|
758
|
-
this.refusedByPolicy.add(req);
|
|
759
|
-
this.oracles.notePolicyBlock();
|
|
760
|
-
// A script's request is answered with a refusal, so the page's handling
|
|
761
|
-
// of one actually runs; a navigation is dropped (policy.ts says why).
|
|
762
|
-
if (answered)
|
|
763
|
-
return route.fulfill(policyRefusal(this.mode, method, pathname, req.headers()["origin"]));
|
|
764
|
-
return route.abort("blockedbyclient");
|
|
924
|
+
return refuse();
|
|
765
925
|
});
|
|
766
926
|
}
|
|
767
927
|
// Popups / target=_blank: adopt same-origin pages as the active page (with
|
|
@@ -782,6 +942,7 @@ export class BrowserEngine {
|
|
|
782
942
|
if (sameOrigin) {
|
|
783
943
|
this.oracles.attach(newPage);
|
|
784
944
|
this.wireDialogHandler(newPage);
|
|
945
|
+
this.wireEmbedMoves(newPage);
|
|
785
946
|
this.page = newPage;
|
|
786
947
|
this.refs.clear();
|
|
787
948
|
this.snapshotUrl = "";
|
|
@@ -794,6 +955,7 @@ export class BrowserEngine {
|
|
|
794
955
|
.catch(() => { });
|
|
795
956
|
});
|
|
796
957
|
this.wireDialogHandler(this.page);
|
|
958
|
+
this.wireEmbedMoves(this.page);
|
|
797
959
|
try {
|
|
798
960
|
await this.page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
799
961
|
}
|
|
@@ -837,6 +999,48 @@ export class BrowserEngine {
|
|
|
837
999
|
`${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""} Call scout_snapshot to see the current state.` +
|
|
838
1000
|
authWarning);
|
|
839
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
|
+
}
|
|
840
1044
|
/** Dialogs (confirm/alert): dismiss in read-only mode, accept otherwise. Must be wired on every page we drive, including adopted popups. */
|
|
841
1045
|
wireDialogHandler(page) {
|
|
842
1046
|
page.on("dialog", (dialog) => {
|
|
@@ -1134,6 +1338,7 @@ export class BrowserEngine {
|
|
|
1134
1338
|
geometry.push(...(await probeOverlays(page)));
|
|
1135
1339
|
const hiddenFileInputs = await this.hiddenFileInputs(page);
|
|
1136
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);
|
|
1137
1342
|
const cov = memory.coverage();
|
|
1138
1343
|
const unvisited = this.unvisitedKnownRoutes();
|
|
1139
1344
|
const title = await page.title();
|
|
@@ -1144,13 +1349,20 @@ export class BrowserEngine {
|
|
|
1144
1349
|
body +
|
|
1145
1350
|
(geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
|
|
1146
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
|
+
: "") +
|
|
1147
1355
|
(hiddenFileInputs.length > 0
|
|
1148
1356
|
? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
|
|
1149
1357
|
`scout_upload {ref} on the control that opens one, or scout_upload {} when it is the page's only file input.`
|
|
1150
1358
|
: "") +
|
|
1151
1359
|
this.socketNotice() +
|
|
1152
1360
|
formatViolations(this.oracles.drain()) +
|
|
1153
|
-
(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
|
+
: ""));
|
|
1154
1366
|
}
|
|
1155
1367
|
/**
|
|
1156
1368
|
* Resolve a ref and re-verify the live element at action time. Refs are
|
|
@@ -1289,6 +1501,121 @@ export class BrowserEngine {
|
|
|
1289
1501
|
lateMark(entry) {
|
|
1290
1502
|
return entry.at < this.actionStartedAt ? `${entry.sig} (late — likely from a previous action)` : entry.sig;
|
|
1291
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
|
+
}
|
|
1292
1619
|
/** Report (and clear) write-policy blocks since the last action. */
|
|
1293
1620
|
drainBlocked() {
|
|
1294
1621
|
this.lastActionBlocked = this.blockedRequests.length;
|
|
@@ -1300,9 +1627,18 @@ export class BrowserEngine {
|
|
|
1300
1627
|
.join("; ");
|
|
1301
1628
|
const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
|
|
1302
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];
|
|
1303
1633
|
this.blockedRequests = [];
|
|
1304
1634
|
return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
|
|
1305
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
|
+
: "") +
|
|
1306
1642
|
(answered
|
|
1307
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. `
|
|
1308
1644
|
: "") +
|
|
@@ -1783,6 +2119,15 @@ export class BrowserEngine {
|
|
|
1783
2119
|
? `\nRevealed on hover:\n${notes.map((t) => ` · ${t}`).join("\n")}${caveat}`
|
|
1784
2120
|
: `\n(no tooltip, overlay, or new page text appeared within ${HOVER_REVEAL_WINDOW_MS / 1000}s — this element reveals nothing on hover${churning ? "; page content was changing on its own, so the text-diff fallback was suppressed" : ""}${this.headed ? ". NOTE: in headed mode the PHYSICAL mouse cursor competes with the synthetic pointer — if it is resting over the browser window, hover warm-ups are cancelled; ask the user to move it off the window and retry" : ""})`));
|
|
1785
2121
|
}
|
|
2122
|
+
/** Record a dropdown's options and the ones picked, by the values selectOption reported. */
|
|
2123
|
+
recordSelectChoice(fingerprint, key, options, picked) {
|
|
2124
|
+
const labels = options.map((o) => o.label);
|
|
2125
|
+
const chosen = picked.map((v) => options.find((o) => o.value === v)?.label).filter((l) => !!l);
|
|
2126
|
+
if (chosen.length === 0)
|
|
2127
|
+
this.memory.recordSelectChoice(fingerprint, key, labels, "");
|
|
2128
|
+
for (const label of chosen)
|
|
2129
|
+
this.memory.recordSelectChoice(fingerprint, key, labels, label);
|
|
2130
|
+
}
|
|
1786
2131
|
async select(ref, value) {
|
|
1787
2132
|
const page = this.requirePage();
|
|
1788
2133
|
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
@@ -1802,8 +2147,12 @@ export class BrowserEngine {
|
|
|
1802
2147
|
return destructiveRefusal(optionLabel || value, this.mode);
|
|
1803
2148
|
}
|
|
1804
2149
|
}
|
|
1805
|
-
|
|
2150
|
+
const loc = page.locator(`xpath=${el.xpath}`);
|
|
2151
|
+
const options = el.tag === "select" ? await readSelectOptions(loc) : null;
|
|
2152
|
+
const picked = await loc.selectOption(value, { timeout: ACTION_TIMEOUT_MS });
|
|
1806
2153
|
this.memory.markExercised(this.currentFingerprint, el.key, "select");
|
|
2154
|
+
if (options)
|
|
2155
|
+
this.recordSelectChoice(this.currentFingerprint, el.key, options, picked);
|
|
1807
2156
|
return this.afterAction("select", `${el.role} "${el.name}" = ${value}`);
|
|
1808
2157
|
}
|
|
1809
2158
|
/**
|
|
@@ -2131,6 +2480,8 @@ export class BrowserEngine {
|
|
|
2131
2480
|
// What a type step has to say about the field it typed into; it goes on
|
|
2132
2481
|
// the step's own line, so it cannot read as the previous step's.
|
|
2133
2482
|
let note = "";
|
|
2483
|
+
// A select step's options and choice, recorded against the dropdown the bookkeeping below finds.
|
|
2484
|
+
let chose = null;
|
|
2134
2485
|
let preState = null;
|
|
2135
2486
|
try {
|
|
2136
2487
|
if (step.action === "navigate") {
|
|
@@ -2215,8 +2566,12 @@ export class BrowserEngine {
|
|
|
2215
2566
|
await loc.press("Enter", { timeout: ACTION_TIMEOUT_MS });
|
|
2216
2567
|
}
|
|
2217
2568
|
}
|
|
2218
|
-
else if (step.action === "select")
|
|
2219
|
-
|
|
2569
|
+
else if (step.action === "select") {
|
|
2570
|
+
const options = await readSelectOptions(loc);
|
|
2571
|
+
const picked = await loc.selectOption(step.value ?? "", { timeout: ACTION_TIMEOUT_MS });
|
|
2572
|
+
if (options)
|
|
2573
|
+
chose = { options, picked };
|
|
2574
|
+
}
|
|
2220
2575
|
else if (step.action === "upload") {
|
|
2221
2576
|
const r = await this.performUpload(loc, planUploadOptions(step.value));
|
|
2222
2577
|
if (r.refused) {
|
|
@@ -2263,12 +2618,13 @@ export class BrowserEngine {
|
|
|
2263
2618
|
// state so a target the pre-capture missed (or a failed capture)
|
|
2264
2619
|
// still records something rather than nothing.
|
|
2265
2620
|
const preHit = preState ? findIn(preState.elements) : undefined;
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2621
|
+
const postHit = preHit ? undefined : findIn(elements);
|
|
2622
|
+
const hit = preHit ? { fp: preState.fp, el: preHit } : postHit ? { fp, el: postHit } : undefined;
|
|
2623
|
+
if (hit) {
|
|
2624
|
+
this.memory.markExercised(hit.fp, hit.el.key, `plan:${step.action}`);
|
|
2625
|
+
// The lookup can fall back to a name match; options belong only to a dropdown.
|
|
2626
|
+
if (chose && hit.el.tag === "select")
|
|
2627
|
+
this.recordSelectChoice(hit.fp, hit.el.key, chose.options, chose.picked);
|
|
2272
2628
|
}
|
|
2273
2629
|
}
|
|
2274
2630
|
catch {
|
|
@@ -258,13 +258,46 @@ export function unfiledDefects(decisions, findings) {
|
|
|
258
258
|
const ids = identifiers(d.evidence);
|
|
259
259
|
const w = words(d.evidence);
|
|
260
260
|
const text = squash(d.evidence);
|
|
261
|
-
if (filed.some((f) => (text !== "" && f.evidence === text) || covers(ids, w, f)))
|
|
261
|
+
if (filed.some((f) => (text !== "" && f.evidence === text) || restates(text, f) || covers(ids, w, f)))
|
|
262
262
|
continue;
|
|
263
263
|
}
|
|
264
264
|
out.push(d.evidence ? `${d.observation} — ${d.evidence}` : d.observation);
|
|
265
265
|
}
|
|
266
266
|
return out;
|
|
267
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Shortest reported evidence that counts as restating a filing it is part of.
|
|
270
|
+
*/
|
|
271
|
+
const MIN_RESTATED = 24;
|
|
272
|
+
/** A test id, captured without its attribute; and a kebab-case id of three or more parts. Lowercase text only. */
|
|
273
|
+
const TESTID_RE = /testid=["']?([a-z0-9_-]+)/g;
|
|
274
|
+
const KEBAB_ID_RE = /\b[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}\b/g;
|
|
275
|
+
/**
|
|
276
|
+
* Whether the reported evidence appears word for word inside a finding's
|
|
277
|
+
* evidence: a lane that filed "testid=row-1..6 non-interactive; GET /api/x 200
|
|
278
|
+
* with only three fields" and reported the first half. It must say something
|
|
279
|
+
* beyond naming a control — two words once test ids and kebab-case ids of
|
|
280
|
+
* three or more parts are taken out — or a bare "testid=inventory-sort-qty" would be found inside
|
|
281
|
+
* every finding about that control, which is the one-shared-id match covers()
|
|
282
|
+
* refuses. One direction only: a short FILED evidence found inside a longer
|
|
283
|
+
* report proves nothing about the rest of the report.
|
|
284
|
+
*/
|
|
285
|
+
function restates(reported, f) {
|
|
286
|
+
if (reported.length < MIN_RESTATED || !f.evidence.includes(reported))
|
|
287
|
+
return false;
|
|
288
|
+
const rest = reported.replace(TESTID_RE, " ").replace(KEBAB_ID_RE, " ");
|
|
289
|
+
return words(rest).size >= 2;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Whether a finding covers a decision. Lanes reword evidence between filing it
|
|
293
|
+
* and reporting it — an arrow for a hyphen, quoted JSON for bare, "8 links"
|
|
294
|
+
* for "8 link(s)" — but keep the identifiers: test ids and contrast ratios.
|
|
295
|
+
* Two shared identifiers, or a near-identical wording, is the same
|
|
296
|
+
* observation. One shared test id is never enough, with or without some
|
|
297
|
+
* words in common: two different defects on one control share both (sort by
|
|
298
|
+
* quantity sorting as text, and the same sort showing no active state), and a
|
|
299
|
+
* real miss must not hide behind its neighbour.
|
|
300
|
+
*/
|
|
268
301
|
function covers(ids, w, f) {
|
|
269
302
|
let shared = 0;
|
|
270
303
|
for (const id of ids)
|
|
@@ -284,9 +317,9 @@ function covers(ids, w, f) {
|
|
|
284
317
|
function identifiers(text) {
|
|
285
318
|
const out = new Set();
|
|
286
319
|
const t = text.toLowerCase();
|
|
287
|
-
for (const m of t.matchAll(
|
|
320
|
+
for (const m of t.matchAll(TESTID_RE))
|
|
288
321
|
out.add(m[1]);
|
|
289
|
-
for (const m of t.matchAll(
|
|
322
|
+
for (const m of t.matchAll(KEBAB_ID_RE))
|
|
290
323
|
out.add(m[0]);
|
|
291
324
|
for (const m of t.matchAll(/\b\d+(?:\.\d+)?:1\b/g))
|
|
292
325
|
out.add(m[0]);
|
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/live-page.js
CHANGED
|
@@ -225,6 +225,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
225
225
|
<div class="bar">
|
|
226
226
|
<strong id="focus-name"></strong>
|
|
227
227
|
<span class="line" id="focus-line"></span>
|
|
228
|
+
<button type="button" id="focus-stream" aria-pressed="false" data-testid="live-focus-stream-toggle">Stream</button>
|
|
228
229
|
<button type="button" id="focus-close" data-testid="live-focus-close">Close</button>
|
|
229
230
|
</div>
|
|
230
231
|
<div class="stage" id="focus-stage">
|
|
@@ -253,6 +254,11 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
253
254
|
/** The header filter, lower-cased. Hides cards; never stops a session running. */
|
|
254
255
|
var filter = '';
|
|
255
256
|
var focused = null;
|
|
257
|
+
// Whether opening the close-up is what switched its session's stream on. The
|
|
258
|
+
// close-up streams while it is open; closing it hands the card back as it
|
|
259
|
+
// was, unless the viewer used the close-up's own Stream button, whose choice
|
|
260
|
+
// stands.
|
|
261
|
+
var focusStartedStream = false;
|
|
256
262
|
var skew = 0;
|
|
257
263
|
var latest = {};
|
|
258
264
|
var focusTick = 0;
|
|
@@ -309,7 +315,6 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
309
315
|
function syncEvents() {
|
|
310
316
|
var want = {};
|
|
311
317
|
Object.keys(cards).forEach(function (name) { if (cards[name].live) want[name] = true; });
|
|
312
|
-
if (focused && cards[focused]) want[focused] = true;
|
|
313
318
|
var key = Object.keys(want).sort().join(',');
|
|
314
319
|
if (key === eventsKey) return;
|
|
315
320
|
eventsKey = key;
|
|
@@ -353,8 +358,20 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
353
358
|
// Frames for a live card arrive over the shared connection; the thumbnail poll takes over again when it is switched off.
|
|
354
359
|
if (on && frames[card.name]) card.img.src = frames[card.name];
|
|
355
360
|
if (!on) card.img.src = shotUrl(card.name);
|
|
361
|
+
if (focused === card.name) paintFocusStream();
|
|
356
362
|
syncEvents();
|
|
357
363
|
}
|
|
364
|
+
// The close-up's Stream button is the card's, shown where the viewer is looking.
|
|
365
|
+
function paintFocusStream() {
|
|
366
|
+
var card = focused && cards[focused];
|
|
367
|
+
var button = document.getElementById('focus-stream');
|
|
368
|
+
button.hidden = !card;
|
|
369
|
+
if (!card) return;
|
|
370
|
+
button.setAttribute('aria-pressed', card.live ? 'true' : 'false');
|
|
371
|
+
button.textContent = card.live ? 'Streaming' : 'Stream';
|
|
372
|
+
// Off, the picture is a still that the status poll refreshes.
|
|
373
|
+
if (!card.live && !scrubbed) document.getElementById('focus-img').src = shotUrl(card.name);
|
|
374
|
+
}
|
|
358
375
|
function refreshThumb(card) {
|
|
359
376
|
if (card.live || document.hidden) return;
|
|
360
377
|
var next = new Image();
|
|
@@ -697,7 +714,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
697
714
|
scrubbed = null;
|
|
698
715
|
document.getElementById('scrub-where').textContent = 'Live';
|
|
699
716
|
document.getElementById('scrub-live').hidden = true;
|
|
700
|
-
if (focused) document.getElementById('focus-img').src = frames[focused] || shotUrl(focused);
|
|
717
|
+
if (focused) document.getElementById('focus-img').src = (cards[focused] && cards[focused].live && frames[focused]) || shotUrl(focused);
|
|
701
718
|
// Otherwise the step just left keeps its outline until the next full feed.
|
|
702
719
|
renderTimeline(null);
|
|
703
720
|
}
|
|
@@ -794,6 +811,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
794
811
|
document.getElementById('scrub-where').textContent = 'Live';
|
|
795
812
|
document.getElementById('scrub-live').hidden = true;
|
|
796
813
|
document.getElementById('focus').classList.add('open');
|
|
814
|
+
var card = cards[name];
|
|
815
|
+
focusStartedStream = !!card && !card.live;
|
|
816
|
+
if (focusStartedStream) setLive(card, true);
|
|
817
|
+
paintFocusStream();
|
|
797
818
|
hoverTask = null;
|
|
798
819
|
renderFeed(document.getElementById('focus-feed'), (latest[name] || {}).feed, showTask);
|
|
799
820
|
syncEvents();
|
|
@@ -806,6 +827,8 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
806
827
|
focused = null;
|
|
807
828
|
document.getElementById('focus').classList.remove('open');
|
|
808
829
|
document.getElementById('focus-img').removeAttribute('src');
|
|
830
|
+
if (focusStartedStream && was && cards[was]) setLive(cards[was], false);
|
|
831
|
+
focusStartedStream = false;
|
|
809
832
|
syncEvents();
|
|
810
833
|
if (was && cards[was]) cards[was].shot.focus();
|
|
811
834
|
}
|
|
@@ -865,6 +888,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
865
888
|
if (!s) { line.textContent = focused ? 'This session has closed.' : ''; return; }
|
|
866
889
|
var d = describe(s);
|
|
867
890
|
line.textContent = d.badge + ' · ' + d.tool + ' · ' + (s.url || '');
|
|
891
|
+
paintFocusStream();
|
|
868
892
|
if (focusTick % 3 === 0) loadFullFeed(focused);
|
|
869
893
|
focusTick += 1;
|
|
870
894
|
}
|
|
@@ -981,6 +1005,13 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
981
1005
|
});
|
|
982
1006
|
document.getElementById('focus-img').addEventListener('load', function () { document.getElementById('focus-stage').classList.remove('empty'); });
|
|
983
1007
|
document.getElementById('focus-close').addEventListener('click', closeFocus);
|
|
1008
|
+
document.getElementById('focus-stream').addEventListener('click', function () {
|
|
1009
|
+
var card = focused && cards[focused];
|
|
1010
|
+
if (!card) return;
|
|
1011
|
+
// The viewer chose: closing the close-up no longer undoes it.
|
|
1012
|
+
focusStartedStream = false;
|
|
1013
|
+
setLive(card, !card.live);
|
|
1014
|
+
});
|
|
984
1015
|
// A re-rendered feed replaces the group under the pointer without a mouseleave; leaving the feed itself still resets.
|
|
985
1016
|
document.getElementById('focus-feed').addEventListener('mouseleave', function () { showTask(null); });
|
|
986
1017
|
document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
|
package/dist/engine/memory.js
CHANGED
|
@@ -283,6 +283,13 @@ function decisionKey(d) {
|
|
|
283
283
|
* them on each save, which is what made an old history slow to open.
|
|
284
284
|
*/
|
|
285
285
|
export const MAX_LANE_DECISIONS = 1000;
|
|
286
|
+
/**
|
|
287
|
+
* Most options a dropdown may have and still be tracked for unchosen options.
|
|
288
|
+
* A status or sort filter has a handful, each of which can change what the
|
|
289
|
+
* page asks the server for; a country or time-zone picker has hundreds, and
|
|
290
|
+
* nobody owes the page a choice of each. Larger dropdowns are not tracked.
|
|
291
|
+
*/
|
|
292
|
+
export const MAX_SELECT_OPTIONS = 20;
|
|
286
293
|
const MAX_DISCOVERED_ROUTES = 300;
|
|
287
294
|
/** Shared finding-similarity helpers (used by live dedup and retro-merge). */
|
|
288
295
|
function findingTokens(s) {
|
|
@@ -412,6 +419,23 @@ function sameEndpointBug(existing, incoming) {
|
|
|
412
419
|
return false;
|
|
413
420
|
return sharesEndpointSignature(existing, incoming);
|
|
414
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Whether two pieces of evidence each name a request and share none — compared
|
|
424
|
+
* by method and normalised path, ignoring the status, so `GET /api/x` and
|
|
425
|
+
* `GET /api/x/ 404` are one request. Evidence that names no request (a test id,
|
|
426
|
+
* a toast's text) disagrees with nothing.
|
|
427
|
+
*/
|
|
428
|
+
export function requestsDisagree(a, b) {
|
|
429
|
+
const requests = (ev) => new Set([...endpointSignatures(ev)].map((sig) => sig.split(" ").slice(0, 2).join(" ")));
|
|
430
|
+
const aReq = requests(a);
|
|
431
|
+
const bReq = requests(b);
|
|
432
|
+
if (aReq.size === 0 || bReq.size === 0)
|
|
433
|
+
return false;
|
|
434
|
+
for (const r of aReq)
|
|
435
|
+
if (bReq.has(r))
|
|
436
|
+
return false;
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
415
439
|
/**
|
|
416
440
|
* Families of finding kinds that one bug is plausibly filed under by two
|
|
417
441
|
* sessions: a crash is a page-error to one and a console-error to another, a
|
|
@@ -474,7 +498,19 @@ function sameFinding(a, b) {
|
|
|
474
498
|
// detail or its own title. Merged, the layout defect was filed and then lost
|
|
475
499
|
// from the report on most runs of a benchmark. Within a family, one bug
|
|
476
500
|
// filed twice under neighbouring categories still merges.
|
|
477
|
-
|
|
501
|
+
//
|
|
502
|
+
// And never when both findings' evidence names requests with no endpoint in
|
|
503
|
+
// common. A quoted control name bridges two findings about that control
|
|
504
|
+
// within one family too: "an unknown order id still offers its actions"
|
|
505
|
+
// (GET /api/orders/9999 404) mentions the "Request manager approval" button
|
|
506
|
+
// that "Request manager approval stays enabled on a pending order" (POST
|
|
507
|
+
// …/request-approval 409) quotes in its title, and was merged into it.
|
|
508
|
+
// Evidence that names a request is the finding's own statement of where it
|
|
509
|
+
// happened; two findings naming different requests are two bugs. A request
|
|
510
|
+
// that answered 2xx counts too — a false success names one — so the same bug
|
|
511
|
+
// described once by its page load and once by its failing call stays as two
|
|
512
|
+
// findings: a visible duplicate, the direction ADR 4 accepts.
|
|
513
|
+
if (sameFamily(a.category, b.category) && !requestsDisagree(a.evidence, b.evidence)) {
|
|
478
514
|
const aTitleLits = findingLiterals(a.title);
|
|
479
515
|
const bTitleLits = findingLiterals(b.title);
|
|
480
516
|
if (aTitleLits.size > 0 || bTitleLits.size > 0) {
|
|
@@ -645,6 +681,44 @@ export class MemoryStore {
|
|
|
645
681
|
this.probes = [];
|
|
646
682
|
this.injectionsReported.clear();
|
|
647
683
|
this.auditsThisRun = 0;
|
|
684
|
+
this.selectChoices.clear();
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Each dropdown's options and the ones chosen in THIS run, by any session,
|
|
688
|
+
* keyed by route and element. A select counts as exercised after one choice,
|
|
689
|
+
* so a lane that tried four of a filter's seven options — and reported having
|
|
690
|
+
* tried them all — left the one that failed untried with nothing to say so.
|
|
691
|
+
* Per run, like the probes: whether an earlier run chose an option says
|
|
692
|
+
* nothing about whether this one looked. Keyed without the role: when two
|
|
693
|
+
* roles see different options in one dropdown, the list read last is the
|
|
694
|
+
* one reported.
|
|
695
|
+
*/
|
|
696
|
+
selectChoices = new Map();
|
|
697
|
+
recordSelectChoice(fingerprint, key, options, chosen) {
|
|
698
|
+
const route = fingerprint.split("#")[0];
|
|
699
|
+
const id = `${route}\u0000${key}`;
|
|
700
|
+
const distinct = [...new Set(options)];
|
|
701
|
+
if (distinct.length > MAX_SELECT_OPTIONS) {
|
|
702
|
+
this.selectChoices.delete(id);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const entry = this.selectChoices.get(id) ?? { route, key, options: [], chosen: new Set() };
|
|
706
|
+
// The latest list wins: options a page added or removed since are not owed.
|
|
707
|
+
if (distinct.length > 0)
|
|
708
|
+
entry.options = distinct;
|
|
709
|
+
if (chosen)
|
|
710
|
+
entry.chosen.add(chosen);
|
|
711
|
+
this.selectChoices.set(id, entry);
|
|
712
|
+
}
|
|
713
|
+
/** Dropdowns with options no session chose this run, in the order they were first used. */
|
|
714
|
+
unchosenOptions() {
|
|
715
|
+
const out = [];
|
|
716
|
+
for (const { route, key, options, chosen } of this.selectChoices.values()) {
|
|
717
|
+
const unchosen = options.filter((o) => !chosen.has(o));
|
|
718
|
+
if (unchosen.length > 0)
|
|
719
|
+
out.push({ route, key, unchosen });
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
648
722
|
}
|
|
649
723
|
constructor(projectDir) {
|
|
650
724
|
this.dir = path.join(projectDir, MEMORY_DIRNAME);
|
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/dist/engine/report.js
CHANGED
|
@@ -318,6 +318,21 @@ export function formatRouteCoverage(allRoutes, unvisited) {
|
|
|
318
318
|
? ` — UNVISITED: ${unvisited.slice(0, 25).join(", ")}${unvisited.length > 25 ? " …" : ""} (scout_crawl covers these in one call)`
|
|
319
319
|
: " ✓"));
|
|
320
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* The dropdowns this run used without trying every option. A select counts as
|
|
323
|
+
* exercised after one choice, so the unexercised list above never shows these:
|
|
324
|
+
* a status filter whose one failing option nobody chose looks fully covered.
|
|
325
|
+
* Empty when there is nothing to say.
|
|
326
|
+
*/
|
|
327
|
+
export function formatUnchosenOptions(dropdowns) {
|
|
328
|
+
if (dropdowns.length === 0)
|
|
329
|
+
return [];
|
|
330
|
+
return [
|
|
331
|
+
"Dropdown options never chosen this run (each can change what the page asks the server for):",
|
|
332
|
+
...dropdowns.slice(0, 15).map((d) => ` ${d.route} ${d.key}: ${d.unchosen.map((o) => JSON.stringify(o)).join(", ")}`),
|
|
333
|
+
...(dropdowns.length > 15 ? [` … +${dropdowns.length - 15} more`] : []),
|
|
334
|
+
];
|
|
335
|
+
}
|
|
321
336
|
/**
|
|
322
337
|
* The GAP LEDGER — an explicit enumeration of what was NOT tested. This is
|
|
323
338
|
* what turns "extensive" from a vibe into a verifiable claim: a run is only
|
package/dist/mcp-server.js
CHANGED
|
@@ -41,7 +41,7 @@ import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
|
41
41
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
42
42
|
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
43
43
|
import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
|
|
44
|
-
import { computeGaps, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
|
|
44
|
+
import { computeGaps, formatRouteCoverage, formatUnchosenOptions, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
|
|
45
45
|
import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
|
|
46
46
|
import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
|
|
47
47
|
import { describePace, normalizePace } from "./engine/settle.js";
|
|
@@ -1147,14 +1147,14 @@ server.registerTool("scout_finding", {
|
|
|
1147
1147
|
? `Finding recorded: [${finding.severity}] ${finding.title} (id ${finding.id})`
|
|
1148
1148
|
: finding.regressedAt
|
|
1149
1149
|
? `⟳ REOPENED as a REGRESSION: finding ${finding.id} was previously resolved but the evidence reproduces again (seen in ${finding.runs} runs). Worth calling out to the user.`
|
|
1150
|
-
: `
|
|
1150
|
+
: `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, seen in ${finding.runs} runs. If yours is a different bug, file it again with evidence naming the request that failed for you (method and path): two findings are kept apart when both name requests and none is shared.`, session);
|
|
1151
1151
|
}
|
|
1152
1152
|
catch (err) {
|
|
1153
1153
|
return errorText(err);
|
|
1154
1154
|
}
|
|
1155
1155
|
}));
|
|
1156
1156
|
server.registerTool("scout_coverage", {
|
|
1157
|
-
description: "Show exploration coverage: states visited across all runs
|
|
1157
|
+
description: "Show exploration coverage: states visited across all runs, which elements remain unexercised, and which options of a dropdown used this run no session has chosen yet. Use to decide where to explore next and when the level's budget is satisfied.",
|
|
1158
1158
|
inputSchema: { session: sessionParam },
|
|
1159
1159
|
}, serializedPerSession("scout_coverage", async (_args, session) => {
|
|
1160
1160
|
try {
|
|
@@ -1173,6 +1173,7 @@ server.registerTool("scout_coverage", {
|
|
|
1173
1173
|
formatRouteCoverage(eng.allKnownRoutes(), unvisited),
|
|
1174
1174
|
`Unexercised elements by route:`,
|
|
1175
1175
|
...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
|
|
1176
|
+
...formatUnchosenOptions(eng.memory.unchosenOptions()),
|
|
1176
1177
|
];
|
|
1177
1178
|
return text(lines.join("\n"), session);
|
|
1178
1179
|
}
|
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.
|
|
@@ -42,7 +42,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
|
|
|
42
42
|
8. **Design-connoisseur pass without pixels: `scout_design_audit`.** Run it once per representative page (dashboard, a form, a detail view, a data table). Its output has two tiers: **⚠ measurable defects** (WCAG contrast, tiny targets, clipped text, aspect-distorted images, horizontal overflow, keyboard tab stops with no visible focus indicator — sampled with real Tab presses) and **→ craft suggestions** (line measure and line-height rhythm, spacing-grid adherence, typography entropy, gray census and accent-hue count, pure-#000 body text, elevation/control consistency, heading structure, indistinguishable links, AI-slop tells like gradient text/glassmorphism/side-stripe borders/identical card grids), closing with a SYSTEM SUMMARY of design-system coherence. Judge every line with product context (dense tables legitimately have small targets; a chart page legitimately uses many hues). File ⚠ defects as `visual`/`a11y`, and genuine → opportunities as `ux-polish` findings **quoting the concrete numbers** — "~142 characters per line (65–75 ideal)" beats "text feels wide". Every audit ends with a **PAGE SCORE** (0–100 overall + a11y/craft/consistency/task-clarity subscores) persisted per route — the report ranks pages worst-first, so re-runs show whether pages got better or worse. Separately, every `scout_snapshot` runs an **overlay/modal probe** automatically: an empty dialog over a grayed page, a backdrop with no dialog, a far-off-centre dialog leaving a blank band, or a dialog extending unreachably below the viewport appear as OVERLAY lines in GEOMETRY issues — treat these as high-value findings (the user is visually stuck). This is where "how could this page be better" gets answered, not just "is it broken".
|
|
43
43
|
9. **Measure task EASE with `scout_journey`, not just correctness.** Wrap each module's primary task (`{action:"start", goal:"Create an order"}` → do it → `{action:"end", completed:…}`). Navigate by CLICKING like a first-time user — typing a known deep URL shortcuts the very thing being measured (a route you can only reach by editing the address bar is itself a finding). The result gives interaction count, distinct screens, the path taken, and BACKTRACKS — returning to a screen already left is the clearest evidence the next step wasn't discoverable. An abandoned journey (`completed:false`) is a high-severity finding: the task is blocked or undiscoverable, which no passing e2e suite would ever reveal.
|
|
44
44
|
10. **Walk the auth surface too — anonymously.** Attach a second session WITHOUT a storage-state file (a fresh logged-out profile) and exercise signup, login failure states, and forgot/reset-password **as far as they physically go**. The mailbox wall is expected — reaching "check your email" IS the success condition; everything before it is what you're testing: does submit actually fire (a dead signup button is a high finding), are errors specific and actionable, can the user resend or recover from a typo, does the flow dead-end. Use plausible synthetic identities only (invent `qa-<runid>@example.com`-style addresses, never a real person's), submit each form valid AND invalid, and judge the feedback. Two classic findings live here: a forgot-password that answers "no account with that email" is an **account-enumeration leak** (file as security; "if an account exists, we sent a link" is the correct shape), and a signup that accepts the form then lands on a blank or logged-out page with no guidance is a **journey dead-end**. Signup creates a record, so what this pass may do depends on the mode. In `observe`, fill and submit the auth forms for their CLIENT-SIDE behaviour only: the engine blocks signup, password change and reset, and lets only a login itself go out. Disclose the server-side half as a gap. Actually creating an account needs the user's explicit okay and safe-write mode; the engine tracks the created account like any other creation.
|
|
45
|
-
11. **`scout_coverage` decides what's next** — it lists unvisited routes and
|
|
45
|
+
11. **`scout_coverage` decides what's next** — it lists unvisited routes, unexercised elements, and the options of each dropdown you used that no session has chosen this run (a filter counts as exercised after one choice, and the option you skipped can be the one whose request fails). Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
|
|
46
46
|
|
|
47
47
|
## Levels (completion contracts — the engine ENFORCES them via `scout_report {level}`)
|
|
48
48
|
|