scenescout 3.14.1 → 3.15.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.
@@ -12,7 +12,17 @@
12
12
  * has already rotated the token, it loads the rotated profile and sends the
13
13
  * current token in place of the spent one. When the response arrives and the
14
14
  * page has stored what it got back, the session writes its state over the
15
- * profile and releases the lock. No two sessions ever present one token.
15
+ * profile and releases the lock, so two sessions do not present one token.
16
+ *
17
+ * Which requests count is decided by brokerDecision. A token in the body, the
18
+ * URL or a header the page set makes the request a refresh. A token that is
19
+ * only in the Cookie header does not by itself: a refresh cookie scoped to "/"
20
+ * rides on every request the page makes, scripts and images included. Such a
21
+ * request counts only when it is plausibly the refresh call, a POST to a path
22
+ * named for one or an endpoint the broker has seen rotate the cookie. Static
23
+ * assets never count, and a broker that cannot do its job lets the request
24
+ * through as the page sent it: an app left without its scripts is worse than
25
+ * the rare double refresh.
16
26
  *
17
27
  * Sessions may be separate processes (one MCP server per lane) sharing the
18
28
  * profile on disk, so the lock is a file, created exclusively, owner-only,
@@ -37,8 +47,14 @@ export const REFRESH_NAME_RE = /refresh/i;
37
47
  export const MIN_TOKEN_LENGTH = 16;
38
48
  /** How deep inside a JSON-valued storage entry a refresh-token field is looked for. */
39
49
  const MAX_JSON_DEPTH = 4;
50
+ /**
51
+ * Whether a value could be a token: long enough, no whitespace, and not an
52
+ * address. An app that keeps the URL it refreshes at under a refresh-named key
53
+ * ("/auth/refresh", "https://…/token") stores a path, which every request to
54
+ * that path would otherwise seem to carry.
55
+ */
40
56
  function looksLikeToken(value) {
41
- return typeof value === "string" && value.length >= MIN_TOKEN_LENGTH && !/\s/.test(value);
57
+ return typeof value === "string" && value.length >= MIN_TOKEN_LENGTH && !/\s/.test(value) && !/^(\/|[a-z][a-z0-9+.-]*:\/\/)/i.test(value);
42
58
  }
43
59
  /** Refresh-token fields inside a JSON value, by dotted path. */
