scenescout 3.23.3 → 3.23.4

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +20 -3
  3. package/dist/browsers.js +7 -6
  4. package/dist/ci-run.js +3 -2
  5. package/dist/cli.js +2 -1
  6. package/dist/clients.js +3 -3
  7. package/dist/code-routes.js +2 -2
  8. package/dist/commands.js +4 -4
  9. package/dist/engine/args.js +21 -0
  10. package/dist/engine/bench.js +1 -1
  11. package/dist/engine/brief.js +1 -1
  12. package/dist/engine/browser.js +59 -15
  13. package/dist/engine/capture.js +4 -4
  14. package/dist/engine/check-replay.js +82 -8
  15. package/dist/engine/check-report.js +8 -8
  16. package/dist/engine/check.js +15 -15
  17. package/dist/engine/ci.js +20 -20
  18. package/dist/engine/claims.js +4 -4
  19. package/dist/engine/collector.js +7 -7
  20. package/dist/engine/crawl.js +1 -1
  21. package/dist/engine/dedup.js +4 -4
  22. package/dist/engine/design.js +7 -7
  23. package/dist/engine/expiry.js +1 -1
  24. package/dist/engine/export.js +7 -7
  25. package/dist/engine/flow.js +10 -10
  26. package/dist/engine/forms.js +1 -1
  27. package/dist/engine/from-run.js +6 -6
  28. package/dist/engine/injection.js +1 -1
  29. package/dist/engine/lane.js +1 -1
  30. package/dist/engine/limits.js +2 -2
  31. package/dist/engine/live.js +30 -0
  32. package/dist/engine/memory.js +24 -7
  33. package/dist/engine/open.js +1 -1
  34. package/dist/engine/oracles.js +2 -2
  35. package/dist/engine/plain.js +1 -1
  36. package/dist/engine/png.js +2 -2
  37. package/dist/engine/policy.js +2 -2
  38. package/dist/engine/postmessage.js +7 -7
  39. package/dist/engine/project-folder.js +1 -1
  40. package/dist/engine/provider.js +1 -1
  41. package/dist/engine/replay.js +3 -1
  42. package/dist/engine/report.js +3 -3
  43. package/dist/engine/sarif.js +2 -2
  44. package/dist/engine/script-literal.js +11 -0
  45. package/dist/engine/sensitive.js +254 -0
  46. package/dist/engine/signed-in.js +2 -2
  47. package/dist/engine/status-pane-page.js +4 -3
  48. package/dist/engine/task.js +1 -1
  49. package/dist/engine/teardown.js +2 -0
  50. package/dist/engine/tickets.js +6 -6
  51. package/dist/engine/verify.js +3 -3
  52. package/dist/export-run.js +1 -1
  53. package/dist/installer.js +4 -4
  54. package/dist/login-run.js +2 -2
  55. package/dist/mcp-server.js +6 -9
  56. package/dist/playbook.js +1 -1
  57. package/package.json +2 -2
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { laneRoutePaths, normalizePath, shortHash, stripRouteQuery } from "./fingerprint.js";
5
5
  import { isFormBookkeeping } from "./forms.js";
6
+ import { maskSecretFieldValues, maskSecretParams, redactPersonal } from "./sensitive.js";
6
7
  import { readFromRunNotes, readRunRecords, recordFromJson, unionFromRunNotes, unionRecords } from "./from-run.js";
7
8
  import { addReading, addVerdict, MAX_TICKETS_KEPT, mergeTicketData } from "./tickets.js";
8
9
  /**
@@ -230,20 +231,30 @@ const SECRET_PATTERNS = [
230
231
  [/\b(api[-_]?key|secret|password|token)(["'\s:=]+)(?=[A-Za-z0-9._~+/=-]*\d)(?=[A-Za-z0-9._~+/=-]*[A-Za-z])[A-Za-z0-9._~+/=]{12,}/gi, "$1$2[redacted]"],
231
232
  ];
232
233
  /**
233
- * Strip anything that looks like a credential from text we are about to persist.
234
+ * Strip anything that looks like a credential from text we are about to
235
+ * persist: credential shapes, a value typed into a field whose label names a
236
+ * secret (`textbox "Password" ← "…"`, whatever its length), the value of a
237
+ * parameter named for one (`?password=…`), and personal data
238
+ * (email addresses and card numbers, sensitive.ts).
234
239
  *
235
240
  * Silent edits are their own hazard, so a redaction announces itself: a reader
236
241
  * who sees mangled-looking text needs to know the tool did it deliberately.
242
+ * Credentials and masked values are counted in a trailing note; personal data
243
+ * is replaced by a marker that names it (`[email redacted]`).
237
244
  */
