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.
@@ -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: "journey:start", target: goal, url: page.url() });
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: "journey:end", target: j.goal, url: page.url(), result: completed ? "completed" : "abandoned" });
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
- if (fillNote)
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
+ }