scenescout 3.13.0 → 3.14.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 +29 -0
- package/README.md +34 -6
- package/dist/browsers.js +13 -0
- package/dist/check-run.js +2 -0
- package/dist/ci-run.js +134 -16
- package/dist/cli.js +72 -1
- package/dist/engine/authloss.js +149 -8
- package/dist/engine/brief.js +7 -1
- package/dist/engine/browser.js +615 -74
- package/dist/engine/capture.js +121 -0
- package/dist/engine/check.js +18 -0
- package/dist/engine/ci.js +117 -6
- package/dist/engine/dedup.js +50 -22
- package/dist/engine/expiry.js +254 -0
- package/dist/engine/fingerprint.js +2 -2
- package/dist/engine/flow.js +0 -2
- package/dist/engine/limits.js +144 -0
- package/dist/engine/live-page.js +57 -4
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/png.js +238 -0
- package/dist/engine/postmessage.js +282 -0
- package/dist/engine/probes.js +4 -4
- package/dist/engine/profiles.js +392 -0
- package/dist/engine/refresh.js +383 -0
- package/dist/engine/scripted-login.js +400 -0
- package/dist/engine/teardown.js +41 -0
- package/dist/login-run.js +444 -0
- package/dist/mcp-server.js +136 -20
- package/package.json +7 -2
- package/skills/scenescout/SKILL.md +9 -6
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG in and out, and a pixel diff of two pictures, with nothing but
|
|
3
|
+
* node:zlib. A `/scenescout qa compare` reply shows an element before and
|
|
4
|
+
* after with the changed pixels highlighted; the pictures are browser
|
|
5
|
+
* screenshots, so this reads what a browser writes (8 bits a channel, not
|
|
6
|
+
* interlaced) and writes plain RGBA. Kept free of Playwright and of any
|
|
7
|
+
* image package so it is table-tested on pictures built in the test, and so
|
|
8
|
+
* the npm package gains no dependency for a feature few runs use.
|
|
9
|
+
*/
|
|
10
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
11
|
+
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
12
|
+
/** Channels a pixel has, by PNG colour type: grey, RGB, grey and alpha, RGBA. Palette images are not read. */
|
|
13
|
+
const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
|
|
14
|
+
/** The largest picture read or diffed, in pixels. An element's capture is far smaller; this bounds the memory a bad file can ask for. */
|
|
15
|
+
export const MAX_PIXELS = 40_000_000;
|
|
16
|
+
const CRC_TABLE = (() => {
|
|
17
|
+
const t = new Uint32Array(256);
|
|
18
|
+
for (let n = 0; n < 256; n++) {
|
|
19
|
+
let c = n;
|
|
20
|
+
for (let k = 0; k < 8; k++)
|
|
21
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
22
|
+
t[n] = c >>> 0;
|
|
23
|
+
}
|
|
24
|
+
return t;
|
|
25
|
+
})();
|
|
26
|
+
function crc32(buf) {
|
|
27
|
+
let c = 0xffffffff;
|
|
28
|
+
for (let i = 0; i < buf.length; i++)
|
|
29
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
30
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
31
|
+
}
|
|
32
|
+
/** Whether these bytes start the way every PNG does. */
|
|
33
|
+
export function isPng(buf) {
|
|
34
|
+
return buf.length >= SIGNATURE.length && SIGNATURE.every((b, i) => buf[i] === b);
|
|
35
|
+
}
|
|
36
|
+
function paeth(a, b, c) {
|
|
37
|
+
const p = a + b - c;
|
|
38
|
+
const pa = Math.abs(p - a);
|
|
39
|
+
const pb = Math.abs(p - b);
|
|
40
|
+
const pc = Math.abs(p - c);
|
|
41
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
42
|
+
}
|
|
43
|
+
/** Reads a PNG into RGBA. Throws, saying why, on anything it does not read: a palette, 16-bit channels, interlacing, a bad checksum. */
|
|
44
|
+
export function decodePng(buf) {
|
|
45
|
+
if (!isPng(buf))
|
|
46
|
+
throw new Error("not a PNG");
|
|
47
|
+
const view = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
48
|
+
let pos = SIGNATURE.length;
|
|
49
|
+
let width = 0;
|
|
50
|
+
let height = 0;
|
|
51
|
+
let colorType = -1;
|
|
52
|
+
const idat = [];
|
|
53
|
+
let ended = false;
|
|
54
|
+
while (pos + 8 <= view.length) {
|
|
55
|
+
const length = view.readUInt32BE(pos);
|
|
56
|
+
const type = view.toString("latin1", pos + 4, pos + 8);
|
|
57
|
+
if (pos + 12 + length > view.length)
|
|
58
|
+
throw new Error(`the PNG is cut short in its ${type} chunk`);
|
|
59
|
+
const body = view.subarray(pos + 8, pos + 8 + length);
|
|
60
|
+
if (crc32(view.subarray(pos + 4, pos + 8 + length)) !== view.readUInt32BE(pos + 8 + length))
|
|
61
|
+
throw new Error(`the PNG's ${type} chunk fails its checksum`);
|
|
62
|
+
if (type === "IHDR") {
|
|
63
|
+
width = body.readUInt32BE(0);
|
|
64
|
+
height = body.readUInt32BE(4);
|
|
65
|
+
const depth = body[8];
|
|
66
|
+
colorType = body[9];
|
|
67
|
+
if (depth !== 8)
|
|
68
|
+
throw new Error(`a PNG with ${depth}-bit channels is not read, only 8-bit`);
|
|
69
|
+
if (!(colorType in CHANNELS))
|
|
70
|
+
throw new Error(`a PNG of colour type ${colorType} is not read (palette images are not)`);
|
|
71
|
+
if (body[12] !== 0)
|
|
72
|
+
throw new Error("an interlaced PNG is not read");
|
|
73
|
+
}
|
|
74
|
+
else if (type === "IDAT")
|
|
75
|
+
idat.push(body);
|
|
76
|
+
else if (type === "IEND") {
|
|
77
|
+
ended = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
pos += 12 + length;
|
|
81
|
+
}
|
|
82
|
+
if (!ended || colorType < 0)
|
|
83
|
+
throw new Error("the PNG has no header or no end");
|
|
84
|
+
if (width === 0 || height === 0 || width * height > MAX_PIXELS)
|
|
85
|
+
throw new Error(`a ${width}×${height} PNG is outside what is read`);
|
|
86
|
+
const channels = CHANNELS[colorType];
|
|
87
|
+
const stride = width * channels;
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
// Bounded by what the header promises, so a small file cannot inflate into gigabytes.
|
|
91
|
+
raw = inflateSync(Buffer.concat(idat), { maxOutputLength: height * (stride + 1) + 1 });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
throw new Error(`the PNG's pixel data does not inflate to the size its header gives (${err instanceof Error ? err.message : String(err)})`);
|
|
95
|
+
}
|
|
96
|
+
if (raw.length !== height * (stride + 1))
|
|
97
|
+
throw new Error("the PNG's pixel data is not the size its header gives");
|
|
98
|
+
const rows = new Uint8Array(height * stride);
|
|
99
|
+
for (let y = 0; y < height; y++) {
|
|
100
|
+
const filter = raw[y * (stride + 1)];
|
|
101
|
+
const src = y * (stride + 1) + 1;
|
|
102
|
+
const out = y * stride;
|
|
103
|
+
for (let x = 0; x < stride; x++) {
|
|
104
|
+
const left = x >= channels ? rows[out + x - channels] : 0;
|
|
105
|
+
const up = y > 0 ? rows[out - stride + x] : 0;
|
|
106
|
+
const upLeft = y > 0 && x >= channels ? rows[out - stride + x - channels] : 0;
|
|
107
|
+
const v = raw[src + x];
|
|
108
|
+
let r;
|
|
109
|
+
if (filter === 0)
|
|
110
|
+
r = v;
|
|
111
|
+
else if (filter === 1)
|
|
112
|
+
r = v + left;
|
|
113
|
+
else if (filter === 2)
|
|
114
|
+
r = v + up;
|
|
115
|
+
else if (filter === 3)
|
|
116
|
+
r = v + ((left + up) >> 1);
|
|
117
|
+
else if (filter === 4)
|
|
118
|
+
r = v + paeth(left, up, upLeft);
|
|
119
|
+
else
|
|
120
|
+
throw new Error(`the PNG uses row filter ${filter}, which does not exist`);
|
|
121
|
+
rows[out + x] = r & 0xff;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (channels === 4)
|
|
125
|
+
return { width, height, data: rows };
|
|
126
|
+
const data = new Uint8Array(width * height * 4);
|
|
127
|
+
for (let i = 0, j = 0; i < width * height; i++, j += channels) {
|
|
128
|
+
const grey = channels <= 2;
|
|
129
|
+
data[i * 4] = rows[j];
|
|
130
|
+
data[i * 4 + 1] = grey ? rows[j] : rows[j + 1];
|
|
131
|
+
data[i * 4 + 2] = grey ? rows[j] : rows[j + 2];
|
|
132
|
+
data[i * 4 + 3] = channels === 2 ? rows[j + 1] : channels === 4 ? rows[j + 3] : 255;
|
|
133
|
+
}
|
|
134
|
+
return { width, height, data };
|
|
135
|
+
}
|
|
136
|
+
function chunk(type, body) {
|
|
137
|
+
const head = Buffer.alloc(8);
|
|
138
|
+
head.writeUInt32BE(body.length, 0);
|
|
139
|
+
head.write(type, 4, "latin1");
|
|
140
|
+
const tail = Buffer.alloc(4);
|
|
141
|
+
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
|
|
142
|
+
return Buffer.concat([head, body, tail]);
|
|
143
|
+
}
|
|
144
|
+
/** Writes RGBA as a PNG: 8-bit colour with alpha, no filtering, deflated. */
|
|
145
|
+
export function encodePng(img) {
|
|
146
|
+
if (img.data.length !== img.width * img.height * 4)
|
|
147
|
+
throw new Error("the pixel data is not width × height × 4 bytes");
|
|
148
|
+
const header = Buffer.alloc(13);
|
|
149
|
+
header.writeUInt32BE(img.width, 0);
|
|
150
|
+
header.writeUInt32BE(img.height, 4);
|
|
151
|
+
header[8] = 8;
|
|
152
|
+
header[9] = 6;
|
|
153
|
+
const stride = img.width * 4;
|
|
154
|
+
const raw = Buffer.alloc(img.height * (stride + 1));
|
|
155
|
+
for (let y = 0; y < img.height; y++)
|
|
156
|
+
raw.set(img.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
157
|
+
return Buffer.concat([SIGNATURE, chunk("IHDR", header), chunk("IDAT", deflateSync(raw)), chunk("IEND", new Uint8Array(0))]);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* How far apart two channel values may be and the pixel still count as the
|
|
161
|
+
* same, out of 255. Two screenshots of one unchanged element in one browser
|
|
162
|
+
* match exactly; the allowance absorbs the odd colour rounded one step
|
|
163
|
+
* differently, not a change anyone would see.
|
|
164
|
+
*/
|
|
165
|
+
export const DIFF_THRESHOLD = 8;
|
|
166
|
+
/** The colour a changed pixel is painted in the diff picture. */
|
|
167
|
+
export const DIFF_HIGHLIGHT = [255, 0, 80];
|
|
168
|
+
/**
|
|
169
|
+
* Compares two pictures pixel by pixel. Pictures of different sizes are laid
|
|
170
|
+
* over each other from the top-left corner, which is where an element's
|
|
171
|
+
* capture starts (its bounds plus a fixed margin), and every pixel only one of
|
|
172
|
+
* them covers counts as changed: an element that grew has changed.
|
|
173
|
+
*/
|
|
174
|
+
export function diffImages(before, after, threshold = DIFF_THRESHOLD) {
|
|
175
|
+
const width = Math.max(before.width, after.width);
|
|
176
|
+
const height = Math.max(before.height, after.height);
|
|
177
|
+
if (width * height > MAX_PIXELS)
|
|
178
|
+
throw new Error(`a ${width}×${height} diff is outside what is compared`);
|
|
179
|
+
const data = new Uint8Array(width * height * 4);
|
|
180
|
+
let changed = 0;
|
|
181
|
+
let minX = width;
|
|
182
|
+
let minY = height;
|
|
183
|
+
let maxX = -1;
|
|
184
|
+
let maxY = -1;
|
|
185
|
+
const at = (img, x, y) => (x < img.width && y < img.height ? (y * img.width + x) * 4 : -1);
|
|
186
|
+
for (let y = 0; y < height; y++) {
|
|
187
|
+
for (let x = 0; x < width; x++) {
|
|
188
|
+
const a = at(before, x, y);
|
|
189
|
+
const b = at(after, x, y);
|
|
190
|
+
let differs = a < 0 || b < 0;
|
|
191
|
+
if (!differs)
|
|
192
|
+
for (let c = 0; c < 4; c++)
|
|
193
|
+
if (Math.abs(before.data[a + c] - after.data[b + c]) > threshold) {
|
|
194
|
+
differs = true;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
const o = (y * width + x) * 4;
|
|
198
|
+
if (differs) {
|
|
199
|
+
changed++;
|
|
200
|
+
if (x < minX)
|
|
201
|
+
minX = x;
|
|
202
|
+
if (y < minY)
|
|
203
|
+
minY = y;
|
|
204
|
+
if (x > maxX)
|
|
205
|
+
maxX = x;
|
|
206
|
+
if (y > maxY)
|
|
207
|
+
maxY = y;
|
|
208
|
+
data[o] = DIFF_HIGHLIGHT[0];
|
|
209
|
+
data[o + 1] = DIFF_HIGHLIGHT[1];
|
|
210
|
+
data[o + 2] = DIFF_HIGHLIGHT[2];
|
|
211
|
+
data[o + 3] = 255;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// Faded grey of the after picture, so the eye goes to the highlight and still sees where it is.
|
|
215
|
+
const lum = 0.299 * after.data[b] + 0.587 * after.data[b + 1] + 0.114 * after.data[b + 2];
|
|
216
|
+
const v = Math.round(255 - (255 - lum) * 0.3);
|
|
217
|
+
data[o] = v;
|
|
218
|
+
data[o + 1] = v;
|
|
219
|
+
data[o + 2] = v;
|
|
220
|
+
data[o + 3] = 255;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const total = width * height;
|
|
225
|
+
return {
|
|
226
|
+
width,
|
|
227
|
+
height,
|
|
228
|
+
changed,
|
|
229
|
+
total,
|
|
230
|
+
// Never rounded down to 0 when something changed: "0%" must mean nothing did.
|
|
231
|
+
percent: changed === 0 ? 0 : Math.max(0.01, Math.round((changed / total) * 10_000) / 100),
|
|
232
|
+
sizeChanged: before.width !== after.width || before.height !== after.height,
|
|
233
|
+
before: { width: before.width, height: before.height },
|
|
234
|
+
after: { width: after.width, height: after.height },
|
|
235
|
+
box: maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
|
|
236
|
+
image: { width, height, data },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The postMessage-token oracle: a page that calls `postMessage` with
|
|
3
|
+
* targetOrigin "*" and a message carrying a credential.
|
|
4
|
+
*
|
|
5
|
+
* targetOrigin is the sender's statement of who may read the message. "*"
|
|
6
|
+
* means anyone: the message is delivered to whatever document the target
|
|
7
|
+
* window holds at that moment, which may be an opener or frame that has
|
|
8
|
+
* navigated to another site, or an embed of another site to begin with. A
|
|
9
|
+
* token in such a message is handed to that site. Naming the intended origin
|
|
10
|
+
* (or "/" for the sender's own) makes the browser drop the message instead.
|
|
11
|
+
*
|
|
12
|
+
* What counts as token-shaped (`tokenShapeOf`), on any string in the message,
|
|
13
|
+
* however deeply nested, and inside a string that is itself JSON:
|
|
14
|
+
*
|
|
15
|
+
* - `jwt`: a whole value of three base64url parts whose first two begin
|
|
16
|
+
* `eyJ` (base64 of `{"`), the header and claims of a JSON Web Token. The
|
|
17
|
+
* signature part may be empty (an unsigned token is still a bearer
|
|
18
|
+
* credential).
|
|
19
|
+
* - `bearer`: a whole value `Bearer <opaque>`, as an Authorization header
|
|
20
|
+
* carries it.
|
|
21
|
+
* - `credential-key`: a value under a key that names a credential
|
|
22
|
+
* (`CREDENTIAL_KEYS`: access_token, id_token, refresh_token, api_key and
|
|
23
|
+
* the like, in any case and with or without `_`/`-`) that looks opaque:
|
|
24
|
+
* 20 or more URL-safe characters with at least one letter and one digit.
|
|
25
|
+
* `token_type: "Bearer"` or `token: "pending"` is not one.
|
|
26
|
+
* - `url-param`: a string with an `access_token=`, `id_token=` or
|
|
27
|
+
* `refresh_token=` parameter of 16 or more characters, the shape an
|
|
28
|
+
* implicit-flow redirect URL or fragment has.
|
|
29
|
+
*
|
|
30
|
+
* The value itself is never kept or reported. A finding names the path to it
|
|
31
|
+
* inside the message (`data.auth.access_token`), its shape, and a masked
|
|
32
|
+
* preview: its first four characters and its length, which identify the
|
|
33
|
+
* token to someone who has it and give nothing to someone who does not.
|
|
34
|
+
*
|
|
35
|
+
* Capture is an init script (`postMessageCaptureScript`) that wraps
|
|
36
|
+
* `window.postMessage` in every frame and hands the engine the message's
|
|
37
|
+
* longer strings only when targetOrigin is "*". Two limits follow from how
|
|
38
|
+
* browsers work, not from a choice: a call made on a window of another origin
|
|
39
|
+
* (`iframe.contentWindow.postMessage(...)` into an embed of another site)
|
|
40
|
+
* goes through the browser's cross-origin wrapper, which no page script can
|
|
41
|
+
* replace, so it is not seen; and `MessagePort.postMessage` takes no
|
|
42
|
+
* targetOrigin, so it is out of scope. What is seen is a call on the
|
|
43
|
+
* sender's own window or on a same-origin one (an opener, a parent, a frame),
|
|
44
|
+
* which is where the "*" is written, and the defect is that line of code.
|
|
45
|
+
*
|
|
46
|
+
* No browser is needed for these rules, so they are table-tested in
|
|
47
|
+
* oracle-test; browser.ts installs the script and reports what it hands back.
|
|
48
|
+
*/
|
|
49
|
+
import { normalizePath } from "./fingerprint.js";
|
|
50
|
+
/** The binding the capture script reports through. */
|
|
51
|
+
export const POSTMESSAGE_BINDING = "__scenescoutPostMessage";
|
|
52
|
+
/** Strings shorter than this are not sent to the engine: nothing token-shaped is shorter. */
|
|
53
|
+
export const MIN_CANDIDATE_LENGTH = 16;
|
|
54
|
+
/** Longest string sent; a JWT carrying many claims runs to a few kilobytes. */
|
|
55
|
+
export const MAX_CANDIDATE_LENGTH = 8192;
|
|
56
|
+
/** Longest JSON-looking string sent whole, so the engine can parse it; a longer one is cut and its contents go unread. */
|
|
57
|
+
export const MAX_JSON_LENGTH = 262144;
|
|
58
|
+
/** Most values the walk visits per message: a large array or a binary buffer must not stall the app's call. */
|
|
59
|
+
export const MAX_VISITS = 2000;
|
|
60
|
+
/** Most items read from any one array or object. */
|
|
61
|
+
export const MAX_ITEMS = 200;
|
|
62
|
+
/** Most strings sent per message, and the deepest nesting walked. */
|
|
63
|
+
export const MAX_CANDIDATES = 50;
|
|
64
|
+
export const MAX_DEPTH = 6;
|
|
65
|
+
/** Whether a targetOrigin lets any origin read the message. Only "*" does; "/" is the sender's own origin. */
|
|
66
|
+
export function postsToAnyOrigin(targetOrigin) {
|
|
67
|
+
return targetOrigin === "*";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The script run in every frame before the page's own scripts. It replaces
|
|
71
|
+
* `window.postMessage` with a wrapper that, when targetOrigin is "*", walks
|
|
72
|
+
* the message for strings of MIN_CANDIDATE_LENGTH or more and hands them,
|
|
73
|
+
* each with its path, to the binding; then it calls the original with the
|
|
74
|
+
* same arguments, whatever happened before. The walk skips binary buffers
|
|
75
|
+
* and stops after MAX_VISITS values and MAX_ITEMS entries of any one array or object, so a large message costs the app little. Both call forms are read:
|
|
76
|
+
* `postMessage(msg, origin)` and `postMessage(msg, { targetOrigin })`, whose
|
|
77
|
+
* default is "/".
|
|
78
|
+
*
|
|
79
|
+
* Failures in the wrapper are swallowed on purpose: the app's call must go
|
|
80
|
+
* through exactly as it would without SceneScout, and a capture error must
|
|
81
|
+
* never be reported as the app's own page error.
|
|
82
|
+
*/
|
|
83
|
+
export function postMessageCaptureScript(binding = POSTMESSAGE_BINDING) {
|
|
84
|
+
return `(() => {
|
|
85
|
+
const W = window;
|
|
86
|
+
const orig = W.postMessage;
|
|
87
|
+
if (typeof orig !== "function" || orig.__scenescoutWrapped) return;
|
|
88
|
+
const NAME = ${JSON.stringify(binding)};
|
|
89
|
+
const MIN = ${MIN_CANDIDATE_LENGTH}, LEN = ${MAX_CANDIDATE_LENGTH}, JSON_LEN = ${MAX_JSON_LENGTH}, MAX = ${MAX_CANDIDATES}, DEPTH = ${MAX_DEPTH}, VISITS = ${MAX_VISITS}, ITEMS = ${MAX_ITEMS};
|
|
90
|
+
const collect = (data) => {
|
|
91
|
+
const out = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
let visits = 0;
|
|
94
|
+
const walk = (v, path, depth) => {
|
|
95
|
+
if (out.length >= MAX || ++visits > VISITS) return;
|
|
96
|
+
if (typeof v === "string") {
|
|
97
|
+
if (v.length >= MIN) out.push([path, v.slice(0, /^\s*[{[]/.test(v) ? JSON_LEN : LEN)]);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!v || typeof v !== "object" || depth >= DEPTH || seen.has(v)) return;
|
|
101
|
+
if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) return;
|
|
102
|
+
seen.add(v);
|
|
103
|
+
const isArray = Array.isArray(v);
|
|
104
|
+
let keys;
|
|
105
|
+
try { keys = isArray ? Array.from({ length: Math.min(v.length, ITEMS) }, (_, i) => i) : Object.keys(v).slice(0, ITEMS); } catch (e) { return; }
|
|
106
|
+
for (const k of keys) {
|
|
107
|
+
let child;
|
|
108
|
+
try { child = v[k]; } catch (e) { continue; }
|
|
109
|
+
walk(child, isArray ? path + "[" + k + "]" : path + "." + k, depth + 1);
|
|
110
|
+
if (out.length >= MAX) return;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(data, "data", 0);
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
const wrapped = function postMessage(message, targetOrigin) {
|
|
117
|
+
try {
|
|
118
|
+
const origin = typeof targetOrigin === "string"
|
|
119
|
+
? targetOrigin
|
|
120
|
+
: targetOrigin && typeof targetOrigin === "object" && targetOrigin.targetOrigin !== undefined
|
|
121
|
+
? String(targetOrigin.targetOrigin)
|
|
122
|
+
: "/";
|
|
123
|
+
const report = W[NAME];
|
|
124
|
+
if (origin === "*" && typeof report === "function") {
|
|
125
|
+
const found = collect(message);
|
|
126
|
+
if (found.length > 0) {
|
|
127
|
+
const sent = report(found);
|
|
128
|
+
// A report that fails must not become an unhandled rejection the page oracle files against the app.
|
|
129
|
+
if (sent && typeof sent.then === "function") sent.then(undefined, () => undefined);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
// The capture is ours; the app's call below must run as it would without it.
|
|
134
|
+
}
|
|
135
|
+
return orig.apply(this, arguments);
|
|
136
|
+
};
|
|
137
|
+
Object.defineProperty(wrapped, "__scenescoutWrapped", { value: true });
|
|
138
|
+
W.postMessage = wrapped;
|
|
139
|
+
})();`;
|
|
140
|
+
}
|
|
141
|
+
/** Key names, lower-cased with `_` and `-` removed, whose value is a credential. */
|
|
142
|
+
export const CREDENTIAL_KEYS = new Set([
|
|
143
|
+
"accesstoken",
|
|
144
|
+
"idtoken",
|
|
145
|
+
"refreshtoken",
|
|
146
|
+
"authtoken",
|
|
147
|
+
"sessiontoken",
|
|
148
|
+
"bearertoken",
|
|
149
|
+
"apitoken",
|
|
150
|
+
"apikey",
|
|
151
|
+
"clientsecret",
|
|
152
|
+
"token",
|
|
153
|
+
"jwt",
|
|
154
|
+
]);
|
|
155
|
+
const JWT_RE = /^eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*$/;
|
|
156
|
+
const BEARER_RE = /^Bearer\s+([A-Za-z0-9._~+/=-]{16,})$/i;
|
|
157
|
+
const OPAQUE_RE = /^[A-Za-z0-9._~+/=-]{20,}$/;
|
|
158
|
+
const URL_PARAM_RE = /(?:^|[?#&])(?:access_token|id_token|refresh_token)=([^&#\s]{16,})/;
|
|
159
|
+
/** Paths are page text; cut so a hostile message cannot fill the report. */
|
|
160
|
+
const MAX_PATH_LENGTH = 120;
|
|
161
|
+
/** The last key of a path such as `data.auth.access_token` or `data.list[0]`. */
|
|
162
|
+
function lastKey(path) {
|
|
163
|
+
const m = /(?:\.([^.[\]]+)|\[[^\]]*\])$/.exec(path);
|
|
164
|
+
return m?.[1] ?? "";
|
|
165
|
+
}
|
|
166
|
+
function isOpaque(value) {
|
|
167
|
+
return OPAQUE_RE.test(value) && /[A-Za-z]/.test(value) && /[0-9]/.test(value);
|
|
168
|
+
}
|
|
169
|
+
/** Masked preview of a credential: its first four characters and its length. Nothing past the fourth character is ever included. */
|
|
170
|
+
export function maskToken(value) {
|
|
171
|
+
return `"${value.slice(0, 4)}…" (${value.length} chars)`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The shape of one string at one path, with the part that is the credential
|
|
175
|
+
* (so the preview is of the token, not of a `Bearer ` prefix), or null when
|
|
176
|
+
* it is not token-shaped.
|
|
177
|
+
*/
|
|
178
|
+
export function tokenShapeOf(path, raw) {
|
|
179
|
+
const value = raw.trim();
|
|
180
|
+
if (JWT_RE.test(value))
|
|
181
|
+
return { shape: "jwt", secret: value };
|
|
182
|
+
const bearer = BEARER_RE.exec(value);
|
|
183
|
+
if (bearer)
|
|
184
|
+
return { shape: "bearer", secret: bearer[1] };
|
|
185
|
+
if (CREDENTIAL_KEYS.has(lastKey(path).toLowerCase().replace(/[_-]/g, "")) && isOpaque(value))
|
|
186
|
+
return { shape: "credential-key", secret: value };
|
|
187
|
+
const param = URL_PARAM_RE.exec(value);
|
|
188
|
+
if (param)
|
|
189
|
+
return { shape: "url-param", secret: param[1] };
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
/** A string message that is itself JSON (`postMessage(JSON.stringify({...}), "*")`), walked like an object would be. */
|
|
193
|
+
function jsonStrings(path, value) {
|
|
194
|
+
const t = value.trim();
|
|
195
|
+
if (!(t.startsWith("{") || t.startsWith("[")))
|
|
196
|
+
return [];
|
|
197
|
+
let parsed;
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(t);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Not JSON after all: the string was already judged as it is.
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const out = [];
|
|
206
|
+
const walk = (v, p, depth) => {
|
|
207
|
+
if (out.length >= MAX_CANDIDATES)
|
|
208
|
+
return;
|
|
209
|
+
if (typeof v === "string") {
|
|
210
|
+
if (v.length >= MIN_CANDIDATE_LENGTH)
|
|
211
|
+
out.push([p, v]);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!v || typeof v !== "object" || depth >= MAX_DEPTH)
|
|
215
|
+
return;
|
|
216
|
+
if (Array.isArray(v))
|
|
217
|
+
v.forEach((c, i) => walk(c, `${p}[${i}]`, depth + 1));
|
|
218
|
+
else
|
|
219
|
+
for (const [k, c] of Object.entries(v))
|
|
220
|
+
walk(c, `${p}.${k}`, depth + 1);
|
|
221
|
+
};
|
|
222
|
+
walk(parsed, `${path}(json)`, 0);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The token-shaped values among what the capture script handed back. Entries
|
|
227
|
+
* come from the page, so anything not a `[path, string]` pair is skipped.
|
|
228
|
+
* One hit per path; the values are discarded here.
|
|
229
|
+
*/
|
|
230
|
+
export function tokenHits(entries) {
|
|
231
|
+
if (!Array.isArray(entries))
|
|
232
|
+
return [];
|
|
233
|
+
const out = [];
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
const judge = (path, value) => {
|
|
236
|
+
const found = tokenShapeOf(path, value);
|
|
237
|
+
const shown = path.slice(0, MAX_PATH_LENGTH);
|
|
238
|
+
if (!found || seen.has(shown))
|
|
239
|
+
return;
|
|
240
|
+
seen.add(shown);
|
|
241
|
+
out.push({ path: shown, shape: found.shape, preview: maskToken(found.secret) });
|
|
242
|
+
};
|
|
243
|
+
for (const entry of entries.slice(0, MAX_CANDIDATES)) {
|
|
244
|
+
// The page cuts strings itself; a page that does not (a spoofed call) is cut here to the same bounds.
|
|
245
|
+
if (!Array.isArray(entry) || typeof entry[0] !== "string" || typeof entry[1] !== "string")
|
|
246
|
+
continue;
|
|
247
|
+
const [path, raw] = entry;
|
|
248
|
+
const value = raw.slice(0, MAX_JSON_LENGTH);
|
|
249
|
+
judge(path, value);
|
|
250
|
+
for (const [p, v] of jsonStrings(path, value))
|
|
251
|
+
judge(p, v);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
function routeOf(url) {
|
|
256
|
+
try {
|
|
257
|
+
return normalizePath(new URL(url).pathname);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return url || "?";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** One report per route, receiving frame, path and shape, so a page that posts on every render is reported once. */
|
|
264
|
+
export function tokenPostKey(hit, pageUrl, frameUrl) {
|
|
265
|
+
let frameOrigin = "";
|
|
266
|
+
try {
|
|
267
|
+
frameOrigin = new URL(frameUrl).origin;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
frameOrigin = "?";
|
|
271
|
+
}
|
|
272
|
+
return `${routeOf(pageUrl)}|${frameOrigin}|${hit.path}|${hit.shape}`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The violation's detail. The shape and path come first because the oracle
|
|
276
|
+
* log signs a violation on its first characters: two tokens posted by one
|
|
277
|
+
* page must not read as one.
|
|
278
|
+
*/
|
|
279
|
+
export function describeTokenPost(hit, frameUrl) {
|
|
280
|
+
return (`${hit.shape} token at ${hit.path} (${hit.preview}) posted with targetOrigin "*" to the window of ${routeOf(frameUrl)} — ` +
|
|
281
|
+
`any origin that window holds (an opener or frame that navigated elsewhere, another site's embed) receives the credential; name the intended origin instead`);
|
|
282
|
+
}
|
package/dist/engine/probes.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { DIALOG_LIKE_SEL } from "./collector.js";
|
|
2
2
|
import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
|
|
3
|
-
/** Per-action Playwright timeout, shared with the engine so a scroll into view fails as fast as a click would. */
|
|
4
|
-
export const ACTION_TIMEOUT_MS = 5000;
|
|
5
3
|
/**
|
|
6
4
|
* Scroll ONE named region rather than the page. The page-level heuristic
|
|
7
5
|
* picks the largest scrollable pane, so a smaller independently-scrolling
|
|
@@ -10,7 +8,9 @@ export const ACTION_TIMEOUT_MS = 5000;
|
|
|
10
8
|
* away. Resolves the element, then scrolls the nearest scrollable ancestor
|
|
11
9
|
* (the target itself is usually the content, not the scroll port).
|
|
12
10
|
*/
|
|
13
|
-
export async function scrollContainer(page, target, to, by
|
|
11
|
+
export async function scrollContainer(page, target, to, by,
|
|
12
|
+
/** The session's action limit, so a scroll into view fails as fast as a click would. */
|
|
13
|
+
timeoutMs) {
|
|
14
14
|
let locator;
|
|
15
15
|
if (target.startsWith("testid="))
|
|
16
16
|
locator = page.locator(`[data-testid=${JSON.stringify(target.slice(7))}]`).first();
|
|
@@ -48,7 +48,7 @@ export async function scrollContainer(page, target, to, by) {
|
|
|
48
48
|
y: Math.round(node.scrollTop),
|
|
49
49
|
max: Math.max(0, node.scrollHeight - node.clientHeight),
|
|
50
50
|
};
|
|
51
|
-
}, { edge: to ?? null, delta: amount }, { timeout:
|
|
51
|
+
}, { edge: to ?? null, delta: amount }, { timeout: timeoutMs })
|
|
52
52
|
.catch(() => undefined);
|
|
53
53
|
if (outcome === undefined)
|
|
54
54
|
return { refused: `Scroll target not found: ${target}`, note: "" };
|