44
60
  function jsonFields(value, at, depth, out) {
@@ -66,7 +82,7 @@ export function refreshTokenSlots(state) {
66
82
  if (Array.isArray(s.cookies)) {
67
83
  for (const c of s.cookies) {
68
84
  if (typeof c?.name === "string" && REFRESH_NAME_RE.test(c.name) && looksLikeToken(c.value)) {
69
- out.push({ slot: `cookie ${c.name} (${String(c.domain ?? "")}${String(c.path ?? "")})`, value: c.value });
85
+ out.push({ slot: `cookie ${c.name} (${String(c.domain ?? "")}${String(c.path ?? "")})`, value: c.value, cookie: c.name });
70
86
  }
71
87
  }
72
88
  }
@@ -106,23 +122,142 @@ function wireForms(value) {
106
122
  return encoded === value ? [value] : [value, encoded];
107
123
  }
108
124
  /**
109
- * The known refresh token a request carries — in its body, a header (a Cookie
110
- * header included) or its URL — or null. `known` is what the session last
111
- * loaded from the profile; a token the page got some other way is not the
112
- * role's and is left alone.
125
+ * Headers the browser adds to a request by itself. A token found in one of
126
+ * these was not sent by the page as an act of refreshing: a cookie scoped to
127
+ * "/" rides on every request the page makes, and a page whose own address
128
+ * carries a token passes it on as the Referer of everything it loads.
129
+ */
130
+ function browserAddedHeader(name) {
131
+ const n = name.toLowerCase();
132
+ return n === "cookie" || n === "referer" || n === "origin" || n === "host" || n.startsWith("sec-") || n.startsWith(":");
133
+ }
134
+ /**
135
+ * The known refresh token a request carries, and where, or null. `known` is
136
+ * what the session last loaded from the profile; a token the page got some
137
+ * other way is not the role's and is left alone. A token in the body, the URL
138
+ * or a header the page set wins over one that is only in the Cookie header.
113
139
  */
114
- export function presentedToken(req, known) {
140
+ export function tokenPresence(req, known) {
115
141
  if (known.length === 0)
116
142
  return null;
117
- const haystacks = [req.body ?? "", req.url, ...Object.values(req.headers)];
143
+ const carries = (haystack, t) => wireForms(t.value).some((form) => haystack.includes(form));
144
+ const explicit = Object.entries(req.headers)
145
+ .filter(([name]) => !browserAddedHeader(name))
146
+ .map(([, value]) => value);
118
147
  for (const t of known) {
119
- for (const form of wireForms(t.value)) {
120
- if (haystacks.some((h) => h.includes(form)))
121
- return t;
122
- }
148
+ if (req.body && carries(req.body, t))
149
+ return { slot: t, via: "body" };
150
+ if (carries(req.url, t))
151
+ return { slot: t, via: "url" };
152
+ if (explicit.some((h) => carries(h, t)))
153
+ return { slot: t, via: "header" };
123
154
  }
155
+ const cookie = Object.entries(req.headers).find(([name]) => name.toLowerCase() === "cookie")?.[1] ?? "";
156
+ for (const t of known)
157
+ if (cookie && carries(cookie, t))
158
+ return { slot: t, via: "cookie" };
124
159
  return null;
125
160
  }
161
+ /**
162
+ * Requests for a page's static assets. They are never brokered, waited on or
163
+ * dropped, whatever cookie rides on them: holding a script back behind the
164
+ * refresh lock leaves the app under test without its code.
165
+ */
166
+ export const STATIC_RESOURCE_TYPES = new Set(["script", "stylesheet", "image", "font", "media", "manifest", "texttrack"]);
167
+ export function isStaticAsset(method, resourceType) {
168
+ return (method === "GET" || method === "HEAD") && STATIC_RESOURCE_TYPES.has(resourceType);
169
+ }
170
+ /**
171
+ * Path segments that name a token refresh. Whole segments, as the write
172
+ * policy's auth segments are, never substrings: `/api/tokens` mints an API
173
+ * token and `/refreshments` is a menu.
174
+ */
175
+ const REFRESH_SEGMENT_RE = /^(refresh|refresh[-_]?token|token|renew|reauth|reauthenticate)$/i;
176
+ /** A path whose last one or two segments name a token refresh: /auth/refresh, /oauth/token, /api/token/refresh. */
177
+ export function refreshLikePath(pathname) {
178
+ const segments = pathname.split("/").filter(Boolean);
179
+ return segments.slice(-2).some((seg) => REFRESH_SEGMENT_RE.test(seg));
180
+ }
181
+ /** An endpoint as the broker remembers it: the method and the path, without the query. */
182
+ export function endpointKey(method, url) {
183
+ let pathname;
184
+ try {
185
+ pathname = new URL(url).pathname;
186
+ }
187
+ catch {
188
+ pathname = url.split("?")[0];
189
+ }
190
+ return `${method.toUpperCase()} ${pathname}`;
191
+ }
192
+ /**
193
+ * Path segments of a sign-in, a sign-up or a sign-out. A cookie such a request
194
+ * sets is a new sign-in, perhaps as somebody else, not a refresh: the broker
195
+ * neither learns the endpoint nor saves the cookie over the role's profile.
196
+ */
197
+ const SIGN_IN_SEGMENT_RE = /^(login|log-in|signin|sign-in|logout|log-out|signout|sign-out|signup|sign-up|register|verify|otp|magic-link|callback|authorize|sso)$/i;
198
+ /** Whether an endpoint seen to rotate the token may be learned as the refresh call: any but a sign-in, sign-up or sign-out. */
199
+ export function learnableEndpoint(url) {
200
+ let pathname;
201
+ try {
202
+ pathname = new URL(url).pathname;
203
+ }
204
+ catch {
205
+ return false;
206
+ }
207
+ return !pathname
208
+ .split("/")
209
+ .filter(Boolean)
210
+ .slice(-2)
211
+ .some((seg) => SIGN_IN_SEGMENT_RE.test(seg));
212
+ }
213
+ /** A body that asks for a grant other than a refresh (a password or code sign-in) is not a refresh, whatever cookie rides on it. */
214
+ function otherGrant(body) {
215
+ const m = /grant_type["']?\s*[=:]\s*["']?([A-Za-z_:.-]+)/.exec(body ?? "");
216
+ return m !== null && !/refresh/i.test(m[1]);
217
+ }
218
+ /**
219
+ * Whether a token that is only in the Cookie header could make this request a
220
+ * refresh, so that header is worth reading at all. A cookie alone never does,
221
+ * since a cookie scoped to "/" rides on every request. Only a request that is
222
+ * plausibly the refresh call counts: one the broker has learned rotates the
223
+ * token (`learned`, by endpointKey), or a POST, PUT or PATCH to a path whose
224
+ * last one or two segments name a refresh.
225
+ */
226
+ export function cookieMayCount(method, url, learned) {
227
+ if (learned.has(endpointKey(method, url)))
228
+ return true;
229
+ if (method !== "POST" && method !== "PUT" && method !== "PATCH")
230
+ return false;
231
+ let pathname;
232
+ try {
233
+ pathname = new URL(url).pathname;
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ return refreshLikePath(pathname);
239
+ }
240
+ /**
241
+ * Whether a request goes through the refresh lock. A static asset never does.
242
+ * A request carrying a known token in its body, URL or a header the page set
243
+ * does. A token that is only in the Cookie header counts only for a request
244
+ * that is plausibly the refresh call (cookieMayCount), and not for one whose
245
+ * body asks for another grant.
246
+ */
247
+ export function brokerDecision(req, known, learned) {
248
+ if (isStaticAsset(req.method, req.resourceType))
249
+ return { kind: "pass", why: "static asset" };
250
+ const found = tokenPresence(req, known);
251
+ if (!found)
252
+ return { kind: "pass", why: "no known token" };
253
+ if (found.via !== "cookie")
254
+ return { kind: "broker", sent: found.slot, via: found.via };
255
+ if (!cookieMayCount(req.method, req.url, learned))
256
+ return { kind: "pass", why: "only in a cookie" };
257
+ if (otherGrant(req.body))
258
+ return { kind: "pass", why: "another grant" };
259
+ return { kind: "broker", sent: found.slot, via: "cookie" };
260
+ }
126
261
  export function planRefresh(sent, current) {
127
262
  if (current.some((c) => c.value === sent.value))
128
263
  return { kind: "send" };
@@ -180,6 +315,35 @@ export function profileAfterRotation(pageState, profileOnDisk) {
180
315
  return pageState;
181
316
  return withSessionStorage(pageState, splitProfile(profileOnDisk).sessionStorage);
182
317
  }
318
+ /**
319
+ * The cookies a response's Set-Cookie headers set, by name. A header value may
320
+ * hold several cookies joined by newlines, as some browsers report them.
321
+ */
322
+ function setCookiePairs(setCookies) {
323
+ const out = new Map();
324
+ for (const line of setCookies.flatMap((l) => l.split("\n"))) {
325
+ const pair = line.split(";")[0];
326
+ const eq = pair.indexOf("=");
327
+ if (eq <= 0)
328
+ continue;
329
+ out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
330
+ }
331
+ return out;
332
+ }
333
+ /**
334
+ * The known cookie tokens a response's Set-Cookie headers replace with a new
335
+ * token: the request that got this response refreshed them. A cookie set to an
336
+ * empty or short value is being cleared (a sign-out), not rotated.
337
+ */
338
+ export function rotatedCookies(setCookies, known) {
339
+ const set = setCookiePairs(setCookies);
340
+ return known.filter((t) => {
341
+ if (t.cookie === undefined)
342
+ return false;
343
+ const next = set.get(t.cookie);
344
+ return looksLikeToken(next) && next !== t.value;
345
+ });
346
+ }
183
347
  /**
184
348
  * The rotated refresh token a refresh response carries, for a page that did
185
349
  * not store it in time: a refresh-named field in a JSON body, else a
@@ -198,16 +362,9 @@ export function rotatedFromResponse(body, setCookies) {
198
362
  // Not a JSON body: the Set-Cookie headers are all there is to read.
199
363
  }
200
364
  if (found.size === 0) {
201
- for (const line of setCookies) {
202
- const pair = line.split(";")[0];
203
- const eq = pair.indexOf("=");
204
- if (eq <= 0)
205
- continue;
206
- const name = pair.slice(0, eq).trim();
207
- const value = pair.slice(eq + 1).trim();
365
+ for (const [name, value] of setCookiePairs(setCookies))
208
366
  if (REFRESH_NAME_RE.test(name) && looksLikeToken(value))
209
367
  found.add(value);
210
- }
211
368
  }
212
369
  return found.size === 1 ? [...found][0] : null;
213
370
  }
@@ -224,6 +381,26 @@ export function swapProfileToken(state, from, to) {
224
381
  }
225
382
  return s;
226
383
  }
384
+ /**
385
+ * The headers the broker sends a request with when it has to send the request
386
+ * itself, which it does only to put the current token in a Cookie header (a
387
+ * browser keeps the Cookie header it built, whatever a route override says).
388
+ * The page's own headers are kept; the ones the HTTP client sets for itself
389
+ * (host, content length, connection, the encodings it can decode) and
390
+ * pseudo-headers are left out, and `cookie` is the line to send.
391
+ */
392
+ export function headersForResend(headers, cookie) {
393
+ const out = {};
394
+ for (const [name, value] of Object.entries(headers)) {
395
+ const n = name.toLowerCase();
396
+ if (n.startsWith(":") || ["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "cookie"].includes(n))
397
+ continue;
398
+ out[n] = value;
399
+ }
400
+ if (cookie)
401
+ out.cookie = cookie;
402
+ return out;
403
+ }
227
404
  // ── Whether the broker runs ──────────────────────────────────────────────────
228
405
  export const REFRESH_BROKER_ENV = "SCENESCOUT_REFRESH_BROKER";
229
406
  /**
@@ -255,6 +432,60 @@ export const DEFAULT_WAIT_MS = 45_000;
255
432
  export function lockPathFor(profileFile) {
256
433
  return `${profileFile}.lock`;
257
434
  }
435
+ // ── Endpoints learned to rotate the token ──────────────────────────────────
436
+ /**
437
+ * Where the broker keeps the endpoints it has seen rotate the role's refresh
438
+ * token, beside the profile: every session of the role reads it, so a session
439
+ * brokers an endpoint another session learned, even in another process.
440
+ */
441
+ export function endpointsPathFor(profileFile) {
442
+ return `${profileFile}.endpoints`;
443
+ }
444
+ /** At most this many learned endpoints are kept, newest first. */
445
+ export const MAX_LEARNED_ENDPOINTS = 20;
446
+ const ENDPOINT_KEY_RE = /^(GET|POST|PUT|PATCH) \/\S{0,500}$/;
447
+ /** The learned endpoints on disk. None when there is no file yet; a file that is not the broker's teaches nothing. */
448
+ export function readLearnedEndpoints(file) {
449
+ let raw;
450
+ try {
451
+ raw = fs.readFileSync(file, "utf8");
452
+ }
453
+ catch (err) {
454
+ if (err.code === "ENOENT")
455
+ return [];
456
+ throw err;
457
+ }
458
+ let parsed;
459
+ try {
460
+ parsed = JSON.parse(raw);
461
+ }
462
+ catch {
463
+ return []; // written by rename, so never half-written: whatever this is, it is not a list of endpoints
464
+ }
465
+ const list = parsed?.endpoints;
466
+ if (!Array.isArray(list))
467
+ return [];
468
+ return list.filter((e) => typeof e === "string" && ENDPOINT_KEY_RE.test(e)).slice(0, MAX_LEARNED_ENDPOINTS);
469
+ }
470
+ /** The list with `key` added first, once, and the oldest dropped past the cap. A key that is not an endpoint changes nothing. */
471
+ export function withLearnedEndpoint(list, key) {
472
+ if (!ENDPOINT_KEY_RE.test(key))
473
+ return [...list];
474
+ return [key, ...list.filter((e) => e !== key)].slice(0, MAX_LEARNED_ENDPOINTS);
475
+ }
476
+ /** Write the learned endpoints owner-only, by rename, so a reader never sees half a file. Call it holding the lock. */
477
+ export function writeLearnedEndpoints(file, list) {
478
+ const temp = `${file}.${process.pid}.tmp`;
479
+ fs.rmSync(temp, { force: true });
480
+ const fd = fs.openSync(temp, "wx", LOCK_FILE_MODE);
481
+ try {
482
+ fs.writeFileSync(fd, JSON.stringify({ endpoints: list }));
483
+ }
484
+ finally {
485
+ fs.closeSync(fd);
486
+ }
487
+ fs.renameSync(temp, file);
488
+ }
258
489
  /** Whether a lock last touched at `mtimeMs` is stale at `now`. */
259
490
  export function isStale(mtimeMs, now, staleMs) {
260
491
  return now - mtimeMs > staleMs;
@@ -275,6 +506,20 @@ function readNonce(file) {
275
506
  }
276
507
  }
277
508
  /** One attempt: the lock, or null when another process holds a live one. */
509
+ /**
510
+ * What an error from creating the lock file means. EEXIST: another process
511
+ * holds it. On Windows, creating a file whose previous copy is still being
512
+ * deleted (a lock just released) fails with EPERM, EBUSY or EACCES instead;
513
+ * that is the lock changing hands, so it is waited out like a held lock, not
514
+ * reported as a failure. Anything else is a real error.
515
+ */
516
+ export function lockCreateError(code, platform = process.platform) {
517
+ if (code === "EEXIST")
518
+ return "held";
519
+ if (platform === "win32" && (code === "EPERM" || code === "EBUSY" || code === "EACCES"))
520
+ return "busy";
521
+ return "error";
522
+ }
278
523
  export function tryAcquireLock(file, opts = {}) {
279
524
  const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
280
525
  const now = opts.now ?? Date.now;
@@ -289,8 +534,11 @@ export function tryAcquireLock(file, opts = {}) {
289
534
  }
290
535
  }
291
536
  catch (err) {
292
- if (err.code !== "EEXIST")
537
+ const meaning = lockCreateError(err.code, opts.platform);
538
+ if (meaning === "error")
293
539
  throw err;
540
+ if (meaning === "busy")
541
+ return null; // being deleted as it changes hands: try again next poll
294
542
  let mtimeMs;
295
543
  try {
296
544
  mtimeMs = fs.statSync(file).mtimeMs;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { SHARED_CHROME_ROUTE, isEmbedKey, isWorthALook } from "./memory.js";
3
+ import { SHARED_CHROME_ROUTE, isEmbedKey, isWorthALook, judgedMergesOf } from "./memory.js";
4
4
  import { sayVerification } from "./verify.js";
5
5
  import { feedForSession } from "./live.js";
6
6
  import { buildReplayHtml, evidenceFor } from "./replay.js";
@@ -503,6 +503,9 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
503
503
  if (extras?.policyAttributed) {
504
504
  lines.push(`| Errors caused by the tester's own write-policy blocks (not counted above) | ${extras.policyAttributed} |`);
505
505
  }
506
+ const judged = memory.dedupJudge?.describe() ?? (memory.dedupOff ? `the rule alone: the dedup judge was asked for and is off (${memory.dedupOff})` : null);
507
+ if (judged)
508
+ lines.push(`| Finding dedup | ${judged.replace(/\|/g, "/")} |`);
506
509
  lines.push(`| Elements exercised (informational — denominator grows with every state) | ${cov.elementsExercised}/${cov.elementsTotal} |`);
507
510
  lines.push(``);
508
511
  // ---- Page quality scores, worst first — the cross-page comparator. ----
@@ -601,6 +604,10 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
601
604
  lines.push(`- **Evidence:** \`${f.evidence}\``);
602
605
  lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
603
606
  lines.push(`- **Seen in runs:** ${f.runs}`);
607
+ // A merge the model made is shown with what was filed, so a wrong one can be seen, and refiled as its own defect (ADR 4).
608
+ for (const m of judgedMergesOf(f)) {
609
+ lines.push(`- **Merged by the dedup judge** (p_same ${m.pSame.toFixed(2)}, ${m.at.slice(0, 10)}): [${m.severity}] ${m.title}, filed as ${m.category}${m.evidence ? ` with evidence \`${m.evidence}\`` : ""}`);
610
+ }
604
611
  // Only printed once somebody has re-tested it. A finding nobody has looked
605
612
  // at again says nothing here, which is the honest thing for it to say.
606
613
  if (f.verdict && f.verifiedAt) {