scenescout 3.14.1 → 3.16.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 +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +198 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
package/dist/engine/png.js
CHANGED
|
@@ -236,3 +236,130 @@ export function diffImages(before, after, threshold = DIFF_THRESHOLD) {
|
|
|
236
236
|
image: { width, height, data },
|
|
237
237
|
};
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Shrinks a picture to `width` × `height` by averaging the source pixels each
|
|
241
|
+
* output pixel covers, so text and thin lines fade rather than vanish the way
|
|
242
|
+
* picking one pixel in every few would make them. Never enlarges: a size
|
|
243
|
+
* larger than the source in either direction is the source's in it.
|
|
244
|
+
*/
|
|
245
|
+
export function shrinkImage(img, width, height) {
|
|
246
|
+
const w = Math.max(1, Math.min(img.width, Math.floor(width)));
|
|
247
|
+
const h = Math.max(1, Math.min(img.height, Math.floor(height)));
|
|
248
|
+
if (w === img.width && h === img.height)
|
|
249
|
+
return img;
|
|
250
|
+
const data = new Uint8Array(w * h * 4);
|
|
251
|
+
const sx = img.width / w;
|
|
252
|
+
const sy = img.height / h;
|
|
253
|
+
for (let y = 0; y < h; y++) {
|
|
254
|
+
const y0 = Math.floor(y * sy);
|
|
255
|
+
const y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
|
|
256
|
+
for (let x = 0; x < w; x++) {
|
|
257
|
+
const x0 = Math.floor(x * sx);
|
|
258
|
+
const x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
|
|
259
|
+
let r = 0;
|
|
260
|
+
let g = 0;
|
|
261
|
+
let b = 0;
|
|
262
|
+
let a = 0;
|
|
263
|
+
for (let yy = y0; yy < y1; yy++) {
|
|
264
|
+
for (let xx = x0; xx < x1; xx++) {
|
|
265
|
+
const i = (yy * img.width + xx) * 4;
|
|
266
|
+
r += img.data[i];
|
|
267
|
+
g += img.data[i + 1];
|
|
268
|
+
b += img.data[i + 2];
|
|
269
|
+
a += img.data[i + 3];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const n = (y1 - y0) * (x1 - x0);
|
|
273
|
+
const o = (y * w + x) * 4;
|
|
274
|
+
data[o] = Math.round(r / n);
|
|
275
|
+
data[o + 1] = Math.round(g / n);
|
|
276
|
+
data[o + 2] = Math.round(b / n);
|
|
277
|
+
data[o + 3] = Math.round(a / n);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { width: w, height: h, data };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Writes a picture as a PNG as small as this encoder makes one: three
|
|
284
|
+
* channels when every pixel is opaque (a screenshot always is), and each row
|
|
285
|
+
* filtered by whichever of the five PNG filters leaves the smallest sum, the
|
|
286
|
+
* usual heuristic. encodePng stays as it is, since the diff is written by it.
|
|
287
|
+
*/
|
|
288
|
+
export function encodePngCompact(img) {
|
|
289
|
+
if (img.data.length !== img.width * img.height * 4)
|
|
290
|
+
throw new Error("the pixel data is not width × height × 4 bytes");
|
|
291
|
+
let opaque = true;
|
|
292
|
+
for (let i = 3; i < img.data.length; i += 4)
|
|
293
|
+
if (img.data[i] !== 255) {
|
|
294
|
+
opaque = false;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
const channels = opaque ? 3 : 4;
|
|
298
|
+
const stride = img.width * channels;
|
|
299
|
+
const rows = new Uint8Array(img.height * stride);
|
|
300
|
+
if (opaque) {
|
|
301
|
+
for (let i = 0, j = 0; i < img.data.length; i += 4, j += 3) {
|
|
302
|
+
rows[j] = img.data[i];
|
|
303
|
+
rows[j + 1] = img.data[i + 1];
|
|
304
|
+
rows[j + 2] = img.data[i + 2];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else
|
|
308
|
+
rows.set(img.data);
|
|
309
|
+
const raw = Buffer.alloc(img.height * (stride + 1));
|
|
310
|
+
const trial = new Uint8Array(stride);
|
|
311
|
+
for (let y = 0; y < img.height; y++) {
|
|
312
|
+
const row = y * stride;
|
|
313
|
+
let best = Infinity;
|
|
314
|
+
const out = y * (stride + 1);
|
|
315
|
+
for (let filter = 0; filter <= 4; filter++) {
|
|
316
|
+
let sum = 0;
|
|
317
|
+
for (let x = 0; x < stride; x++) {
|
|
318
|
+
const left = x >= channels ? rows[row + x - channels] : 0;
|
|
319
|
+
const up = y > 0 ? rows[row - stride + x] : 0;
|
|
320
|
+
const upLeft = y > 0 && x >= channels ? rows[row - stride + x - channels] : 0;
|
|
321
|
+
const v = rows[row + x];
|
|
322
|
+
const p = filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? up : filter === 3 ? (left + up) >> 1 : paeth(left, up, upLeft);
|
|
323
|
+
const f = (v - p) & 0xff;
|
|
324
|
+
trial[x] = f;
|
|
325
|
+
sum += f < 128 ? f : 256 - f;
|
|
326
|
+
}
|
|
327
|
+
if (sum < best) {
|
|
328
|
+
best = sum;
|
|
329
|
+
raw[out] = filter;
|
|
330
|
+
raw.set(trial, out + 1);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const header = Buffer.alloc(13);
|
|
335
|
+
header.writeUInt32BE(img.width, 0);
|
|
336
|
+
header.writeUInt32BE(img.height, 4);
|
|
337
|
+
header[8] = 8;
|
|
338
|
+
header[9] = opaque ? 2 : 6;
|
|
339
|
+
return Buffer.concat([SIGNATURE, chunk("IHDR", header), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", new Uint8Array(0))]);
|
|
340
|
+
}
|
|
341
|
+
/** The most a picture is shrunk to fit its bytes: below this on its longer side it is no longer worth showing. */
|
|
342
|
+
export const FIT_MIN_SIDE = 120;
|
|
343
|
+
/**
|
|
344
|
+
* A picture within `maxSide` pixels on its longer side and `maxBytes` as a
|
|
345
|
+
* PNG: shrunk to the side first, then, while the file is still too large,
|
|
346
|
+
* shrunk again in proportion to how far over it is. Null when even
|
|
347
|
+
* FIT_MIN_SIDE on its longer side is over the bytes: nothing worth looking at
|
|
348
|
+
* fits, and the caller says so rather than keeping a smudge.
|
|
349
|
+
*/
|
|
350
|
+
export function fitPicture(img, maxSide, maxBytes) {
|
|
351
|
+
const longer = Math.max(img.width, img.height);
|
|
352
|
+
let scale = Math.min(1, maxSide / longer);
|
|
353
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
354
|
+
const width = Math.max(1, Math.round(img.width * scale));
|
|
355
|
+
const height = Math.max(1, Math.round(img.height * scale));
|
|
356
|
+
const png = encodePngCompact(shrinkImage(img, width, height));
|
|
357
|
+
if (png.length <= maxBytes)
|
|
358
|
+
return { png, width, height, shrunk: width !== img.width || height !== img.height };
|
|
359
|
+
if (Math.max(width, height) <= FIT_MIN_SIDE)
|
|
360
|
+
return null;
|
|
361
|
+
// Bytes go roughly with area, so the side goes with the square root; a little under, so one more pass usually fits.
|
|
362
|
+
scale = Math.max(FIT_MIN_SIDE / longer, scale * Math.sqrt(maxBytes / png.length) * 0.9);
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
package/dist/engine/policy.js
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
const DESTRUCTIVE_PATTERNS = [
|
|
7
7
|
/\bdelete\b/i,
|
|
8
|
-
|
|
8
|
+
// "Remove" a filter chip drops a condition from the view: "Remove Status:
|
|
9
|
+
// Open filter" re-queries the list, the same sense the reset rule exempts.
|
|
10
|
+
// A label whose object, at its end, is a filter or a chip, or a test id with
|
|
11
|
+
// the chip before the verb ("filter-remove"), is exempt; "Remove member",
|
|
12
|
+
// and "Remove member. Use the filter to find others.", still count.
|
|
13
|
+
/(?<!\b(?:filters?|chips?)[\s-]{1,3})\bremove\b(?![^\n]{0,60}?\b(?:filters?|chips?)\W*$)/i,
|
|
9
14
|
/\brevoke\b/i,
|
|
10
15
|
/\bdestroy\b/i,
|
|
11
16
|
/\bpurge\b/i,
|
|
@@ -20,13 +25,23 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
20
25
|
// view/form senses; everything else ("Reset workspace", "Factory reset",
|
|
21
26
|
// "Reset all data") still counts.
|
|
22
27
|
/\breset\b(?!\s+(filters?|zoom|search|view|sort|order|form|password|layout|columns?|selection|preferences?|defaults?))/i,
|
|
23
|
-
|
|
28
|
+
// "Discard" names two different things. "Discard changes", "Discard your
|
|
29
|
+
// edits" and "Discard unsaved changes" drop what the tester typed and has not
|
|
30
|
+
// sent: no record exists to lose, and refusing it left a session stuck on a
|
|
31
|
+
// dirty form behind the app's own leave-confirmation. Those senses are
|
|
32
|
+
// exempt, written as words or as a test id ("discard-changes"); "Discard
|
|
33
|
+
// draft", "Discard record" and a bare "Discard" still count.
|
|
34
|
+
// If the confirm does send a write, the wire policy judges that request as it
|
|
35
|
+
// judges any other, and `discard` is a destructive verb in a path there.
|
|
36
|
+
/\bdiscard\b(?![\s_-]+(?:(?:your|my|all|the|any)[\s_-]+)?(?:unsaved[\s_-]+|pending[\s_-]+|local[\s_-]+|draft[\s_-]+)?(?:changes|edits)\b)/i,
|
|
24
37
|
/\bcancel subscription\b/i,
|
|
25
|
-
// The verb: a control that signs the user off,
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// "
|
|
29
|
-
|
|
38
|
+
// The verb: a control that signs the user off, starting the label ("Sign
|
|
39
|
+
// off", "(Sign off)", a test id "sign-off-button") or joined to another verb
|
|
40
|
+
// ("Save and sign off", "Save & sign off"). Anywhere else it is the noun
|
|
41
|
+
// approval apps label things with ("Needs sign-off", "Manager sign-off",
|
|
42
|
+
// "Final sign-off recorded"), which destroys nothing. Told by position, not
|
|
43
|
+
// by a list of the words that may come before it: any modifier can.
|
|
44
|
+
/(?:^[^\p{L}\p{N}]*|\b(?:and|then)\s+|&\s*)sign(?: |-)?off\b/iu,
|
|
30
45
|
// The tool is generic — destructive labels come in many languages.
|
|
31
46
|
/\b(eliminar|borrar|suprimir)\b/i, // es
|
|
32
47
|
/\b(excluir|apagar|remover)\b/i, // pt
|
|
@@ -55,7 +70,7 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
55
70
|
* destructive. So the body is matched only for STRUCTURED destructive intent.
|
|
56
71
|
*/
|
|
57
72
|
/** A destructive verb occupying a URL PATH segment. Bare keywords are meaningful here — a path is not prose. */
|
|
58
|
-
const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke|deactivate|wipe|bulk[-_]?delete|force[-_]?delete)(\/|\b|_)/i;
|
|
73
|
+
const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke|deactivate|wipe|discard|bulk[-_]?delete|force[-_]?delete)(\/|\b|_)/i;
|
|
59
74
|
/**
|
|
60
75
|
* Structured destructive intent inside a body — never a bare keyword.
|
|
61
76
|
* Two shapes: (a) a GraphQL destructive mutation (the 200-char window after
|
|
@@ -72,7 +87,7 @@ const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke
|
|
|
72
87
|
* and `verb` are not in the key list at all — they read as prose far more often
|
|
73
88
|
* than as command fields.
|
|
74
89
|
*/
|
|
75
|
-
const DESTRUCTIVE_BODY_RE = /\bmutation\b[\s\S]{0,200}?\b(?:delete|remove|archive|destroy|purge|revoke)[A-Za-z_]|(?:^|[{,[\s]*["']|[?&])\s*(?:action|operation|op|method|_method|command|cmd)["']?\s*[:=]\s*["']?(?:delete|remove|purge|destroy|archive|revoke|deactivate|wipe)\b/i;
|
|
90
|
+
const DESTRUCTIVE_BODY_RE = /\bmutation\b[\s\S]{0,200}?\b(?:delete|remove|archive|destroy|purge|revoke)[A-Za-z_]|(?:^|[{,[\s]*["']|[?&])\s*(?:action|operation|op|method|_method|command|cmd)["']?\s*[:=]\s*["']?(?:delete|remove|purge|destroy|archive|revoke|deactivate|wipe|discard)\b/i;
|
|
76
91
|
export function isDestructiveWire(url, body) {
|
|
77
92
|
return DESTRUCTIVE_URL_RE.test(url) || (!!body && DESTRUCTIVE_BODY_RE.test(body.slice(0, 2000)));
|
|
78
93
|
}
|
|
@@ -128,11 +143,226 @@ export function isDestructive(...labels) {
|
|
|
128
143
|
});
|
|
129
144
|
});
|
|
130
145
|
}
|
|
146
|
+
/** The most words a click target's own text may have and still be read as its command. */
|
|
147
|
+
export const COMMAND_TEXT_WORDS = 4;
|
|
148
|
+
/** A short verb phrase, or null: at most COMMAND_TEXT_WORDS words and no sentence. */
|
|
149
|
+
function commandText(text) {
|
|
150
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
151
|
+
return words.length > 0 && words.length <= COMMAND_TEXT_WORDS && !SENTENCE_RE.test(text) ? text : null;
|
|
152
|
+
}
|
|
153
|
+
/** Roles whose accessible name is the label of the one thing a click does. */
|
|
154
|
+
const LABELLED_CONTROL_ROLES = new Set([
|
|
155
|
+
"button",
|
|
156
|
+
"link",
|
|
157
|
+
"menuitem",
|
|
158
|
+
"menuitemcheckbox",
|
|
159
|
+
"menuitemradio",
|
|
160
|
+
"tab",
|
|
161
|
+
"option",
|
|
162
|
+
"checkbox",
|
|
163
|
+
"radio",
|
|
164
|
+
"switch",
|
|
165
|
+
"treeitem",
|
|
166
|
+
"image",
|
|
167
|
+
]);
|
|
168
|
+
/**
|
|
169
|
+
* The label that makes a listed element destructive, or null. A control is
|
|
170
|
+
* judged by its OWN name, never by what it merely contains:
|
|
171
|
+
*
|
|
172
|
+
* - A button, link, menu item, tab or option (by tag or by role) is named
|
|
173
|
+
* by what it does, and that name is judged whole, however long: a button
|
|
174
|
+
* "Delete all my saved data" is refused. The word cap below is only for
|
|
175
|
+
* containers, whose text is the record they show.
|
|
176
|
+
* - A dropdown (a `<select>`, a combobox, a listbox) is named by its options,
|
|
177
|
+
* all of them, so a filter offering "All, Create, Update, Delete" read as a
|
|
178
|
+
* delete control and choosing "Create" was refused. Choosing is judged where
|
|
179
|
+
* the value is known: scout_select vets the chosen option, and an option
|
|
180
|
+
* clicked in a custom list is a control of its own. Only its test id, and a
|
|
181
|
+
* control covering its centre, are judged here.
|
|
182
|
+
* - Anything else (a row, a card, a heading, a panel listed for its test id
|
|
183
|
+
* or a click handler) is judged by its test id and by the control covering
|
|
184
|
+
* its centre, where a click on it lands: a row whose middle IS a delete
|
|
185
|
+
* button is still refused, and the buttons inside it are listed, and
|
|
186
|
+
* refused, on their own. Its text is the record it shows ("Archive Test
|
|
187
|
+
* Widget", "Final sign-off recorded"), not a command, so the text counts
|
|
188
|
+
* only for a click target whose own text is a short verb phrase
|
|
189
|
+
* (COMMAND_TEXT_WORDS words, no sentence): a clickable div saying "Delete".
|
|
190
|
+
* A heading is never judged by its text.
|
|
191
|
+
*
|
|
192
|
+
* When the page could not say what the own text is, the whole name stands in
|
|
193
|
+
* and is judged whole, so a reading that failed can only refuse more.
|
|
194
|
+
*/
|
|
195
|
+
export function destructiveLabelOf(c) {
|
|
196
|
+
const first = (...labels) => labels.find((l) => typeof l === "string" && l.length > 0 && isDestructive(l)) ?? null;
|
|
197
|
+
const centre = c.centre ?? [];
|
|
198
|
+
if (c.tag === "select" || c.role === "combobox" || c.role === "listbox")
|
|
199
|
+
return first(c.testid, ...centre);
|
|
200
|
+
if (LABELLED_CONTROL_ROLES.has(c.role) || c.tag === "button" || c.tag === "a")
|
|
201
|
+
return first(c.name, c.testid);
|
|
202
|
+
if (c.role === "heading" || /^h[1-6]$/i.test(c.tag))
|
|
203
|
+
return first(c.testid, ...centre);
|
|
204
|
+
const own = c.ownText == null ? c.name : c.interactive === false ? null : commandText(c.ownText);
|
|
205
|
+
return first(own, c.testid, ...centre);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* A dropdown pick, judged by the value asked for, the dropdown's test id and
|
|
209
|
+
* the label of the option it matches. The dropdown's own name is not judged
|
|
210
|
+
* (destructiveLabelOf), so this is the check, and a label that could not be
|
|
211
|
+
* read (null) refuses: an unvetted pick could be "Delete".
|
|
212
|
+
*/
|
|
213
|
+
export function pickIsDestructive(value, testid, optionLabel) {
|
|
214
|
+
return optionLabel === null || isDestructive(value, testid, optionLabel);
|
|
215
|
+
}
|
|
131
216
|
export function destructiveRefusal(label, mode = "read-only") {
|
|
132
217
|
return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
|
|
133
218
|
`This run is ${mode}; do not attempt this element again. If destructive flows must be tested, ` +
|
|
134
219
|
`the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
|
|
135
220
|
}
|
|
221
|
+
/** The `why` of a page move refused as a possible escape from a foreign frame's sandbox. */
|
|
222
|
+
export const ESCAPE_REFUSAL = "a possible frame escape";
|
|
223
|
+
/** New blocked endpoints listed by name in one notice; the rest are counted. */
|
|
224
|
+
export const BLOCK_NOTICE_LIST = 5;
|
|
225
|
+
/** What a blocked write is remembered by: its method and URL, without query or fragment. */
|
|
226
|
+
export function blockSignature(sig) {
|
|
227
|
+
return sig.replace(/[?#].*$/, "");
|
|
228
|
+
}
|
|
229
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
230
|
+
/**
|
|
231
|
+
* The write-policy block notice, said once. A page that beacons to a
|
|
232
|
+
* monitoring endpoint on every load had every tool result open with the same
|
|
233
|
+
* blocked request five times over and the whole explanation after it, which
|
|
234
|
+
* buried the action's own result and the blocks that mattered.
|
|
235
|
+
*
|
|
236
|
+
* So a session remembers what it has been told. Each endpoint (by
|
|
237
|
+
* blockSignature, per write rule) is named in full the first time it is
|
|
238
|
+
* blocked; after that it is counted on one line. The explanation is given in
|
|
239
|
+
* full once per rule, and later notices for a new endpoint refer back to it.
|
|
240
|
+
* Nothing about the blocking changes: every request is still refused, and
|
|
241
|
+
* every refusal is still reported, if only as a count.
|
|
242
|
+
*/
|
|
243
|
+
export class BlockNotices {
|
|
244
|
+
seen = new Set();
|
|
245
|
+
explained = new Set();
|
|
246
|
+
/** Forget everything said: a re-attached session starts a new conversation. */
|
|
247
|
+
reset() {
|
|
248
|
+
this.seen.clear();
|
|
249
|
+
this.explained.clear();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The notice for the writes refused since the last action, or "" when there
|
|
253
|
+
* were none. `createdCount` is how many records this run has created, which
|
|
254
|
+
* the safe-write advice names.
|
|
255
|
+
*/
|
|
256
|
+
notice(rule, blocked, createdCount = 0) {
|
|
257
|
+
if (blocked.length === 0)
|
|
258
|
+
return "";
|
|
259
|
+
const groups = new Map();
|
|
260
|
+
for (const b of blocked) {
|
|
261
|
+
const key = blockSignature(b.sig);
|
|
262
|
+
const g = groups.get(key);
|
|
263
|
+
if (g) {
|
|
264
|
+
g.count += 1;
|
|
265
|
+
g.allLate &&= b.late;
|
|
266
|
+
g.answered ||= !!b.answered;
|
|
267
|
+
if (b.why)
|
|
268
|
+
g.whys.add(b.why);
|
|
269
|
+
}
|
|
270
|
+
else
|
|
271
|
+
groups.set(key, { first: b, count: 1, allLate: b.late, answered: !!b.answered, whys: new Set(b.why ? [b.why] : []) });
|
|
272
|
+
}
|
|
273
|
+
const fresh = [];
|
|
274
|
+
const repeats = [];
|
|
275
|
+
for (const [key, g] of groups) {
|
|
276
|
+
const memo = `${rule} ${key}`;
|
|
277
|
+
if (this.seen.has(memo))
|
|
278
|
+
repeats.push([key, g]);
|
|
279
|
+
else {
|
|
280
|
+
this.seen.add(memo);
|
|
281
|
+
fresh.push([key, g]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const late = (g) => (g.allLate ? " (late — likely from a previous action or background traffic)" : "");
|
|
285
|
+
const times = (g) => (g.count > 1 ? ` ×${g.count}` : "");
|
|
286
|
+
const repeatCount = repeats.reduce((n, [, g]) => n + g.count, 0);
|
|
287
|
+
const repeatLine = repeats.length === 0
|
|
288
|
+
? ""
|
|
289
|
+
: `${plural(repeatCount, "repeat block")} of ${plural(repeats.length, "known endpoint")} (` +
|
|
290
|
+
repeats
|
|
291
|
+
.slice(0, 3)
|
|
292
|
+
.map(([key, g]) => `${key} ×${g.count}${g.allLate ? ", background" : ""}`)
|
|
293
|
+
.join("; ") +
|
|
294
|
+
(repeats.length > 3 ? `; +${repeats.length - 3} more` : "") +
|
|
295
|
+
`)`;
|
|
296
|
+
const head = `\n🛡 WRITE-POLICY blocked (${rule}): `;
|
|
297
|
+
if (fresh.length === 0)
|
|
298
|
+
return `${head}${repeatLine}, refused as before. The tester's safety policy, not an app bug.`;
|
|
299
|
+
const list = fresh
|
|
300
|
+
.slice(0, BLOCK_NOTICE_LIST)
|
|
301
|
+
.map(([, g]) => `${g.first.sig}${times(g)}${late(g)}`)
|
|
302
|
+
.join("; ");
|
|
303
|
+
const more = fresh.length > BLOCK_NOTICE_LIST ? ` (+${fresh.length - BLOCK_NOTICE_LIST} more new)` : "";
|
|
304
|
+
const also = repeatLine ? `; also ${repeatLine}` : "";
|
|
305
|
+
const whys = new Set(fresh.flatMap(([, g]) => [...g.whys]));
|
|
306
|
+
const escaped = whys.delete(ESCAPE_REFUSAL);
|
|
307
|
+
const foreign = [...whys];
|
|
308
|
+
const answered = fresh.some(([, g]) => g.answered);
|
|
309
|
+
const reasons = (foreign.length > 0
|
|
310
|
+
? `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. `
|
|
311
|
+
: "") +
|
|
312
|
+
(escaped
|
|
313
|
+
? `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. `
|
|
314
|
+
: "");
|
|
315
|
+
if (this.explained.has(rule)) {
|
|
316
|
+
return (`${head}${list}${more}${also}. The tester's safety policy, not an app bug (explained in full earlier in this session). ` +
|
|
317
|
+
reasons +
|
|
318
|
+
(answered ? `The page's request was answered with a 403 in the server's place, as before. ` : "")).trimEnd();
|
|
319
|
+
}
|
|
320
|
+
this.explained.add(rule);
|
|
321
|
+
return (`${head}${list}${more}${also}. ` +
|
|
322
|
+
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
323
|
+
reasons +
|
|
324
|
+
(answered
|
|
325
|
+
? `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. `
|
|
326
|
+
: "") +
|
|
327
|
+
(rule === "observe"
|
|
328
|
+
? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target. If a refused POST only reads (a search or query sent as POST), the user can name it in readPosts instead; never add one yourself.`
|
|
329
|
+
: rule === "read-only"
|
|
330
|
+
? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
|
|
331
|
+
: `In safe-write, updates/deletes are only allowed on resources this session created (${createdCount} so far).`));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The answer to a native dialog. alert, confirm and prompt are dismissed in
|
|
336
|
+
* the modes that protect data (observe, read-only) and accepted otherwise, as
|
|
337
|
+
* before: a confirm can stand between a click and a delete.
|
|
338
|
+
*
|
|
339
|
+
* `beforeunload` is the page asking whether to leave while it holds unsent
|
|
340
|
+
* input. Leaving sends no write of its own, and whatever the page sends as it
|
|
341
|
+
* goes is judged by the wire policy like any other request, but leaving does
|
|
342
|
+
* throw away what the tester typed. So it is the caller's choice: `leave: true`
|
|
343
|
+
* leaves, `leave: false` stays, and with no choice the mode decides, staying
|
|
344
|
+
* in observe and read-only. Either way the result says what happened (see
|
|
345
|
+
* dialogNote), because a dismissed one surfaced only as a bare ERR_ABORTED.
|
|
346
|
+
*/
|
|
347
|
+
export function dialogResponse(type, readOnly, leave) {
|
|
348
|
+
if (type === "beforeunload")
|
|
349
|
+
return (leave ?? !readOnly) ? "accept" : "dismiss";
|
|
350
|
+
return readOnly ? "dismiss" : "accept";
|
|
351
|
+
}
|
|
352
|
+
/** The line an action's result carries for a native dialog the page opened during it. */
|
|
353
|
+
export function dialogNote(d) {
|
|
354
|
+
const message = d.message.trim().replace(/\s+/g, " ").slice(0, 120);
|
|
355
|
+
if (d.type === "beforeunload") {
|
|
356
|
+
return d.response === "dismiss"
|
|
357
|
+
? `\n⚠ LEAVE CONFIRMATION: the page asked to confirm leaving (it holds input that was never sent), and the engine answered "stay"` +
|
|
358
|
+
`${d.leave === false ? " as asked (leave: false)" : ""}, so the navigation was cancelled and the page is unchanged. ` +
|
|
359
|
+
`This is not an app bug, and nothing was sent. To leave and discard that unsent input, repeat the action with leave: true.`
|
|
360
|
+
: `\nℹ LEAVE CONFIRMATION: the page asked to confirm leaving (it held input that was never sent), and the engine answered "leave"` +
|
|
361
|
+
`${d.leave === true ? " as asked (leave: true)" : ""}, so that unsent input is discarded. ` +
|
|
362
|
+
`Any request the page sends as it is left is judged by the write policy like any other.`;
|
|
363
|
+
}
|
|
364
|
+
return `\nℹ DIALOG (${d.type})${message ? `: "${message}"` : ""} — ${d.response === "accept" ? "accepted" : "dismissed"} by the engine.`;
|
|
365
|
+
}
|
|
136
366
|
export const WRITE_MODES = ["observe", "read-only", "safe-write", "destructive"];
|
|
137
367
|
/** The stricter of two modes: WRITE_MODES runs from the strictest to the loosest. */
|
|
138
368
|
export function stricterMode(a, b) {
|
|
@@ -572,6 +802,146 @@ export const FOREIGN_FRAME_SANDBOX = "sandbox allow-scripts allow-forms allow-sa
|
|
|
572
802
|
export function withForeignFrameSandbox(existing) {
|
|
573
803
|
return existing && existing.trim() ? `${existing}, ${FOREIGN_FRAME_SANDBOX}` : FOREIGN_FRAME_SANDBOX;
|
|
574
804
|
}
|
|
805
|
+
/** The most POST endpoints a session may name as reads. */
|
|
806
|
+
export const MAX_READ_POSTS = 20;
|
|
807
|
+
/** The environment variable naming POST endpoints that only read, for every surface that attaches. A `readPosts` option wins over it. */
|
|
808
|
+
export const READ_POSTS_ENV = "SCENESCOUT_READ_POSTS";
|
|
809
|
+
/**
|
|
810
|
+
* The POST endpoints a session was told only read, from the `readPosts`
|
|
811
|
+
* option, else the environment variable (entries separated by commas or new
|
|
812
|
+
* lines). Nothing by default: observe refuses every POST until the user names
|
|
813
|
+
* one, and the agent must never add one itself.
|
|
814
|
+
*/
|
|
815
|
+
export function readPostsSetting(option, env) {
|
|
816
|
+
if (option !== undefined)
|
|
817
|
+
return [...option];
|
|
818
|
+
return (env ?? "")
|
|
819
|
+
.split(/[,\n]/)
|
|
820
|
+
.map((e) => e.trim())
|
|
821
|
+
.filter(Boolean);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* The entries parsed, and what was given that is not one. An entry is
|
|
825
|
+
* `POST <path>` for the app's own origin, or `POST <http(s) URL>` for an API
|
|
826
|
+
* on another origin: the method must be POST, the path must start with `/`
|
|
827
|
+
* and carry no query or fragment, and `*` may stand only for one whole path
|
|
828
|
+
* segment, such as an id. Exact otherwise, so nothing is widened by accident;
|
|
829
|
+
* a trailing slash is ignored.
|
|
830
|
+
*/
|
|
831
|
+
export function readPostEntries(list) {
|
|
832
|
+
const entries = [];
|
|
833
|
+
const rejected = [];
|
|
834
|
+
const overflow = [];
|
|
835
|
+
for (const raw of list) {
|
|
836
|
+
const m = /^\s*POST\s+(\S+)\s*$/i.exec(raw);
|
|
837
|
+
if (!m) {
|
|
838
|
+
rejected.push(raw);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
let origin = null;
|
|
842
|
+
let pathname = m[1];
|
|
843
|
+
if (/^https?:\/\//i.test(pathname)) {
|
|
844
|
+
let u;
|
|
845
|
+
try {
|
|
846
|
+
u = new URL(pathname);
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
rejected.push(raw);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (u.search || u.hash || u.username || u.password || /[?#]/.test(pathname)) {
|
|
853
|
+
rejected.push(raw);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
origin = u.origin;
|
|
857
|
+
// The path as written: URL would percent-encode a `*`.
|
|
858
|
+
const slash = pathname.indexOf("/", pathname.indexOf("//") + 2);
|
|
859
|
+
pathname = slash === -1 ? "/" : pathname.slice(slash);
|
|
860
|
+
}
|
|
861
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
862
|
+
if (!pathname.startsWith("/") || /[?#\s]/.test(pathname) || segments.some((seg) => seg.includes("*") && seg !== "*")) {
|
|
863
|
+
rejected.push(raw);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const entry = `POST ${origin ?? ""}${"/" + segments.join("/")}`;
|
|
867
|
+
if (entries.some((e) => e.entry === entry))
|
|
868
|
+
continue;
|
|
869
|
+
if (entries.length < MAX_READ_POSTS)
|
|
870
|
+
entries.push({ entry, origin, segments });
|
|
871
|
+
else
|
|
872
|
+
overflow.push(raw);
|
|
873
|
+
}
|
|
874
|
+
return { entries, rejected, overflow };
|
|
875
|
+
}
|
|
876
|
+
/** The entry a POST to `url` matches, or null. An entry with no origin matches the app's own origin only. */
|
|
877
|
+
export function matchReadPost(entries, appUrl, url) {
|
|
878
|
+
let target;
|
|
879
|
+
let app;
|
|
880
|
+
try {
|
|
881
|
+
target = new URL(url);
|
|
882
|
+
app = new URL(appUrl).origin;
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
const segments = target.pathname.split("/").filter(Boolean);
|
|
888
|
+
return (entries.find((e) => (e.origin ?? app) === target.origin && e.segments.length === segments.length && e.segments.every((seg, i) => seg === "*" || seg === segments[i])) ?? null);
|
|
889
|
+
}
|
|
890
|
+
/** The longest body read for a GraphQL operation; a longer one cannot be vetted and is refused. */
|
|
891
|
+
export const MAX_READ_POST_BODY = 100_000;
|
|
892
|
+
/** A GraphQL operation that writes: `mutation` or `subscription`, then an optional name, then its variables, directives or selection. */
|
|
893
|
+
const GRAPHQL_WRITE_OP_RE = /(?:^|[^A-Za-z0-9_$])(?:mutation|subscription)\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*)?[({@]/;
|
|
894
|
+
/**
|
|
895
|
+
* Whether a body carries a GraphQL operation that is not a query, or one that
|
|
896
|
+
* cannot be read. Read from the body as sent, from every `query` string in a
|
|
897
|
+
* JSON body (or a batch of them), so an escaped newline cannot hide the
|
|
898
|
+
* keyword, and from a form-encoded `query` field. A persisted query (a hash
|
|
899
|
+
* and no text) counts, since its operation is unknown. A search box's text
|
|
900
|
+
* ("mutation testing") is not an operation and does not match.
|
|
901
|
+
*/
|
|
902
|
+
export function graphqlWriteOperation(body) {
|
|
903
|
+
if (GRAPHQL_WRITE_OP_RE.test(body))
|
|
904
|
+
return true;
|
|
905
|
+
let parsed;
|
|
906
|
+
try {
|
|
907
|
+
parsed = JSON.parse(body);
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
// A form-encoded body: its `query` field, decoded.
|
|
911
|
+
const query = /(?:^|&)query=/.test(body) ? new URLSearchParams(body).get("query") : null;
|
|
912
|
+
return query !== null && GRAPHQL_WRITE_OP_RE.test(query);
|
|
913
|
+
}
|
|
914
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
915
|
+
return items.some((item) => {
|
|
916
|
+
if (typeof item !== "object" || item === null)
|
|
917
|
+
return false;
|
|
918
|
+
const { query, extensions } = item;
|
|
919
|
+
if (typeof query === "string")
|
|
920
|
+
return GRAPHQL_WRITE_OP_RE.test(query);
|
|
921
|
+
// A persisted query names its operation by hash alone, so what it runs cannot be read: refused.
|
|
922
|
+
return typeof extensions === "object" && extensions !== null && extensions.persistedQuery !== undefined;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* The entry that lets this POST out of observe, or null. Only in observe:
|
|
927
|
+
* read-only and safe-write already let a POST out unless it looks
|
|
928
|
+
* destructive, and destructive lets everything out. Even a listed endpoint is
|
|
929
|
+
* refused when its path or body looks destructive (the whole body is read),
|
|
930
|
+
* when its body is a GraphQL mutation, subscription or persisted query, or
|
|
931
|
+
* when the body is too long to vet.
|
|
932
|
+
*/
|
|
933
|
+
export function readPostAllowed(input) {
|
|
934
|
+
if (input.mode !== "observe" || input.method !== "POST" || input.destructiveWire || input.entries.length === 0)
|
|
935
|
+
return null;
|
|
936
|
+
const entry = matchReadPost(input.entries, input.appUrl, input.url);
|
|
937
|
+
if (!entry)
|
|
938
|
+
return null;
|
|
939
|
+
const body = input.body ?? "";
|
|
940
|
+
// The whole body, not the first 2000 characters isDestructiveWire reads: a command after a long filter still counts.
|
|
941
|
+
if (body.length > MAX_READ_POST_BODY || DESTRUCTIVE_BODY_RE.test(body) || graphqlWriteOperation(body))
|
|
942
|
+
return null;
|
|
943
|
+
return entry;
|
|
944
|
+
}
|
|
575
945
|
export function allowsWrite(mode, method, destructiveWire, owned) {
|
|
576
946
|
if (mode === "destructive")
|
|
577
947
|
return true;
|
package/dist/engine/probes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DIALOG_LIKE_SEL } from "./collector.js";
|
|
1
|
+
import { ACCESSIBLE_NAME_SRC, DIALOG_LIKE_SEL } from "./collector.js";
|
|
2
2
|
import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
|
|
3
3
|
/**
|
|
4
4
|
* Scroll ONE named region rather than the page. The page-level heuristic
|
|
@@ -267,7 +267,8 @@ export async function probeFocusIndicators(page) {
|
|
|
267
267
|
el.setAttribute("data-scout-focus-probe", "${i}");
|
|
268
268
|
const s = getComputedStyle(el);
|
|
269
269
|
const tid = el.getAttribute("data-testid");
|
|
270
|
-
|
|
270
|
+
// Named as the snapshot names it, so an icon button reads by its aria-label here too.
|
|
271
|
+
const name = (${ACCESSIBLE_NAME_SRC})(el).slice(0, 30);
|
|
271
272
|
return { label: tid ? "[" + tid + "]" : "<" + el.tagName.toLowerCase() + "> " + JSON.stringify(name), focused: ${styleSig} };
|
|
272
273
|
})()`));
|
|
273
274
|
if (info === null || info === "wrapped")
|