238
245
  export function redactSecrets(text) {
239
- let out = text;
240
- let hits = 0;
246
+ const masked = maskSecretFieldValues(text);
247
+ // A parameter named for a secret (`?pin=…` from a GET form) is masked whatever its value looks like.
248
+ const params = maskSecretParams(masked.text);
249
+ let out = params.text;
250
+ let hits = masked.hits + params.hits;
241
251
  for (const [re, replacement] of SECRET_PATTERNS) {
242
252
  out = out.replace(re, (...args) => {
243
253
  hits += 1;
244
254
  return replacement.replace(/\$(\d)/g, (_, n) => String(args[Number(n)] ?? ""));
245
255
  });
246
256
  }
257
+ out = redactPersonal(out);
247
258
  return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
248
259
  }
249
260
  /**
@@ -1927,11 +1938,17 @@ export class MemoryStore {
1927
1938
  * Append to the action log — the choke point where redaction belongs.
1928
1939
  *
1929
1940
  * Redacting only a finding's title/detail/evidence left the same secrets
1930
- * flowing by a parallel route: `repro` is built from these entries, and a
1931
- * `type` step logs the text that was typed (a password, during an auth-flow
1932
- * pass) while every entry carries a full URL that may hold `?token=…`. Both
1933
- * land in memory.json and are rendered into the report. One filter here
1941
+ * flowing by a parallel route: `repro` is built from these entries, a `type`
1942
+ * step logs the text that was typed, and every entry carries a full URL that
1943
+ * may hold `?token=…`. All of it lands in memory.json and is rendered into
1944
+ * the report, the live view's feed and exported issues. One filter here
1934
1945
  * covers the log, the repro traces and the JSONL trail at once.
1946
+ *
1947
+ * A value typed into a secret field (type=password, a credential or card
1948
+ * autocomplete token, or a name or label that says so) is masked by the
1949
+ * browser before it gets here (sensitive.ts secretFieldKind). redactSecrets
1950
+ * masks it again where only the label is left to go on, and catches
1951
+ * credential shapes, email addresses and card numbers in anything else.
1935
1952
  */
