scenescout 1.2.0 → 1.4.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 +26 -0
- package/README.md +41 -9
- package/dist/browsers.js +11 -0
- package/dist/cli.js +127 -37
- package/dist/engine/browser.js +207 -10
- package/dist/engine/injection.js +162 -0
- package/dist/engine/live-page.js +650 -0
- package/dist/engine/live.js +579 -0
- package/dist/engine/memory.js +10 -2
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/report.js +8 -2
- package/dist/installer.js +82 -4
- package/dist/mcp-server.js +216 -26
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +3 -2
package/dist/engine/browser.js
CHANGED
|
@@ -2,13 +2,14 @@ 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";
|
|
5
|
-
import { AUTH_LOSS_PREFIX, MemoryStore } from "./memory.js";
|
|
5
|
+
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore } from "./memory.js";
|
|
6
|
+
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
6
7
|
import { AuthLossTracker } from "./authloss.js";
|
|
7
8
|
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
|
|
8
9
|
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
9
10
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
10
11
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
11
|
-
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, serviceWorkerPolicy, sharedWorkersAllowed } from "../browsers.js";
|
|
12
|
+
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, } from "../browsers.js";
|
|
12
13
|
import { revealedLines } from "./hover.js";
|
|
13
14
|
import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
14
15
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
@@ -98,6 +99,8 @@ function actionabilityDiagnostic(message) {
|
|
|
98
99
|
* actions by ref, runs oracles after every action, and records everything in
|
|
99
100
|
* the persistent memory store. Contains no LLM calls — the MCP client is the brain.
|
|
100
101
|
*/
|
|
102
|
+
/** A screencast whose page has been gone for this many 500 ms ticks ends and says so; a re-attach takes fewer. */
|
|
103
|
+
const SCREENCAST_PAGELESS_TICKS = 20;
|
|
101
104
|
export class BrowserEngine {
|
|
102
105
|
browser = null;
|
|
103
106
|
context = null;
|
|
@@ -182,10 +185,26 @@ export class BrowserEngine {
|
|
|
182
185
|
return this.memory?.createdResources ?? this.localCreatedResources;
|
|
183
186
|
}
|
|
184
187
|
localCreatedResources = [];
|
|
188
|
+
/** Creations not yet named in a tool result. Unlike the once-per-session mutation notice, every one is reported. */
|
|
189
|
+
newlyCreated = [];
|
|
190
|
+
/**
|
|
191
|
+
* Name every record created since the last result. The mutation notice
|
|
192
|
+
* reports each endpoint once per session so a read-only run is not flooded;
|
|
193
|
+
* in safe-write that hid every creation after the first on an endpoint,
|
|
194
|
+
* so the agent could not tell from the result that it had just made one.
|
|
195
|
+
*/
|
|
196
|
+
drainCreated() {
|
|
197
|
+
if (this.newlyCreated.length === 0)
|
|
198
|
+
return "";
|
|
199
|
+
const list = this.newlyCreated.splice(0);
|
|
200
|
+
return `\n(created: ${list.join(", ")} — this session may edit or delete ${list.length === 1 ? "it" : "them"})`;
|
|
201
|
+
}
|
|
185
202
|
/** Design audits run this session — the report gate requires at least one. */
|
|
186
203
|
designAuditCount = 0;
|
|
187
204
|
/** Active task-efficiency measurement (scout_journey), if any. */
|
|
188
205
|
journey = null;
|
|
206
|
+
/** The session's task, from scout_attach. Empty when the agent gave none. */
|
|
207
|
+
task = "";
|
|
189
208
|
/**
|
|
190
209
|
* Begin measuring a user JOURNEY — the interaction cost of completing one
|
|
191
210
|
* real task ("create an order", "approve a document"). E2E suites assert
|
|
@@ -200,7 +219,7 @@ export class BrowserEngine {
|
|
|
200
219
|
fromLog: this.memory?.actionLog.length ?? 0,
|
|
201
220
|
startUrl: page.url(),
|
|
202
221
|
};
|
|
203
|
-
this.logAction({ action:
|
|
222
|
+
this.logAction({ action: JOURNEY_START, target: goal, url: page.url() });
|
|
204
223
|
return `JOURNEY STARTED — "${goal}"\nFrom: ${page.url()}\nNow perform the task the way a first-time user would (click through the UI; don't jump straight to a known deep URL, or the measurement is meaningless). Call scout_journey {action:"end"} when the task is complete or you conclude it can't be.`;
|
|
205
224
|
}
|
|
206
225
|
/** Close the journey and report its interaction cost + friction signals. */
|
|
@@ -225,7 +244,7 @@ export class BrowserEngine {
|
|
|
225
244
|
catch {
|
|
226
245
|
/* fact recording is best-effort */
|
|
227
246
|
}
|
|
228
|
-
this.logAction({ action:
|
|
247
|
+
this.logAction({ action: JOURNEY_END, target: j.goal, url: page.url(), result: completed ? "completed" : "abandoned" });
|
|
229
248
|
return formatJourney({ goal: j.goal, completed, seconds, note }, measured);
|
|
230
249
|
}
|
|
231
250
|
/** Whether the browser window is visible — headed hover results carry a physical-cursor caveat. */
|
|
@@ -244,6 +263,54 @@ export class BrowserEngine {
|
|
|
244
263
|
* allowed POST /items that shares its prefix.
|
|
245
264
|
*/
|
|
246
265
|
abortedByPolicy = new WeakSet();
|
|
266
|
+
/** Markup-shaped values this session typed, so every later page can be checked for them rendering as elements. */
|
|
267
|
+
probes = [];
|
|
268
|
+
/** Injections already reported, by payload and route, so a page is not reported on every snapshot. */
|
|
269
|
+
injectionsReported = new Set();
|
|
270
|
+
/**
|
|
271
|
+
* Remember a typed value when it holds an element worth watching for. What
|
|
272
|
+
* to type is the agent's choice; this only watches for the outcome. The
|
|
273
|
+
* page's current count of such elements is the baseline: shared chrome that
|
|
274
|
+
* happens to look like the payload is not an injection.
|
|
275
|
+
*/
|
|
276
|
+
async noteProbe(value, field) {
|
|
277
|
+
const shape = probeShape(value);
|
|
278
|
+
if (!shape || this.probes.some((p) => p.payload === shape.payload))
|
|
279
|
+
return;
|
|
280
|
+
const page = this.page;
|
|
281
|
+
let baseline = 0;
|
|
282
|
+
if (page && !page.isClosed()) {
|
|
283
|
+
try {
|
|
284
|
+
baseline = (await page.evaluate(probeScript([shape]))).length;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// A page mid-navigation: no baseline, so a coincidence on this page would be reported. Rare, and visible.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
this.probes = rememberProbe(this.probes, { ...shape, field, typedOn: page?.url() ?? "", baseline });
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The DOM-injection oracle: has anything this session typed come back as an
|
|
294
|
+
* element on the current page? Runs wherever violations are drained, so the
|
|
295
|
+
* finding reaches the agent in the result of the action that revealed it.
|
|
296
|
+
*/
|
|
297
|
+
async scanForInjections() {
|
|
298
|
+
const page = this.page;
|
|
299
|
+
if (!page || page.isClosed() || this.probes.length === 0)
|
|
300
|
+
return;
|
|
301
|
+
const url = page.url();
|
|
302
|
+
let hits;
|
|
303
|
+
try {
|
|
304
|
+
hits = (await page.evaluate(probeScript(probeQueries(this.probes))));
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// A page mid-navigation has no DOM to ask; the next drain looks again.
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const found of newInjections(this.probes, hits, url, this.injectionsReported)) {
|
|
311
|
+
this.oracles.noteInjection(describeInjection(found.probe, url, found.outer), url);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
247
314
|
/** Raw mutation sigs of the most recent action (pre-dedup) — double-submit detection. */
|
|
248
315
|
lastActionMutationSigs = [];
|
|
249
316
|
/** Write-policy blocks drained by the last action — counted, so an action can know a request fired even when the policy stopped it. */
|
|
@@ -258,6 +325,7 @@ export class BrowserEngine {
|
|
|
258
325
|
throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
|
|
259
326
|
}
|
|
260
327
|
this.mode = opts.mode ?? "read-only";
|
|
328
|
+
this.task = (opts.task ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
|
|
261
329
|
this.headed = opts.headed ?? false;
|
|
262
330
|
this.blockedRequests = [];
|
|
263
331
|
this.pendingCreations = new Set();
|
|
@@ -636,6 +704,7 @@ export class BrowserEngine {
|
|
|
636
704
|
const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
|
|
637
705
|
memory.recordRoleAccess(this.role, route, "reached");
|
|
638
706
|
this.logAction({ action: "snapshot", url, result: fp });
|
|
707
|
+
await this.scanForInjections();
|
|
639
708
|
const line = (el) => {
|
|
640
709
|
const dup = el.key.match(/~(\d+)$/);
|
|
641
710
|
const flags = [
|
|
@@ -793,8 +862,9 @@ export class BrowserEngine {
|
|
|
793
862
|
formatViolations(this.oracles.drain()));
|
|
794
863
|
}
|
|
795
864
|
this.logAction({ action, target, url });
|
|
865
|
+
await this.scanForInjections();
|
|
796
866
|
const violations = this.oracles.drain();
|
|
797
|
-
const mutations = this.drainMutations() + this.drainBlocked();
|
|
867
|
+
const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
|
|
798
868
|
const navigated = this.snapshotUrl !== "" && url !== this.snapshotUrl;
|
|
799
869
|
if (navigated) {
|
|
800
870
|
// Refs point into the previous page's DOM; invalidate so a stale ref
|
|
@@ -842,6 +912,7 @@ export class BrowserEngine {
|
|
|
842
912
|
const desc = `${verdict.collection} id=${id}`;
|
|
843
913
|
if (!this.createdResources.includes(desc)) {
|
|
844
914
|
this.createdResources.push(desc);
|
|
915
|
+
this.newlyCreated.push(desc);
|
|
845
916
|
this.logAction({ action: "created-resource", target: desc, url: this.page?.url() ?? "" });
|
|
846
917
|
}
|
|
847
918
|
}
|
|
@@ -1076,6 +1147,8 @@ export class BrowserEngine {
|
|
|
1076
1147
|
if (refusal)
|
|
1077
1148
|
return refusal;
|
|
1078
1149
|
const locator = page.locator(`xpath=${el.xpath}`);
|
|
1150
|
+
// Before the fill: a page that reflects input as it is typed already holds the element afterwards.
|
|
1151
|
+
await this.noteProbe(text, `${el.role} "${el.name}"`);
|
|
1079
1152
|
const fillNote = await this.fillOrAppend(locator, text, replace);
|
|
1080
1153
|
if (pressEnter) {
|
|
1081
1154
|
// Enter inside a form submits it — check the form's submit target, or
|
|
@@ -1609,6 +1682,7 @@ export class BrowserEngine {
|
|
|
1609
1682
|
if (typeof status === "number" && status >= 400)
|
|
1610
1683
|
memory.markAttempted(requestedRoute, `status:${status}`, this.role);
|
|
1611
1684
|
this.logAction({ action: "crawl", target: path, url: finalUrl });
|
|
1685
|
+
await this.scanForInjections();
|
|
1612
1686
|
const violations = this.oracles.drain();
|
|
1613
1687
|
const deadEnd = elements.length === 0;
|
|
1614
1688
|
const unnamed = elements.filter((el) => !el.name).length;
|
|
@@ -1681,6 +1755,9 @@ export class BrowserEngine {
|
|
|
1681
1755
|
let preTestid = null;
|
|
1682
1756
|
let preLabel = "";
|
|
1683
1757
|
let forcedClick = false;
|
|
1758
|
+
// What a type step has to say about the field it typed into; it goes on
|
|
1759
|
+
// the step's own line, so it cannot read as the previous step's.
|
|
1760
|
+
let note = "";
|
|
1684
1761
|
let preState = null;
|
|
1685
1762
|
try {
|
|
1686
1763
|
if (step.action === "navigate") {
|
|
@@ -1750,9 +1827,9 @@ export class BrowserEngine {
|
|
|
1750
1827
|
: ` hover revealed nothing within ${HOVER_REVEAL_WINDOW_MS / 1000}s`);
|
|
1751
1828
|
}
|
|
1752
1829
|
else if (step.action === "type") {
|
|
1830
|
+
await this.noteProbe(step.value ?? "", step.target ?? "the field");
|
|
1753
1831
|
const fillNote = await this.fillOrAppend(loc, step.value ?? "", step.replace ?? false);
|
|
1754
|
-
|
|
1755
|
-
transcript.push(` ${fillNote.trim()}`);
|
|
1832
|
+
note = fillNote;
|
|
1756
1833
|
if (step.pressEnter) {
|
|
1757
1834
|
if (this.readOnly) {
|
|
1758
1835
|
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
@@ -1821,19 +1898,20 @@ export class BrowserEngine {
|
|
|
1821
1898
|
/* coverage bookkeeping must never fail the plan */
|
|
1822
1899
|
}
|
|
1823
1900
|
}
|
|
1901
|
+
await this.scanForInjections();
|
|
1824
1902
|
const violations = this.oracles.drain();
|
|
1825
|
-
const mutations = this.drainMutations() + this.drainBlocked();
|
|
1903
|
+
const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
|
|
1826
1904
|
// Abort only on NEW violations: a known-failing endpoint repeating on
|
|
1827
1905
|
// every navigation must not make every plan abort at step 1.
|
|
1828
1906
|
if (violations.some((v) => !v.repeat)) {
|
|
1829
|
-
transcript.push(`${desc} → OK, but oracle fired:${formatViolations(violations)}${mutations}`);
|
|
1907
|
+
transcript.push(`${desc} → OK${note}, but oracle fired:${formatViolations(violations)}${mutations}`);
|
|
1830
1908
|
transcript.push(`PLAN ABORTED at step ${i + 1} — investigate before continuing.`);
|
|
1831
1909
|
break;
|
|
1832
1910
|
}
|
|
1833
1911
|
const forcedNote = forcedClick
|
|
1834
1912
|
? " (forced — the strict click timed out on this element's hit-test/stability check but a forced click still landed; something may render on top of it or delegate via a label, cross-check GEOMETRY overlaps before calling it a bug)"
|
|
1835
1913
|
: "";
|
|
1836
|
-
transcript.push(`${desc} → OK (${page.url()})${mutations}${forcedNote}`);
|
|
1914
|
+
transcript.push(`${desc} → OK (${page.url()})${note}${mutations}${forcedNote}`);
|
|
1837
1915
|
}
|
|
1838
1916
|
catch (err) {
|
|
1839
1917
|
const fullMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -1909,6 +1987,125 @@ export class BrowserEngine {
|
|
|
1909
1987
|
this.logAction({ action: "screenshot", url: page.url() });
|
|
1910
1988
|
return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
|
|
1911
1989
|
}
|
|
1990
|
+
/**
|
|
1991
|
+
* What the live view shows next to a session's name. The task is what the
|
|
1992
|
+
* agent said the session is for; the objective is the goal of the journey it
|
|
1993
|
+
* is on right now (scout_journey). Both are the agent's own words — the
|
|
1994
|
+
* engine sees tool calls, never the reasoning behind them. Neither is
|
|
1995
|
+
* redacted here: the caller that writes them anywhere does that.
|
|
1996
|
+
*/
|
|
1997
|
+
get liveDescription() {
|
|
1998
|
+
return {
|
|
1999
|
+
mode: this.mode,
|
|
2000
|
+
browser: this.engineName,
|
|
2001
|
+
headed: this.headed,
|
|
2002
|
+
...(this.task ? { task: this.task } : {}),
|
|
2003
|
+
...(this.journey ? { objective: this.journey.goal, objectiveSince: new Date(this.journey.startedAt).toISOString() } : {}),
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* A frame for somebody WATCHING the run, as opposed to scout_screenshot,
|
|
2008
|
+
* which is the agent looking. It is not logged: the action log is the repro
|
|
2009
|
+
* trace attached to findings, and a person glancing at the dashboard is not
|
|
2010
|
+
* a step anyone should replay. It is also bounded, because the moment a
|
|
2011
|
+
* viewer most wants a picture is when the renderer has wedged.
|
|
2012
|
+
*/
|
|
2013
|
+
async liveShot(timeoutMs = 3000) {
|
|
2014
|
+
const page = this.page;
|
|
2015
|
+
if (!page || page.isClosed())
|
|
2016
|
+
return null;
|
|
2017
|
+
let timer;
|
|
2018
|
+
// The driver's own timeout covers a slow capture; the race covers a
|
|
2019
|
+
// renderer that never answers the protocol at all.
|
|
2020
|
+
return Promise.race([
|
|
2021
|
+
page.screenshot({ type: "jpeg", quality: 55, fullPage: false, timeout: timeoutMs }).catch(() => null),
|
|
2022
|
+
new Promise((resolve) => {
|
|
2023
|
+
timer = setTimeout(() => resolve(null), timeoutMs + 500);
|
|
2024
|
+
}),
|
|
2025
|
+
]).finally(() => clearTimeout(timer));
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Push frames of this session's page until the returned function is called.
|
|
2029
|
+
* The stream follows the session rather than one tab: adopting a popup
|
|
2030
|
+
* replaces `this.page`, and a stream left on the old tab would show a page
|
|
2031
|
+
* the session is no longer driving.
|
|
2032
|
+
*/
|
|
2033
|
+
async startScreencast(onFrame, onEnd = () => { }) {
|
|
2034
|
+
if (!this.page || this.page.isClosed())
|
|
2035
|
+
return null;
|
|
2036
|
+
let stopped = false;
|
|
2037
|
+
let bound = null;
|
|
2038
|
+
let release = null;
|
|
2039
|
+
// Ticks in a row with no page to take frames from: a re-attach passes
|
|
2040
|
+
// through a few, a closed session never comes back.
|
|
2041
|
+
let pageless = 0;
|
|
2042
|
+
const bind = async (page) => {
|
|
2043
|
+
if (screencastSupport(this.engineName) === "cdp") {
|
|
2044
|
+
const cdp = await page.context().newCDPSession(page);
|
|
2045
|
+
try {
|
|
2046
|
+
cdp.on("Page.screencastFrame", (frame) => {
|
|
2047
|
+
if (!stopped)
|
|
2048
|
+
onFrame(Buffer.from(frame.data, "base64"));
|
|
2049
|
+
void cdp.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => { });
|
|
2050
|
+
});
|
|
2051
|
+
await cdp.send("Page.startScreencast", { format: "jpeg", quality: 55, maxWidth: 1280, maxHeight: 900, everyNthFrame: 2 });
|
|
2052
|
+
}
|
|
2053
|
+
catch (err) {
|
|
2054
|
+
// Not bound: the timer tries this page again on its next tick.
|
|
2055
|
+
await cdp.detach().catch(() => { });
|
|
2056
|
+
throw err;
|
|
2057
|
+
}
|
|
2058
|
+
release = async () => {
|
|
2059
|
+
await cdp.send("Page.stopScreencast").catch(() => { });
|
|
2060
|
+
await cdp.detach().catch(() => { });
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
else {
|
|
2064
|
+
release = null;
|
|
2065
|
+
}
|
|
2066
|
+
bound = page;
|
|
2067
|
+
};
|
|
2068
|
+
const stop = async () => {
|
|
2069
|
+
stopped = true;
|
|
2070
|
+
clearInterval(timer);
|
|
2071
|
+
const current = release;
|
|
2072
|
+
release = null;
|
|
2073
|
+
await current?.();
|
|
2074
|
+
};
|
|
2075
|
+
await bind(this.page).catch(() => { });
|
|
2076
|
+
// One timer does both jobs: it notices a replaced tab, and where the
|
|
2077
|
+
// browser cannot push frames it is also what takes them.
|
|
2078
|
+
const timer = setInterval(() => {
|
|
2079
|
+
if (stopped)
|
|
2080
|
+
return;
|
|
2081
|
+
const page = this.page;
|
|
2082
|
+
if (!page || page.isClosed()) {
|
|
2083
|
+
pageless += 1;
|
|
2084
|
+
if (pageless >= SCREENCAST_PAGELESS_TICKS)
|
|
2085
|
+
void stop().finally(onEnd);
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
pageless = 0;
|
|
2089
|
+
if (page !== bound) {
|
|
2090
|
+
const previous = release;
|
|
2091
|
+
release = null;
|
|
2092
|
+
void (async () => {
|
|
2093
|
+
await previous?.();
|
|
2094
|
+
if (!stopped)
|
|
2095
|
+
await bind(page).catch(() => { });
|
|
2096
|
+
})();
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (screencastSupport(this.engineName) === "poll") {
|
|
2100
|
+
void this.liveShot(1500).then((jpeg) => {
|
|
2101
|
+
if (jpeg && !stopped)
|
|
2102
|
+
onFrame(jpeg);
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
}, 500);
|
|
2106
|
+
timer.unref();
|
|
2107
|
+
return stop;
|
|
2108
|
+
}
|
|
1912
2109
|
get currentState() {
|
|
1913
2110
|
return this.currentFingerprint;
|
|
1914
2111
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM-injection oracle's rules: what a typed value has to look like to be
|
|
3
|
+
* worth watching for, how to look for it on a later page, and how to describe
|
|
4
|
+
* it when it turns up as an element.
|
|
5
|
+
*
|
|
6
|
+
* The engine never tells the agent what to type. It only remembers the shape
|
|
7
|
+
* of any markup-shaped value the agent chose to type, and checks every page it
|
|
8
|
+
* sees afterwards for an element of that shape. A value that comes back as an
|
|
9
|
+
* element rather than text means whoever opens that page runs the input:
|
|
10
|
+
* stored or reflected injection (XSS).
|
|
11
|
+
*
|
|
12
|
+
* An element counts as the typed one only when it carries exactly the
|
|
13
|
+
* payload's attributes (an app's own link has extras such as a testid; an
|
|
14
|
+
* injected one has just what was typed) and, when the payload had text, that
|
|
15
|
+
* text. A payload with neither is not watched: `<script>` or `<br>` on their
|
|
16
|
+
* own would match any page. No browser is needed for these rules, so they are
|
|
17
|
+
* table-tested; the one DOM query lives in browser.ts.
|
|
18
|
+
*/
|
|
19
|
+
import { normalizePath } from "./fingerprint.js";
|
|
20
|
+
/** Most probes a session keeps. Every page is checked for each in one query, so the list can be generous: a fuzzing pass types many. */
|
|
21
|
+
export const MAX_PROBES = 60;
|
|
22
|
+
/** Text is compared on this many characters, on both sides, so a long fuzz value still matches its element. */
|
|
23
|
+
export const INJECTION_TEXT_MAX = 200;
|
|
24
|
+
/** Most matching elements reported per probe per page. */
|
|
25
|
+
export const MAX_HITS = 20;
|
|
26
|
+
const OPEN_RE = /<([a-zA-Z][a-zA-Z0-9-]*)/;
|
|
27
|
+
// One attribute at a time, anchored where the last one ended: a slash between
|
|
28
|
+
// attributes is the same as a space to a browser (`<svg/onload=…>`). Each
|
|
29
|
+
// match consumes at least one character, so a hostile value cannot make the
|
|
30
|
+
// parse backtrack.
|
|
31
|
+
const ATTR_RE = /[\s/]*([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/y;
|
|
32
|
+
const CLOSE_RE = /[\s/]*>/y;
|
|
33
|
+
/** Values longer than this are cut before parsing; no payload needs more. */
|
|
34
|
+
const MAX_VALUE_LENGTH = 2000;
|
|
35
|
+
/** Attribute names that can go into a selector as they are. Anything else is dropped rather than escaped. */
|
|
36
|
+
const ATTR_NAME_RE = /^[A-Za-z_][-A-Za-z0-9_]*$/;
|
|
37
|
+
function cssValue(value) {
|
|
38
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\a ").replace(/\r/g, "\\d ");
|
|
39
|
+
}
|
|
40
|
+
function routeOf(url) {
|
|
41
|
+
try {
|
|
42
|
+
return normalizePath(new URL(url).pathname);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return url || "?";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The shape for a typed value, or null when the value holds no element worth
|
|
50
|
+
* watching: plain text, an email, a URL, a stray `<`, and an element with
|
|
51
|
+
* nothing to tell it apart by (`<script>`, `<br>`) are none of the oracle's
|
|
52
|
+
* business.
|
|
53
|
+
*/
|
|
54
|
+
export function probeShape(raw) {
|
|
55
|
+
const value = raw.slice(0, MAX_VALUE_LENGTH);
|
|
56
|
+
const open = OPEN_RE.exec(value);
|
|
57
|
+
if (!open)
|
|
58
|
+
return null;
|
|
59
|
+
const tag = open[1].toLowerCase();
|
|
60
|
+
const attrs = [];
|
|
61
|
+
let pos = open.index + open[0].length;
|
|
62
|
+
for (;;) {
|
|
63
|
+
CLOSE_RE.lastIndex = pos;
|
|
64
|
+
const close = CLOSE_RE.exec(value);
|
|
65
|
+
if (close) {
|
|
66
|
+
pos += close[0].length;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
ATTR_RE.lastIndex = pos;
|
|
70
|
+
const a = ATTR_RE.exec(value);
|
|
71
|
+
// Neither an attribute nor the end of the tag: not an element, just a "<".
|
|
72
|
+
if (!a)
|
|
73
|
+
return null;
|
|
74
|
+
pos += a[0].length;
|
|
75
|
+
const name = a[1].toLowerCase();
|
|
76
|
+
if (!ATTR_NAME_RE.test(name))
|
|
77
|
+
continue;
|
|
78
|
+
attrs.push([name, a[2] ?? a[3] ?? a[4] ?? ""]);
|
|
79
|
+
}
|
|
80
|
+
const rest = value.slice(pos);
|
|
81
|
+
const close = rest.toLowerCase().indexOf(`</${tag}`);
|
|
82
|
+
const inner = (close >= 0 ? rest.slice(0, close) : rest).trim();
|
|
83
|
+
const text = inner ? inner.slice(0, INJECTION_TEXT_MAX) : null;
|
|
84
|
+
if (attrs.length === 0 && text === null)
|
|
85
|
+
return null;
|
|
86
|
+
return {
|
|
87
|
+
payload: value.slice(0, 200),
|
|
88
|
+
tag,
|
|
89
|
+
attrs,
|
|
90
|
+
text,
|
|
91
|
+
selector: tag + attrs.map(([name, v]) => `[${name}="${cssValue(v)}"]`).join(""),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function injectionProbe(value, field, typedOn, baseline = 0) {
|
|
95
|
+
const shape = probeShape(value);
|
|
96
|
+
return shape ? { ...shape, field, typedOn, baseline } : null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The session's probe list with one more: a payload already on it is kept as
|
|
100
|
+
* first seen (its baseline came from where it was first typed), and past the
|
|
101
|
+
* cap the oldest goes. Returns the list to keep, so the rule is testable.
|
|
102
|
+
*/
|
|
103
|
+
export function rememberProbe(probes, probe) {
|
|
104
|
+
if (probes.some((p) => p.payload === probe.payload))
|
|
105
|
+
return [...probes];
|
|
106
|
+
const kept = probes.length >= MAX_PROBES ? probes.slice(probes.length - MAX_PROBES + 1) : [...probes];
|
|
107
|
+
return [...kept, probe];
|
|
108
|
+
}
|
|
109
|
+
export function probeQueries(probes) {
|
|
110
|
+
return probes.map((p) => ({ selector: p.selector, attrs: p.attrs, text: p.text }));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The script the page runs to find matching elements: the same rules as
|
|
114
|
+
* matchesElement, applied in the page so the cap counts matches, not
|
|
115
|
+
* candidates. Returns [{ index, outer }] with at most MAX_HITS per probe.
|
|
116
|
+
*/
|
|
117
|
+
export function probeScript(queries) {
|
|
118
|
+
return (`(() => { const queries = ${JSON.stringify(queries)}; const max = ${MAX_HITS}; const textMax = ${INJECTION_TEXT_MAX}; const out = []; ` +
|
|
119
|
+
`queries.forEach((q, index) => { let els; try { els = document.querySelectorAll(q.selector); } catch { return; } let n = 0; ` +
|
|
120
|
+
`for (const el of els) { if (n >= max) break; if (el.attributes.length !== q.attrs.length) continue; ` +
|
|
121
|
+
`if (!q.attrs.every(([name, value]) => el.getAttribute(name) === value)) continue; ` +
|
|
122
|
+
`if (q.text !== null && (el.textContent || '').trim().slice(0, textMax) !== q.text) continue; ` +
|
|
123
|
+
`out.push({ index, outer: el.outerHTML.slice(0, 200) }); n += 1; } }); return out; })()`);
|
|
124
|
+
}
|
|
125
|
+
/** The rule the script applies, in one place a test can reach. */
|
|
126
|
+
export function matchesElement(query, el) {
|
|
127
|
+
if (Object.keys(el.attrs).length !== query.attrs.length)
|
|
128
|
+
return false;
|
|
129
|
+
if (!query.attrs.every(([name, value]) => el.attrs[name] === value))
|
|
130
|
+
return false;
|
|
131
|
+
return query.text === null || el.text.trim().slice(0, INJECTION_TEXT_MAX) === query.text;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Which hits on a page are injections to report: more matching elements than
|
|
135
|
+
* the page the value was typed on already held (shared chrome such as a nav
|
|
136
|
+
* link is the same on every page), and not reported for this route before.
|
|
137
|
+
*/
|
|
138
|
+
export function newInjections(probes, hits, url, reported) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const route = routeOf(url);
|
|
141
|
+
probes.forEach((probe, index) => {
|
|
142
|
+
const mine = hits.filter((h) => h.index === index);
|
|
143
|
+
if (mine.length <= probe.baseline)
|
|
144
|
+
return;
|
|
145
|
+
const key = `${probe.payload}|${route}`;
|
|
146
|
+
if (reported.has(key))
|
|
147
|
+
return;
|
|
148
|
+
reported.add(key);
|
|
149
|
+
out.push({ probe, outer: mine[mine.length - 1]?.outer ?? "", key });
|
|
150
|
+
});
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The violation's detail: what it became, where it fired, and where it was
|
|
155
|
+
* typed. The element comes first because the oracle log signs a violation on
|
|
156
|
+
* the first characters of its detail: two payloads that fired on one page
|
|
157
|
+
* must not read as one.
|
|
158
|
+
*/
|
|
159
|
+
export function describeInjection(probe, url, outerHtml) {
|
|
160
|
+
return (`${outerHtml.slice(0, 160)} on ${routeOf(url)} is a value typed into ${probe.field} on ${routeOf(probe.typedOn)}, come back as markup — ` +
|
|
161
|
+
`whoever opens this page runs what was typed there (stored or reflected injection, XSS)`);
|
|
162
|
+
}
|