1936
1953
  logAction(entry) {
1937
1954
  const full = {
@@ -34,7 +34,7 @@ export function openChoiceFromEnv(env) {
34
34
  * browser would open on the remote machine if at all, or a Linux or other
35
35
  * Unix machine with no display server. macOS and Windows always have one.
36
36
  */
37
- export function noDisplay(env, platform) {
37
+ function noDisplay(env, platform) {
38
38
  if (env.SSH_CONNECTION || env.SSH_TTY)
39
39
  return true;
40
40
  if (platform === "darwin" || platform === "win32")
@@ -196,7 +196,7 @@ export function isRouteCancellation(message, c) {
196
196
  return ROUTE_CANCEL_RE.test(message) || (c.viaLink && c.dialogOpened);
197
197
  }
198
198
  /** Appended to a page error read as a route-change cancellation, saying which evidence it was read on. */
199
- export function routeCancelNote(message) {
199
+ function routeCancelNote(message) {
200
200
  const why = ROUTE_CANCEL_RE.test(message)
201
201
  ? "the click left the URL unchanged and the error says the route change was cancelled"
202
202
  : "the click on a link left the URL unchanged and the page opened a dialog instead";
@@ -456,7 +456,7 @@ export class OracleMonitor {
456
456
  * uncaught exception, a failed request, typed markup coming back, a page
457
457
  * contradicting the server, a token posted to any origin) stops a plan either way.
458
458
  */
459
- export const CONTINUABLE_KINDS = new Set(["http_error", "console_error"]);
459
+ const CONTINUABLE_KINDS = new Set(["http_error", "console_error"]);
460
460
  /** Whether these violations, drained after one plan step, end the plan. Repeats of already-reported violations never do. */
461
461
  export function planStopsAt(violations, rule) {
462
462
  const fresh = violations.filter((v) => !v.repeat);
@@ -184,7 +184,7 @@ function joinAnd(parts) {
184
184
  return parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
185
185
  }
186
186
  /** The short summary that opens the plain section. */
187
- export function plainSummary(input) {
187
+ function plainSummary(input) {
188
188
  const lines = [];
189
189
  const { current } = input;
190
190
  const by = (s) => current.filter((f) => f.severity === s).length;
@@ -12,7 +12,7 @@ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
12
12
  /** Channels a pixel has, by PNG colour type: grey, RGB, grey and alpha, RGBA. Palette images are not read. */
13
13
  const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
14
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;
15
+ const MAX_PIXELS = 40_000_000;
16
16
  const CRC_TABLE = (() => {
17
17
  const t = new Uint32Array(256);
18
18
  for (let n = 0; n < 256; n++) {
@@ -162,7 +162,7 @@ export function encodePng(img) {
162
162
  * match exactly; the allowance absorbs the odd colour rounded one step
163
163
  * differently, not a change anyone would see.
164
164
  */
165
- export const DIFF_THRESHOLD = 8;
165
+ const DIFF_THRESHOLD = 8;
166
166
  /** The colour a changed pixel is painted in the diff picture. */
167
167
  export const DIFF_HIGHLIGHT = [255, 0, 80];
168
168
  /**
@@ -3,6 +3,7 @@
3
3
  * with elements whose accessible name / testid / id matches these patterns —
4
4
  * enforcement lives here, at the tool layer, never in the model's prompt.
5
5
  */
6
+ import { scriptLiteral } from "./script-literal.js";
6
7
  const DESTRUCTIVE_PATTERNS = [
7
8
  /\bdelete\b/i,
8
9
  // "Remove" a filter chip drops a condition from the view: "Remove Status:
@@ -634,9 +635,8 @@ export function offAppPageWrite(appUrl, pageUrl, destinationUrl, movedByEmbed) {
634
635
  * the browser without asking, and the page it lands on was not sandboxed.
635
636
  */
636
637
  export function sandboxedRedirectPage(target) {
637
- const json = JSON.stringify(target).replace(/</g, "\\u003c");
638
638
  // No referrer: the next hop would otherwise name this stand-in page, where a real redirect names the app.
639
- return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><script>location.replace(${json});</script>`;
639
+ return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><script>location.replace(${scriptLiteral(target)});</script>`;
640
640
  }
641
641
  /** The last path segment of a page that is a sign-in page, and nothing else: not a verification step, where a payment provider's frame sits. */
642
642
  const SIGN_IN_SEGMENT_RE = /^(login|log-in|signin|sign-in|signup|sign-up|sso|oauth)$/i;
@@ -50,18 +50,18 @@ import { normalizePath } from "./fingerprint.js";
50
50
  /** The binding the capture script reports through. */
51
51
  export const POSTMESSAGE_BINDING = "__scenescoutPostMessage";
52
52
  /** Strings shorter than this are not sent to the engine: nothing token-shaped is shorter. */
53
- export const MIN_CANDIDATE_LENGTH = 16;
53
+ const MIN_CANDIDATE_LENGTH = 16;
54
54
  /** Longest string sent; a JWT carrying many claims runs to a few kilobytes. */
55
- export const MAX_CANDIDATE_LENGTH = 8192;
55
+ const MAX_CANDIDATE_LENGTH = 8192;
56
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;
57
+ const MAX_JSON_LENGTH = 262144;
58
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;
59
+ const MAX_VISITS = 2000;
60
60
  /** Most items read from any one array or object. */
61
61
  export const MAX_ITEMS = 200;
62
62
  /** Most strings sent per message, and the deepest nesting walked. */
63
- export const MAX_CANDIDATES = 50;
64
- export const MAX_DEPTH = 6;
63
+ const MAX_CANDIDATES = 50;
64
+ const MAX_DEPTH = 6;
65
65
  /** Whether a targetOrigin lets any origin read the message. Only "*" does; "/" is the sender's own origin. */
66
66
  export function postsToAnyOrigin(targetOrigin) {
67
67
  return targetOrigin === "*";
@@ -139,7 +139,7 @@ export function postMessageCaptureScript(binding = POSTMESSAGE_BINDING) {
139
139
  })();`;
140
140
  }
141
141
  /** Key names, lower-cased with `_` and `-` removed, whose value is a credential. */
142
- export const CREDENTIAL_KEYS = new Set([
142
+ const CREDENTIAL_KEYS = new Set([
143
143
  "accesstoken",
144
144
  "idtoken",
145
145
  "refreshtoken",
@@ -25,7 +25,7 @@ import { MEMORY_DIRNAME } from "./memory.js";
25
25
  /** The setting: an absolute folder that holds one folder per tested site, or `off`. */
26
26
  export const PROJECTS_DIR_ENV = "SCENESCOUT_PROJECTS_DIR";
27
27
  /** The folder made under the documents folder when the setting is unset. */
28
- export const DEFAULT_PROJECTS_DIRNAME = "SceneScout";
28
+ const DEFAULT_PROJECTS_DIRNAME = "SceneScout";
29
29
  /**
30
30
  * The user's documents folder. Windows: `%USERPROFILE%\Documents`; Linux and
31
31
  * others: XDG_DOCUMENTS_DIR from the environment or user-dirs.dirs (which the
@@ -2,7 +2,7 @@
2
2
  export class MalformedReply extends Error {
3
3
  }
4
4
  /** The most output one model call may produce. Large enough for a plan of steps; small enough that one reply cannot spend the budget. */
5
- export const MAX_OUTPUT_TOKENS = 16_000;
5
+ const MAX_OUTPUT_TOKENS = 16_000;
6
6
  /** A plain object as a record, or null for anything else (an array, null, a string). */
7
7
  export const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
8
8
  const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
@@ -21,6 +21,7 @@
21
21
  import crypto from "node:crypto";
22
22
  import path from "node:path";
23
23
  import { isSafeRelativePath } from "./plain.js";
24
+ import { scriptLiteral } from "./script-literal.js";
24
25
  /** Most frames one recorded session keeps. A long run is thousands of actions, and a project folder is not a video store. */
25
26
  export const RECORD_MAX_FRAMES = 600;
26
27
  /** A name reduced to one plain path segment: letters, digits, dot, dash and underscore, never leading with a dot or dash. */
@@ -407,7 +408,8 @@ details.evidence figcaption { margin-top:4px; color:var(--muted); font:11px/1.5
407
408
  function exitWatch(savedFile) {
408
409
  return ("<script>(function () {\n" +
409
410
  " var saved = " +
410
- JSON.stringify(savedFile) +
411
+ // Escaped: a folder named with "</script>" must not end this element.
412
+ scriptLiteral(savedFile) +
411
413
  ";\n" +
412
414
  " var gone = false;\n" +
413
415
  " var bar = document.getElementById('gone-bar');\n" +
@@ -117,7 +117,7 @@ function projectName(dir) {
117
117
  * when there are none. The first bullet names the id, as a finding's does, so
118
118
  * the live view hangs the frames recorded around it under the entry.
119
119
  */
120
- export function formatWorthALook(items, sessionStart) {
120
+ function formatWorthALook(items, sessionStart) {
121
121
  if (items.length === 0)
122
122
  return [];
123
123
  const lines = [
@@ -139,7 +139,7 @@ export function formatWorthALook(items, sessionStart) {
139
139
  return lines;
140
140
  }
141
141
  /** A finding's picture as a bullet, its path relative to report.md, or nothing when it has none. */
142
- export function pictureLine(f) {
142
+ function pictureLine(f) {
143
143
  const p = readFindingPicture(f);
144
144
  return p ? [`- **Picture:** \`${p.file}\` (${describePicture(p)})`] : [];
145
145
  }
@@ -182,7 +182,7 @@ function replaySessions(memory) {
182
182
  }));
183
183
  }
184
184
  /** The frames that were on screen while each finding was being found. */
185
- export function findingEvidence(memory, sessions) {
185
+ function findingEvidence(memory, sessions) {
186
186
  const all = sessions.flatMap((s) => s.steps).sort((a, b) => a.at.localeCompare(b.at));
187
187
  return memory.findings
188
188
  .map((f) => {
@@ -80,11 +80,11 @@ export function resolveSarifAnchor(o) {
80
80
  return { ...chosen, warning: `SARIF: ${missed.map(describe).join(" and ")} is not in the repository; results point at ${chosen.file} instead` };
81
81
  }
82
82
  /** The repository root the paths are relative to: the Actions checkout when there is one, else the project. */
83
- export function repositoryRoot(env, projectDir) {
83
+ function repositoryRoot(env, projectDir) {
84
84
  return env.GITHUB_WORKSPACE || projectDir;
85
85
  }
86
86
  /** `target` relative to `root` with forward slashes, or null when it is outside it. */
87
- export function repoRelative(root, target) {
87
+ function repoRelative(root, target) {
88
88
  const rel = path.relative(path.resolve(root), path.resolve(target));
89
89
  if (rel === "")
90
90
  return "";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A value written into an inline `<script>` as a JavaScript literal.
3
+ *
4
+ * JSON alone is not enough there: the HTML parser ends a script element at the
5
+ * first `</script`, and `<!--` or `<script` inside it change how the rest is
6
+ * read, before any JavaScript runs. Every `<` is written as `<`, which
7
+ * the string reads back as the same character, so none of those can appear.
8
+ */
9
+ export function scriptLiteral(value) {
10
+ return JSON.stringify(value).replace(/</g, "\\u003c");
11
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * What must never be written down in clear: the value typed into a secret
3
+ * field, and personal data (email addresses, card numbers) in any text the
4
+ * tool keeps. Pure functions of what the page and the text say, so
5
+ * memory-test table-tests them; browser.ts only gathers the facts.
6
+ *
7
+ * Masking errs towards hiding. A field wrongly taken for a secret costs a repro
8
+ * one typed value; a password wrongly taken for an ordinary field is published
9
+ * in the action log, the report and every issue exported from it.
10
+ */
11
+ /** The autocomplete tokens (HTML's autofill field names) that mark a credential or a payment card. */
12
+ const AUTOCOMPLETE_KINDS = {
13
+ "current-password": "password",
14
+ "new-password": "password",
15
+ "one-time-code": "one-time code",
16
+ "cc-number": "card",
17
+ "cc-csc": "card",
18
+ "cc-exp": "card",
19
+ "cc-exp-month": "card",
20
+ "cc-exp-year": "card",
21
+ };
22
+ /**
23
+ * Words that name a secret, matched against a name's words (camelCase, `_`,
24
+ * `-` and spaces split them; trailing digits dropped, so `password2` and
25
+ * `cvv2` count). A word in `whole` must be the whole word, so "spinner",
26
+ * "pinned", "tokens" and "tokenizer" are not a PIN or a token. A word in
27
+ * `suffix` may also end a longer one (`userpassword`, `clientsecret`, `totp`), but not
28
+ * start it: "passwordless" and "secretary" are not secrets.
29
+ */
30
+ const WORD_KINDS = [
31
+ { kind: "password", whole: ["pwd"], suffix: ["password", "passwd", "passphrase"], phrases: [] },
32
+ {
33
+ kind: "one-time code",
34
+ whole: ["pin", "onetime", "otpcode", "mfa", "authenticator"],
35
+ suffix: ["otp", "passcode"],
36
+ phrases: ["one time", "verification code", "pass code", "auth code", "authentication code", "two factor"],
37
+ },
38
+ {
39
+ kind: "card",
40
+ whole: ["cvc", "cvv", "csc", "ccv", "ccnum"],
41
+ suffix: ["cardnumber", "ccnumber"],
42
+ phrases: ["security code", "card number", "card no", "credit card", "cc number", "cc num", "cc csc", "cc exp"],
43
+ },
44
+ {
45
+ kind: "secret",
46
+ whole: ["token"],
47
+ suffix: ["secret", "apikey"],
48
+ phrases: ["api key", "recovery code", "backup code", "private key", "seed phrase", "recovery phrase", "security answer"],
49
+ },
50
+ ];
51
+ /** A name's words, lower-case: `newPassword` → new password, `PINCode` → pin code, `cvv2` → cvv. */
52
+ function wordsOf(text) {
53
+ return text
54
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
55
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
56
+ .toLowerCase()
57
+ .split(/[^\p{L}\p{N}]+/u)
58
+ .map((w) => w.replace(/\d+$/, ""))
59
+ .filter((w) => w.length > 0);
60
+ }
61
+ /** Which kind of secret a field's name, label or placeholder names, if any. */
62
+ export function secretWordKind(text) {
63
+ // "2FA" splits into "2" and "fa" below, so it is read before the split.
64
+ if (/(?:^|[^\p{L}\p{N}])2fa(?![\p{L}\p{N}])/iu.test(text))
65
+ return "one-time code";
66
+ const words = wordsOf(text);
67
+ if (words.length === 0)
68
+ return null;
69
+ const joined = ` ${words.join(" ")} `;
70
+ for (const { kind, whole, suffix, phrases } of WORD_KINDS) {
71
+ if (words.some((w) => whole.includes(w) || suffix.some((s) => w.endsWith(s))))
72
+ return kind;
73
+ if (phrases.some((p) => joined.includes(` ${p} `)))
74
+ return kind;
75
+ }
76
+ return null;
77
+ }
78
+ /** The input types a split code's boxes use (scripted-login.ts reads the same boxes). */
79
+ const SINGLE_CHARACTER_TYPES = new Set(["", "text", "tel", "number", "password"]);
80
+ /** Whether what is typed into this field is a secret, and which kind; null for an ordinary field. */
81
+ export function secretFieldKind(facts) {
82
+ if ((facts.type ?? "").trim().toLowerCase() === "password")
83
+ return "password";
84
+ for (const token of (facts.autocomplete ?? "").toLowerCase().split(/\s+/)) {
85
+ const kind = AUTOCOMPLETE_KINDS[token];
86
+ if (kind)
87
+ return kind;
88
+ }
89
+ // One box per character is a split one-time code, whatever each box is called ("Digit 1").
90
+ if (facts.maxLength === 1 && SINGLE_CHARACTER_TYPES.has((facts.type ?? "").trim().toLowerCase()))
91
+ return "one-time code";
92
+ const labels = typeof facts.label === "string" ? [facts.label] : (facts.label ?? []);
93
+ for (const text of [facts.name, facts.id, ...labels, facts.placeholder]) {
94
+ const kind = text ? secretWordKind(text) : null;
95
+ if (kind)
96
+ return kind;
97
+ }
98
+ return null;
99
+ }
100
+ /** The fixed stand-in for a secret field's value: the same whatever was typed, so not even its length is kept. */
101
+ export function maskedValue(kind) {
102
+ return `•••• (masked: ${kind} field)`;
103
+ }
104
+ /** A JSON-quoted string, as the type result writes a value. */
105
+ const QUOTED = String.raw `"(?:[^"\\]|\\.)*"`;
106
+ /** `"<label>" ← "<value>"`, the shape a type entry names its field and value in. */
107
+ const TYPED_INTO_RE = new RegExp(String.raw `"((?:[^"\\]|\\.)*)" ← (${QUOTED})`, "g");
108
+ const EXISTING_RE = new RegExp(String.raw `existing content ${QUOTED}`, "g");
109
+ /**
110
+ * Mask the typed value, and any previous content a note quotes, wherever text
111
+ * says a value went into a field whose label names a secret. The browser masks
112
+ * these as it types (browser.ts); this catches text written by any other route,
113
+ * such as an older log or an agent's own note. `hits` counts what was masked.
114
+ */
115
+ export function maskSecretFieldValues(text) {
116
+ let hits = 0;
117
+ let out = "";
118
+ let last = 0;
119
+ for (const m of text.matchAll(TYPED_INTO_RE)) {
120
+ const kind = secretWordKind(m[1]);
121
+ if (!kind)
122
+ continue;
123
+ const start = m.index ?? 0;
124
+ out += text.slice(last, start) + `"${m[1]}" ← ${maskedValue(kind)}`;
125
+ last = start + m[0].length;
126
+ hits += 1;
127
+ // What the note says the field held before is the same secret's earlier value.
128
+ const next = text.indexOf(" ← ", last);
129
+ const rest = text.slice(last, next === -1 ? text.length : next);
130
+ const cleaned = rest.replace(EXISTING_RE, () => {
131
+ hits += 1;
132
+ return `existing content (masked: ${kind} field)`;
133
+ });
134
+ out += cleaned;
135
+ last += rest.length;
136
+ }
137
+ return { text: out + text.slice(last), hits };
138
+ }
139
+ /**
140
+ * `name=value` in a query string, a fragment or a form body. A GET form puts
141
+ * what was typed into the address (`?password=…`), and the address is logged
142
+ * with every action. Group 1 is what precedes it, 2 the name, 3 the value.
143
+ */
144
+ const PARAM_RE = /(^|[?&;#\s"'(])([A-Za-z][\w.%[\]-]*)=([^&#;\s"')]+)/g;
145
+ /** Mask the value of every parameter whose name names a secret (`pin=`, `otp=`, `password=`, `access_token=`), whatever its length. */
146
+ export function maskSecretParams(text) {
147
+ let hits = 0;
148
+ const out = text.replace(PARAM_RE, (m, before, name, value) => {
149
+ // A form's nested names (`user[password]`, `user%5Bpassword%5D`) name the field last; a value already masked stays.
150
+ if (value.startsWith("[") || !secretWordKind(name.replace(/%5[BD]/gi, " ")))
151
+ return m;
152
+ hits += 1;
153
+ return `${before}${name}=[redacted]`;
154
+ });
155
+ return { text: out, hits };
156
+ }
157
+ /**
158
+ * Playwright's call log names the value an action was given (`- fill("…")`),
159
+ * so the error from a failed fill of a secret field quotes the secret. It does
160
+ * not escape a quote inside the value, so the call is masked to the end of its
161
+ * line, and every value the fill was given is removed wherever else it appears.
162
+ */
163
+ export function maskFillCalls(message, values = []) {
164
+ let out = message;
165
+ // Longest first, so a value inside another is not left behind as part of it.
166
+ for (const value of [...values].sort((a, b) => b.length - a.length))
167
+ if (value !== "")
168
+ out = out.split(value).join("(masked)");
169
+ return out.replace(/\b(fill|type|pressSequentially|insertText)\(.*$/gm, "$1((masked))");
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Personal data in text
173
+ // ---------------------------------------------------------------------------
174
+ /** Domains reserved for documentation (RFC 2606 / 6761) name nobody, so an address on one stays readable. */
175
+ const RESERVED_DOMAIN_RE = /^(?:[\p{L}\p{N}-]+\.)*(?:example\.(?:com|org|net)|example|test|invalid|localhost)$/iu;
176
+ /** An image's pixel-density suffix (`@2x` before its extension), which reads like an address but is a file name. */
177
+ const DENSITY_DOMAIN_RE = /^\d+(?:\.\d+)?x\./i;
178
+ /** An address in any script, its @ written out or percent-encoded (`%40`), as a query string carries it. Group 1 is the domain. */
179
+ const EMAIL_RE = /[\p{L}\p{N}._%+-]+(?:@|%40)([\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.(?:\p{L}{2,}|xn--[a-z0-9-]+))/giu;
180
+ /** A run of digit groups split by single spaces or hyphens; a card number is some of its groups. */
181
+ const DIGIT_RUN_RE = /(?<![\d.])\d+(?:[ -]\d+)*/g;
182
+ /** A card number's first digit: the networks issue from 2 to 6 and 8, so a millisecond timestamp (a leading 1) is never one. */
183
+ const CARD_FIRST_DIGIT_RE = /^[2-68]/;
184
+ function luhnValid(digits) {
185
+ let sum = 0;
186
+ for (let i = 0; i < digits.length; i++) {
187
+ let d = Number(digits[digits.length - 1 - i]);
188
+ if (i % 2 === 1) {
189
+ d *= 2;
190
+ if (d > 9)
191
+ d -= 9;
192
+ }
193
+ sum += d;
194
+ }
195
+ return sum % 10 === 0;
196
+ }
197
+ function isCardNumber(digits) {
198
+ return digits.length >= 13 && digits.length <= 19 && CARD_FIRST_DIGIT_RE.test(digits) && luhnValid(digits);
199
+ }
200
+ /**
201
+ * Whether groups[i..j] are laid out as a card number is written: one run of
202
+ * digits, or groups of at least four (4-4-4-4, 4-6-5) with a shorter last one
203
+ * allowed. A list of small numbers (`320 375 414 768`) is not, whatever its
204
+ * digits add up to.
205
+ */
206
+ function cardShaped(groups, i, j) {
207
+ for (let k = i; k < j; k++)
208
+ if (groups[k].length < 4)
209
+ return false;
210
+ return true;
211
+ }
212
+ /**
213
+ * The card numbers among a run's digit groups, each the longest that starts at
214
+ * its group, so a date or a quantity written just before a card number does
215
+ * not hide it. A group is never split: digits inside a longer id stay.
216
+ */
217
+ function redactCardsInRun(run) {
218
+ const parts = run.split(/([ -])/);
219
+ const groups = parts.filter((_, i) => i % 2 === 0);
220
+ let out = "";
221
+ let i = 0;
222
+ while (i < groups.length) {
223
+ let end = -1;
224
+ let digits = "";
225
+ for (let j = i; j < groups.length && digits.length + groups[j].length <= 19; j++) {
226
+ digits += groups[j];
227
+ if (cardShaped(groups, i, j) && isCardNumber(digits))
228
+ end = j;
229
+ }
230
+ if (i > 0)
231
+ out += parts[2 * i - 1];
232
+ if (end === -1) {
233
+ out += groups[i];
234
+ i += 1;
235
+ }
236
+ else {
237
+ out += "[card number redacted]";
238
+ i = end + 1;
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+ /**
244
+ * Personal data a page, a flow or a typed value can put in text the tool
245
+ * keeps: an email address, its @ written out or percent-encoded (a reserved
246
+ * example address and an image's `@2x` name excepted), and a card number
247
+ * (digits that start as a card network's do and pass the Luhn check, so an
248
+ * order number or a timestamp stays). Each replacement names what it removed.
249
+ */
250
+ export function redactPersonal(text) {
251
+ return text
252
+ .replace(EMAIL_RE, (m, domain) => (RESERVED_DOMAIN_RE.test(domain) || DENSITY_DOMAIN_RE.test(domain) ? m : "[email redacted]"))
253
+ .replace(DIGIT_RUN_RE, redactCardsInRun);
254
+ }
@@ -137,7 +137,7 @@ export function looksLikeCredential(h) {
137
137
  return OPAQUE.test(h.value);
138
138
  }
139
139
  /** The first value held now that was not held when the window opened, or held a different value, and looks like a session. */
140
- export function newCredential(watch, held) {
140
+ function newCredential(watch, held) {
141
141
  for (const h of held) {
142
142
  if (watch.baseline.get(h.key) === h.value)
143
143
  continue;
@@ -153,7 +153,7 @@ export function newCredential(watch, held) {
153
153
  */
154
154
  const SIGN_IN_STEP_RE = /\/(log-in|mfa|2fa|otp|verify|challenge|select-account|choose-account|account-picker)(\/|$)/;
155
155
  /** The path is a sign-in route (/login, /signin, /auth/..., /mfa, /verify, an account picker). */
156
- export function signInRoute(url) {
156
+ function signInRoute(url) {
157
157
  try {
158
158
  const path = new URL(url).pathname.toLowerCase();
159
159
  return LOGIN_ROUTE_RE.test(path) || SIGN_IN_STEP_RE.test(path);
@@ -16,6 +16,7 @@
16
16
  * its parent window. Like live-page.ts, the client script avoids template
17
17
  * literals because the page is one; status-pane tests check the script parses.
18
18
  */
19
+ import { scriptLiteral } from "./script-literal.js";
19
20
  import { MCP_APPS_PROTOCOL, STATUS_POLL_MS, STATUS_POLL_TOOL } from "./status-pane.js";
20
21
  export function statusPanePage(version) {
21
22
  return `<!doctype html>
@@ -140,10 +141,10 @@ export function statusPanePage(version) {
140
141
  <script>
141
142
  (function () {
142
143
  "use strict";
143
- var POLL_TOOL = ${JSON.stringify(STATUS_POLL_TOOL)};
144
+ var POLL_TOOL = ${scriptLiteral(STATUS_POLL_TOOL)};
144
145
  var POLL_MS = ${STATUS_POLL_MS};
145
- var PROTOCOL = ${JSON.stringify(MCP_APPS_PROTOCOL)};
146
- var VERSION = ${JSON.stringify(version)};
146
+ var PROTOCOL = ${scriptLiteral(MCP_APPS_PROTOCOL)};
147
+ var VERSION = ${scriptLiteral(version)};
147
148
  var REQUEST_MS = 10000;
148
149
 
149
150
  var nextId = 1;
@@ -23,7 +23,7 @@ export const TASK_MAX = 120;
23
23
  * The tools that act on the app under test. Each changes what the person
24
24
  * watching is looking at, so each has to be able to say why.
25
25
  */
26
- export const NEEDS_TASK = new Set([
26
+ const NEEDS_TASK = new Set([
27
27
  "scout_navigate",
28
28
  "scout_back",
29
29
  "scout_click",
@@ -25,6 +25,7 @@ export async function boundedTeardown(context, browser, capMs) {
25
25
  await context?.close().catch(() => { });
26
26
  await browser?.close().catch(() => { });
27
27
  })()
28
+ // Each close above already ignores its own failure; this keeps anything unexpected from becoming an unhandled rejection, since teardown never throws.
28
29
  .catch(() => { })
29
30
  .finally(() => {
30
31
  finished = true;
@@ -36,6 +37,7 @@ export async function boundedTeardown(context, browser, capMs) {
36
37
  timer = setTimeout(resolve, capMs);
37
38
  }),
38
39
  ]);
40
+ // Past the cap: one more try at the browser, whose failure changes nothing for a caller already released.
39
41
  if (!finished)
40
42
  void browser?.close().catch(() => { });
41
43
  }
@@ -6,17 +6,17 @@ export const MAX_TICKET_TEXT = 200_000;
6
6
  export const MAX_TICKETS = 50;
7
7
  /** Most tickets the project keeps across runs: the newest readings. */
8
8
  export const MAX_TICKETS_KEPT = 100;
9
- export const MAX_CRITERIA_PER_TICKET = 50;
10
- export const MAX_CRITERION_TEXT = 500;
9
+ const MAX_CRITERIA_PER_TICKET = 50;
10
+ const MAX_CRITERION_TEXT = 500;
11
11
  export const MAX_REASON = 300;
12
12
  export const MAX_CRITERION_FINDINGS = 20;
13
- export const MAX_CRITERION_VERDICTS = 1000;
13
+ const MAX_CRITERION_VERDICTS = 1000;
14
14
  /** A ticket file is read only up to this size. */
15
15
  export const MAX_TICKET_FILE_BYTES = 1_000_000;
16
16
  /** The file types a ticket is read from: text, never a binary or a document format. */
17
17
  export const TICKET_FILE_EXTENSIONS = [".md", ".markdown", ".txt", ".text", ".feature"];
18
18
  /** Below this confidence, the plain report says the verdict is unsure. */
19
- export const UNSURE_BELOW = 0.6;
19
+ const UNSURE_BELOW = 0.6;
20
20
  /** Whether a file name has one of the extensions a ticket is read from. */
21
21
  export function isTicketFileName(name) {
22
22
  const lower = name.toLowerCase();
@@ -287,7 +287,7 @@ function numberCriteria(drafts) {
287
287
  });
288
288
  }
289
289
  /** The key a ticket's heading carries, if any: "PROJ-12" or "#12". */
290
- export function ticketKey(heading) {
290
+ function ticketKey(heading) {
291
291
  const key = TICKET_KEY.exec(heading)?.[1];
292
292
  if (key)
293
293
  return key;
@@ -520,7 +520,7 @@ export function answerTicket(ticket, verdicts) {
520
520
  }
521
521
  // ── Reporting ─────────────────────────────────────────────────────────────────
522
522
  /** Why a criterion was not tested, in plain words. */
523
- export const NOT_TESTED_WORDS = {
523
+ const NOT_TESTED_WORDS = {
524
524
  "no-access": "the account the run used could not reach this part of the site",
525
525
  "observe-blocked": "the run was only allowed to look, not to send changes, so this could not be tried",
526
526
  "out-of-scope": "it is outside what this run could check",