@saywright/vite 0.1.1 → 0.2.1

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/dist/index.js CHANGED
@@ -3,6 +3,53 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { parse } from "@babel/parser";
5
5
  import MagicString from "magic-string";
6
+ new RegExp("\\b(?:" + [
7
+ "pass(?:word|wd|code|phrase|key)s?",
8
+ "pwd",
9
+ "pw",
10
+ "secrets?",
11
+ "tokens?",
12
+ "api ?keys?",
13
+ "access ?keys?",
14
+ "secret ?keys?",
15
+ "private ?keys?",
16
+ "client ?secrets?",
17
+ "licen[cs]e ?keys?",
18
+ "product ?keys?",
19
+ "activation ?(?:keys?|codes?)",
20
+ "recovery ?(?:keys?|codes?)",
21
+ "backup ?codes?",
22
+ "auth(?:entication|orization)? ?codes?",
23
+ "otp",
24
+ "totp",
25
+ "one ?time ?(?:codes?|passwords?|passcodes?|pins?)",
26
+ "(?:verification|security|confirmation|sms|2fa|mfa) ?codes?",
27
+ "2fa",
28
+ "mfa",
29
+ "cvv2?",
30
+ "cvc2?",
31
+ "csc",
32
+ "card ?(?:numbers?|num|no|codes?|verification|holder|security|expiry|expiration|exp|cvc|cvv)",
33
+ "name ?on ?card",
34
+ "credit ?cards?",
35
+ "debit ?cards?",
36
+ "cc ?(?:number|num|no|exp|expiry|csc|cvc|cvv|name|given|family|type)",
37
+ "iban",
38
+ "account ?(?:numbers?|num|no)",
39
+ "routing ?(?:numbers?|num|no)",
40
+ "sort ?codes?",
41
+ "ssn",
42
+ "social ?security",
43
+ "tax ?id",
44
+ "national ?id",
45
+ "passports?",
46
+ "pins?(?: ?codes?)?",
47
+ "(?:recovery|seed|secret|backup|mnemonic) ?(?:phrases?|words?)",
48
+ "mnemonics?",
49
+ "(?:security|secret) ?(?:questions?|answers?)",
50
+ "memorable ?(?:words?|information|info)",
51
+ "mother s maiden name"
52
+ ].join("|") + ")\\b");
6
53
  /** Attribute names injected by the Vite plugin at compile time. */
7
54
  var SRC_ATTR = "data-ds-src";
8
55
  var NAME_ATTR = "data-ds-name";
@@ -3,6 +3,7 @@
3
3
  * bridge running inside the target app (iframe). Both sides check `ns` and the
4
4
  * message source before trusting anything.
5
5
  */
6
+ export * from './redact';
6
7
  export declare const NS: 'saywright';
7
8
  /**
8
9
  * The plugin in an app and the studio are updated separately, so each side
@@ -24,6 +25,11 @@ export declare function outdated(studio: {
24
25
  }): 'plugin' | 'studio' | null;
25
26
  /** Where the studio runs unless told otherwise. Not 5173: that is Vite's own default, where your app likely runs. */
26
27
  export declare const STUDIO_PORT = 7357;
28
+ /**
29
+ * Through the studio's proxy, a page's XMLHttpRequest to another port of this computer says with this header that it
30
+ * was sent with its credentials (withCredentials, set after open): the proxy then sends the site's cookies with it.
31
+ */
32
+ export declare const PROXY_CREDENTIALS_HEADER = "x-saywright-credentials";
27
33
  export declare const STUDIO_URL = "http://localhost:7357";
28
34
  /** Attribute names injected by the Vite plugin at compile time. */
29
35
  export declare const SRC_ATTR = "data-ds-src";
@@ -51,6 +57,8 @@ export interface ElementInfo {
51
57
  rect: Rect;
52
58
  /** Index of this node among elements sharing the same `src` (lists render one JSX site many times). */
53
59
  index: number;
60
+ /** How many elements on the page share its `src`, when more than one: a list's rows, a component's uses. An edit to it changes them all. */
61
+ same?: number;
54
62
  /** When `src` is null: what can find the element in the code. */
55
63
  hint?: SourceHint;
56
64
  /** Pages without source attributes: where React's development build says it came from (see SourceFrame). */
@@ -87,6 +95,12 @@ export interface LocatorInfo {
87
95
  /** The title of the card or row that tells it apart from others like it ("Celadon mug, 12 oz"). */
88
96
  within?: string;
89
97
  }
98
+ /**
99
+ * A step of Write test, as the app's bridge saw it. A secret (a password, a card number…) comes as `secret`, the
100
+ * name a test reads it under (PASSWORD), with an empty `value`: what was typed never leaves the page. A choice in a
101
+ * list comes as a `fill` with `select`, the options chosen (by what they say, and `values` by their value), so a
102
+ * studio that doesn't know `select` still writes a step rather than leaving one out.
103
+ */
90
104
  export type RecordedAction = {
91
105
  kind: 'click';
92
106
  locator: string;
@@ -96,6 +110,10 @@ export type RecordedAction = {
96
110
  locator: string;
97
111
  value: string;
98
112
  description: string;
113
+ secret?: string;
114
+ select?: string[];
115
+ values?: string[];
116
+ checked?: boolean;
99
117
  } | {
100
118
  kind: 'press';
101
119
  locator: string;
@@ -129,6 +147,8 @@ export interface SessionTarget {
129
147
  rect: [number, number, number, number];
130
148
  /** A link inside running text (exempt from minimum target sizes). */
131
149
  inline?: boolean;
150
+ /** Smaller than 24×24 px but with nothing else to click within 12 px of its centre (WCAG 2.5.8's spacing exception). */
151
+ spaced?: boolean;
132
152
  /** When `src` is null: what can find it in the code. */
133
153
  hint?: SourceHint;
134
154
  }
@@ -162,16 +182,22 @@ export type SessionEventBody = {
162
182
  el: SessionTarget;
163
183
  count: number;
164
184
  disabled?: boolean;
165
- } | {
185
+ }
186
+ /** `masked`: a secret (`value` is a dot per character); `secret`: the name a test would read it under (PASSWORD). */
187
+ | {
166
188
  type: 'input';
167
189
  el: SessionTarget;
168
190
  value: string;
169
191
  masked: boolean;
170
- } | {
192
+ secret?: string;
193
+ }
194
+ /** `options`: what each option chosen in a list says (several, in a list that takes more than one); `value` is the first. */
195
+ | {
171
196
  type: 'change';
172
197
  el: SessionTarget;
173
198
  value: string;
174
199
  checked?: boolean;
200
+ options?: string[];
175
201
  } | {
176
202
  type: 'submit';
177
203
  el: SessionTarget;
@@ -351,10 +377,20 @@ export type StudioToBridge = Base & ({
351
377
  type: 'describe';
352
378
  reqId: number;
353
379
  id: number;
354
- } | {
380
+ }
381
+ /**
382
+ * `check`: the locator is for a check that the element should show `expected`, so it isn't found by words the fix
383
+ * changes; with `state`, of whether it is checked or chosen, so it is the checkbox, radio button or list it belongs
384
+ * to (its label, an option of it, pointed at).
385
+ */
386
+ | {
355
387
  type: 'locator';
356
388
  reqId: number;
357
389
  id: number;
390
+ check?: {
391
+ expected: string;
392
+ state?: boolean;
393
+ };
358
394
  }
359
395
  /** Move from an element: out, in, to the next or previous one; or to the nearest one called `what` ("card"). `id` 0: from nothing. */
360
396
  | {
@@ -382,11 +418,13 @@ export type StudioToBridge = Base & ({
382
418
  type: 'replay';
383
419
  reqId: number;
384
420
  locator: string;
421
+ /** `select`: the options to choose, by what they say or their value (all of them, in a list that takes several). */
385
422
  step?: {
386
423
  kind: 'click' | 'fill' | 'press';
387
424
  value?: string;
388
425
  key?: string;
389
426
  checked?: boolean;
427
+ select?: string[];
390
428
  };
391
429
  read?: boolean;
392
430
  like?: {
@@ -420,6 +458,11 @@ export type StudioToBridge = Base & ({
420
458
  type: 'record';
421
459
  on: boolean;
422
460
  }
461
+ /** Write test: send the steps still waiting (text being typed) now, then reply; a check said next comes after them. */
462
+ | {
463
+ type: 'flush';
464
+ reqId: number;
465
+ }
423
466
  /** Report the real mouse position while the user interacts with the app directly (Use / Record modes). */
424
467
  | {
425
468
  type: 'mouse';
@@ -482,6 +525,11 @@ export type BridgeToStudio = Base & ({
482
525
  locator?: LocatorInfo | null;
483
526
  /** What an element shows (replay, with `read`). */
484
527
  text?: string;
528
+ /** A state check's control: whether it is checked now. */
529
+ checked?: boolean;
530
+ /** A state check on an option of a list: what that option says; and what the options chosen now say. */
531
+ option?: string;
532
+ chosen?: string[];
485
533
  /** A page review's results. */
486
534
  review?: PageReview;
487
535
  /** What Autopilot sees of the page. */
@@ -545,14 +593,17 @@ export interface PageControl {
545
593
  role: string;
546
594
  /** What a person would call it: its label or its text. */
547
595
  name: string;
548
- /** A field: its type, what it holds now (never a password's value), whether it must be filled, its choices. */
596
+ /** A field: its type, what it holds now (never a password's value), whether it must be filled, its choices, and whether what is typed into it is a secret. */
549
597
  field?: {
550
598
  type: string;
551
599
  value: string;
552
600
  required: boolean;
553
601
  checked?: boolean;
554
602
  options?: string[];
603
+ secret?: boolean;
555
604
  };
605
+ /** A field in a form: what pressing Enter in it sends the form with (its default button's name, else the form's). */
606
+ submits?: string;
556
607
  disabled: boolean;
557
608
  /** Outside what the page shows now: reached by scrolling. */
558
609
  offscreen: boolean;
@@ -607,11 +658,13 @@ export interface PageFacts {
607
658
  external: number;
608
659
  };
609
660
  images: number;
661
+ /** `inputmode`: the keyboard it asks for (numeric, email…), when it says. */
610
662
  forms: {
611
663
  label: string;
612
664
  type: string;
613
665
  autocomplete: string | null;
614
666
  required: boolean;
667
+ inputmode?: string | null;
615
668
  }[];
616
669
  buttons: string[];
617
670
  /** Measured in this browser (lab, not field data). */
@@ -664,4 +717,3 @@ export declare function parseSrc(src: string): {
664
717
  line: number;
665
718
  col: number;
666
719
  } | null;
667
- export {};
package/dist/protocol.js CHANGED
@@ -1,9 +1,332 @@
1
- //#region src/protocol.ts
1
+ //#region src/redact.ts
2
+ /**
3
+ * What is never recorded: passwords, one-time codes, keys and tokens, card and bank numbers. The bridge's two
4
+ * recorders (test sessions, and Write test), the studio (for what an older plugin still sends), the tests it writes
5
+ * and what it sends an AI all go by these rules, so a secret is caught the same way wherever it would show.
6
+ *
7
+ * A field is secret by what it is (a password field, a field someone marked private) or by what it is called (its
8
+ * name, id, autocomplete, label or placeholder: “Password”, “cc-number”, “API key”, “رمز التحقق”). A value is secret
9
+ * by its look alone (a card number, a key or a token), whatever its field is called. A test types a secret from the
10
+ * environment (SAYWRIGHT_SECRET_PASSWORD), never from its own file.
11
+ */
12
+ /** Shown in place of a secret. */
13
+ var MASK = "•••";
14
+ /** A generated test reads the secret named PASSWORD from the environment variable SAYWRIGHT_SECRET_PASSWORD. */
15
+ var SECRET_ENV_PREFIX = "SAYWRIGHT_SECRET_";
16
+ /** A value as it may be shown: a dot for each character, at most twelve, so its length is a hint and nothing more. */
17
+ function dots(value) {
18
+ return "•".repeat(Math.min(value.length, 12));
19
+ }
20
+ /** A name, id or label as plain words: “cardNumber”, “card_number”, “cc-number” → “card number”, “cc number”. */
21
+ function words(text) {
22
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
23
+ }
24
+ /** Words that make a field secret, whatever else it is called. */
25
+ var SECRET_WORDS = new RegExp("\\b(?:" + [
26
+ "pass(?:word|wd|code|phrase|key)s?",
27
+ "pwd",
28
+ "pw",
29
+ "secrets?",
30
+ "tokens?",
31
+ "api ?keys?",
32
+ "access ?keys?",
33
+ "secret ?keys?",
34
+ "private ?keys?",
35
+ "client ?secrets?",
36
+ "licen[cs]e ?keys?",
37
+ "product ?keys?",
38
+ "activation ?(?:keys?|codes?)",
39
+ "recovery ?(?:keys?|codes?)",
40
+ "backup ?codes?",
41
+ "auth(?:entication|orization)? ?codes?",
42
+ "otp",
43
+ "totp",
44
+ "one ?time ?(?:codes?|passwords?|passcodes?|pins?)",
45
+ "(?:verification|security|confirmation|sms|2fa|mfa) ?codes?",
46
+ "2fa",
47
+ "mfa",
48
+ "cvv2?",
49
+ "cvc2?",
50
+ "csc",
51
+ "card ?(?:numbers?|num|no|codes?|verification|holder|security|expiry|expiration|exp|cvc|cvv)",
52
+ "name ?on ?card",
53
+ "credit ?cards?",
54
+ "debit ?cards?",
55
+ "cc ?(?:number|num|no|exp|expiry|csc|cvc|cvv|name|given|family|type)",
56
+ "iban",
57
+ "account ?(?:numbers?|num|no)",
58
+ "routing ?(?:numbers?|num|no)",
59
+ "sort ?codes?",
60
+ "ssn",
61
+ "social ?security",
62
+ "tax ?id",
63
+ "national ?id",
64
+ "passports?",
65
+ "pins?(?: ?codes?)?",
66
+ "(?:recovery|seed|secret|backup|mnemonic) ?(?:phrases?|words?)",
67
+ "mnemonics?",
68
+ "(?:security|secret) ?(?:questions?|answers?)",
69
+ "memorable ?(?:words?|information|info)",
70
+ "mother s maiden name"
71
+ ].join("|") + ")\\b");
72
+ /** The same in Arabic (password, secret code, verification code, card number, account number). */
73
+ var SECRET_WORDS_AR = /كلمة (?:ال)?(?:مرور|سر)|(?:ال)?رمز (?:ال)?(?:سري|تحقق|التحقق|أمان|الأمان|المرور)|رقم (?:ال)?(?:بطاقة|حساب|سري)|آيبان/;
74
+ /** Whether a field described so (its type, name, id, autocomplete, label and placeholder, in one text) holds a secret. */
75
+ function isSecretHint(hint) {
76
+ return SECRET_WORDS.test(words(hint)) || SECRET_WORDS_AR.test(hint);
77
+ }
78
+ /** Words a field's label adds that don't tell one secret from another. */
79
+ var FILLER = /* @__PURE__ */ new Set([
80
+ "enter",
81
+ "your",
82
+ "the",
83
+ "a",
84
+ "an",
85
+ "please",
86
+ "type",
87
+ "confirm",
88
+ "confirmation",
89
+ "repeat",
90
+ "again",
91
+ "re",
92
+ "retype",
93
+ "field",
94
+ "here",
95
+ "of",
96
+ "required",
97
+ "optional"
98
+ ]);
99
+ /** A word before a secret that does tell it from another: a new password isn't the current one. */
100
+ var QUALIFIER = /* @__PURE__ */ new Set([
101
+ "new",
102
+ "old",
103
+ "current",
104
+ "previous",
105
+ "admin",
106
+ "master",
107
+ "root"
108
+ ]);
109
+ /**
110
+ * What a secret is called in a test, from its field's label (or name): “Enter your password” → PASSWORD, “Card
111
+ * number” → CARD_NUMBER, “New password” → NEW_PASSWORD, “Password (8 characters or more)” → PASSWORD; “Confirm
112
+ * password” is PASSWORD too, so one value fills both.
113
+ */
114
+ function secretName(label) {
115
+ const w = words(label);
116
+ const m = SECRET_WORDS.exec(w);
117
+ let name;
118
+ if (m) {
119
+ const before = w.slice(0, m.index).trim().split(" ").pop() ?? "";
120
+ name = [...QUALIFIER.has(before) ? [before] : [], ...m[0].split(" ")];
121
+ } else name = w.split(" ").filter((x) => x && !FILLER.has(x) && /^[a-z0-9]+$/.test(x)).slice(0, 3);
122
+ if (name.length) return name.join("_").toUpperCase().slice(0, 40);
123
+ if (/كلمة (?:ال)?(?:مرور|سر)/.test(label)) return "PASSWORD";
124
+ if (/تحقق|التحقق/.test(label)) return "VERIFICATION_CODE";
125
+ if (/بطاقة/.test(label)) return "CARD_NUMBER";
126
+ return "SECRET";
127
+ }
128
+ /** A secret's name as an environment variable can take it: capitals, digits and underscores. */
129
+ function secretEnvName(name) {
130
+ return SECRET_ENV_PREFIX + (name.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "SECRET");
131
+ }
132
+ /** What may be a card number: digits with spaces or dashes between them (isCard decides). */
133
+ var CARD = /\b\d(?:[ -]?\d){12,22}\b/g;
134
+ /** Keys and tokens by their shape. */
135
+ var TOKENS = [
136
+ /\beyJ[\w-]{6,}\.eyJ[\w-]{6,}\.[\w-]{6,}/g,
137
+ /\b(?:sk|rk)-[\w-]{16,}/g,
138
+ /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g,
139
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
140
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
141
+ /\bglpat-[\w-]{16,}/g,
142
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
143
+ /\bnpm_[A-Za-z0-9]{30,}/g,
144
+ /\bAKIA[0-9A-Z]{16}\b/g,
145
+ /\bAIza[0-9A-Za-z_-]{35}/g,
146
+ /\bwhsec_[A-Za-z0-9]{16,}/g,
147
+ /\bSG\.[\w-]{16,}\.[\w-]{16,}/g,
148
+ /(?<=\b(?:aws_?secret_?access_?key|secret_?access_?key|client_?secret|api_?secret|private_?key|access_?token|refresh_?token|auth_?token|password|passwd)["']?\s*[:=]\s*["']?)[^\s"',;}{]{8,}/gi,
149
+ /\b[a-z]{2,8}_(?:(?:live|test)_)?(?=[A-Za-z0-9]{20,}\b)(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[a-z])(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]{20,}\b/g,
150
+ /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/g,
151
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g
152
+ ];
153
+ function luhn(digits) {
154
+ let sum = 0;
155
+ for (let i = 0; i < digits.length; i++) {
156
+ let d = Number(digits[digits.length - 1 - i]);
157
+ if (i % 2) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
158
+ sum += d;
159
+ }
160
+ return sum % 10 === 0;
161
+ }
162
+ /**
163
+ * Whether digits are written as a card number is: 13 to 19 of them, starting with 2 to 6 (as cards of every network
164
+ * do), passing the Luhn check, and either unbroken or in groups of 4 to 6 (the last one shorter) with one kind of
165
+ * separator. Not a date with a number either side of it, an order row or a timestamp.
166
+ */
167
+ function isCard(text) {
168
+ const d = text.replace(/\D/g, "");
169
+ if (d.length < 13 || d.length > 19 || !/^[2-6]/.test(d) || !luhn(d)) return false;
170
+ if (/^\d+$/.test(text)) return true;
171
+ if (text.includes(" ") && text.includes("-")) return false;
172
+ const groups = text.split(/[ -]/);
173
+ return groups.every((g) => /^\d+$/.test(g)) && groups.slice(0, -1).every((g) => g.length >= 4 && g.length <= 6) && groups[groups.length - 1].length <= 6;
174
+ }
175
+ /** A card number in `run` (digits with spaces or dashes), with what follows it kept: the longest first part that is one. */
176
+ function maskCard(run) {
177
+ const parts = run.split(/(?<=\d)(?=[ -])/);
178
+ for (let k = parts.length; k > 0; k--) {
179
+ const head = parts.slice(0, k).join("");
180
+ if (isCard(head)) return `•••• ${head.replace(/\D/g, "").slice(-4)}${parts.slice(k).join("")}`;
181
+ }
182
+ return run;
183
+ }
184
+ /** Text with every card number (kept: its last four digits) and every key- or token-shaped string in it masked. */
185
+ function maskSecrets(text) {
186
+ if (!text) return text;
187
+ let out = text.replace(CARD, maskCard);
188
+ out = out.replace(/\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)([^\s@/]+)@/gi, (_m, head) => `${head}${MASK}@`);
189
+ for (const re of TOKENS) out = out.replace(re, (m) => m.startsWith("Bearer") ? `Bearer ${MASK}` : MASK);
190
+ return out;
191
+ }
192
+ /**
193
+ * Whether a typed value is a secret by its look alone: a card number, a key or a token, or a long random string (of
194
+ * letters in both cases and digits, as keys are; or 32 hexadecimal digits or more). A slug, a sentence, an email
195
+ * address or a coupon code is not.
196
+ */
197
+ function looksSecret(value) {
198
+ const v = value.trim();
199
+ if (v.length < 12) return false;
200
+ if (maskSecrets(v) !== v) return true;
201
+ if (/^[a-f0-9]{32,}$/i.test(v) && /\d/.test(v) && /[a-f]/i.test(v)) return true;
202
+ return /^[A-Za-z0-9+/_=-]{24,}$/.test(v) && /\d/.test(v) && /[a-z]/.test(v) && /[A-Z]/.test(v);
203
+ }
204
+ /** Query and hash parameters whose value is a secret by their name alone. */
205
+ var SECRET_PARAM = /^(?:access_?token|refresh_?token|id_?token|auth_?token|token|auth|authorization|api_?key|apikey|key|secret|client_?secret|password|passwd|pwd|pass|otp|code_?verifier|jwt|session|session_?id|sessionid|sid|signature|sig|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
206
+ /** Addresses of sign-in steps, where `code`, `state` and `nonce` carry what lets someone in. */
207
+ var AUTH_PATH = /(?:^|\/)(?:callback|oauth2?|auth|authorize|sso|login|signin|sign-in|verify|magic|reset|confirm)(?:[/?#.]|$)/i;
208
+ /** Whether a page's path is a sign-in step's (/login, /verify, /auth/callback…): a code asked for there lets someone in. */
209
+ function isAuthPath(pathname) {
210
+ return AUTH_PATH.test(pathname);
211
+ }
212
+ /** Codes that are not secrets: a test needs them as typed (SAVE20, a postal code, a colour). */
213
+ var PLAIN_CODE = /\b(?:coupon|promo|promotion|discount|voucher|gift|referral|invite|invitation|postal|post|zip|area|country|dialling|dialing|product|sku|item|colou?r|tracking|order|booking|reference|ref|course|class|group|room|seat|ticket|event|campaign|source|language|currency|qr|bar|barcode|html|css|snippet|embed|promotional)\b/;
214
+ /**
215
+ * Whether a field described so asks for a code that lets someone in, by its look: called a code or a PIN (“Code”,
216
+ * “Enter the 6-digit code we emailed you”), not a coupon or a postal code, and short and numeric (its input mode,
217
+ * type, pattern or length), or asked for on a sign-in step's page.
218
+ */
219
+ function isCodeField(hint, field, pathname = "") {
220
+ const w = words(hint);
221
+ if (!/\b(?:code|codes|pin|passcode)\b/.test(w) || PLAIN_CODE.test(w)) return false;
222
+ return field.numeric || field.shortMax || isAuthPath(pathname);
223
+ }
224
+ function decoded(s) {
225
+ try {
226
+ return decodeURIComponent(s.replace(/\+/g, " "));
227
+ } catch {
228
+ return s;
229
+ }
230
+ }
231
+ /** `a=1&b=2` with the values of secret parameters masked, and key- or token-shaped values too. */
232
+ function maskParams(params, authPath) {
233
+ return params.split("&").map((pair) => {
234
+ const eq = pair.indexOf("=");
235
+ if (eq < 0) return maskSecrets(pair);
236
+ const name = decoded(pair.slice(0, eq));
237
+ const value = pair.slice(eq + 1);
238
+ if (!value) return pair;
239
+ const secret = SECRET_PARAM.test(name) || isSecretHint(name) || authPath && /^(?:code|state|nonce)$/i.test(name) || looksSecret(decoded(value));
240
+ return `${pair.slice(0, eq)}=${secret ? MASK : maskSecrets(value)}`;
241
+ }).join("&");
242
+ }
243
+ /**
244
+ * An address with the secrets in it masked: the values of parameters named like a token, key, password or session
245
+ * (in its query, and in its hash: `#access_token=…`), sign-in codes, and key- or token-shaped parts of its path.
246
+ * Works on whole and relative addresses, and never throws.
247
+ */
248
+ function redactUrl(url) {
249
+ if (!url) return url;
250
+ const hashAt = url.indexOf("#");
251
+ const base = hashAt < 0 ? url : url.slice(0, hashAt);
252
+ const hash = hashAt < 0 ? null : url.slice(hashAt + 1);
253
+ const q = base.indexOf("?");
254
+ const path = q < 0 ? base : base.slice(0, q);
255
+ const authPath = AUTH_PATH.test(path);
256
+ let out = maskSecrets(path).replace(/(?<=\/)[A-Za-z0-9_-]{24,}(?=\/|$)/g, (part) => /\d/.test(part) && /[a-z]/.test(part) && /[A-Z]/.test(part) ? MASK : part);
257
+ if (q >= 0) out += `?${maskParams(base.slice(q + 1), authPath)}`;
258
+ if (hash !== null) {
259
+ const hq = hash.indexOf("?");
260
+ if (hq >= 0) out += `#${maskSecrets(hash.slice(0, hq))}?${maskParams(hash.slice(hq + 1), authPath || AUTH_PATH.test(hash.slice(0, hq)))}`;
261
+ else out += `#${hash.includes("=") ? maskParams(hash, authPath) : maskSecrets(hash)}`;
262
+ }
263
+ return out;
264
+ }
265
+ /** A recorded element's words, masked: text it showed may hold a card number or a key. */
266
+ function scrubTarget(el) {
267
+ if (!el || typeof el !== "object") return;
268
+ const t = el;
269
+ if (typeof t.label === "string") t.label = maskSecrets(t.label);
270
+ if (typeof t.text === "string") t.text = maskSecrets(t.text);
271
+ if (typeof t.description === "string") t.description = maskSecrets(t.description);
272
+ }
2
273
  /**
3
- * Message protocol between the Saywright studio (parent window) and the
4
- * bridge running inside the target app (iframe). Both sides check `ns` and the
5
- * message source before trusting anything.
274
+ * A session event as it may be kept: addresses with their secrets masked (see redactUrl), card numbers and keys
275
+ * masked in every text it carries (errors, console lines, response bodies, what the screen said), and a typed value
276
+ * that is a secret (by its field's name, or its look) as dots. The bridge records events so; the studio scrubs
277
+ * what arrives again, for events an older plugin recorded. Changes the event in place, and returns it.
6
278
  */
279
+ function scrubEvent(event) {
280
+ const e = event;
281
+ const url = (k) => {
282
+ if (typeof e[k] === "string") e[k] = redactUrl(e[k]);
283
+ };
284
+ const text = (k) => {
285
+ if (typeof e[k] === "string") e[k] = maskSecrets(e[k]);
286
+ };
287
+ scrubTarget(e.el);
288
+ switch (e.type) {
289
+ case "page":
290
+ url("url");
291
+ url("referrer");
292
+ break;
293
+ case "nav":
294
+ url("url");
295
+ break;
296
+ case "net":
297
+ url("url");
298
+ text("body");
299
+ text("error");
300
+ break;
301
+ case "error":
302
+ case "console":
303
+ text("message");
304
+ text("text");
305
+ text("stack");
306
+ break;
307
+ case "dom":
308
+ url("url");
309
+ text("text");
310
+ if (Array.isArray(e.headings)) e.headings = e.headings.map((h) => typeof h === "string" ? maskSecrets(h) : h);
311
+ break;
312
+ case "input": {
313
+ const target = e.el ?? {};
314
+ const value = typeof e.value === "string" ? e.value : "";
315
+ if (!e.masked && (isSecretHint(`${target.label ?? ""} ${target.name ?? ""}`) || looksSecret(value))) {
316
+ e.value = dots(value);
317
+ e.masked = true;
318
+ e.secret ??= secretName(target.label || target.name || "");
319
+ } else if (!e.masked) text("value");
320
+ break;
321
+ }
322
+ case "change":
323
+ text("value");
324
+ if (Array.isArray(e.options)) e.options = e.options.map((o) => typeof o === "string" ? maskSecrets(o) : o);
325
+ }
326
+ return event;
327
+ }
328
+ //#endregion
329
+ //#region src/protocol.ts
7
330
  var NS = "saywright";
8
331
  /**
9
332
  * The plugin in an app and the studio are updated separately, so each side
@@ -23,6 +346,11 @@ function outdated(studio, bridge) {
23
346
  }
24
347
  /** Where the studio runs unless told otherwise. Not 5173: that is Vite's own default, where your app likely runs. */
25
348
  var STUDIO_PORT = 7357;
349
+ /**
350
+ * Through the studio's proxy, a page's XMLHttpRequest to another port of this computer says with this header that it
351
+ * was sent with its credentials (withCredentials, set after open): the proxy then sends the site's cookies with it.
352
+ */
353
+ var PROXY_CREDENTIALS_HEADER = "x-saywright-credentials";
26
354
  var STUDIO_URL = `http://localhost:${STUDIO_PORT}`;
27
355
  /** Attribute names injected by the Vite plugin at compile time. */
28
356
  var SRC_ATTR = "data-ds-src";
@@ -61,4 +389,4 @@ function parseSrc(src) {
61
389
  };
62
390
  }
63
391
  //#endregion
64
- export { NAME_ATTR, NS, OLDEST_PROTOCOL, PROTOCOL_VERSION, SRC_ATTR, STUDIO_PORT, STUDIO_URL, isStudioMessage, jsComment, jsString, outdated, parseSrc };
392
+ export { MASK, NAME_ATTR, NS, OLDEST_PROTOCOL, PROTOCOL_VERSION, PROXY_CREDENTIALS_HEADER, SECRET_ENV_PREFIX, SRC_ATTR, STUDIO_PORT, STUDIO_URL, dots, isAuthPath, isCodeField, isSecretHint, isStudioMessage, jsComment, jsString, looksSecret, maskSecrets, outdated, parseSrc, redactUrl, scrubEvent, secretEnvName, secretName, words };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What is never recorded: passwords, one-time codes, keys and tokens, card and bank numbers. The bridge's two
3
+ * recorders (test sessions, and Write test), the studio (for what an older plugin still sends), the tests it writes
4
+ * and what it sends an AI all go by these rules, so a secret is caught the same way wherever it would show.
5
+ *
6
+ * A field is secret by what it is (a password field, a field someone marked private) or by what it is called (its
7
+ * name, id, autocomplete, label or placeholder: “Password”, “cc-number”, “API key”, “رمز التحقق”). A value is secret
8
+ * by its look alone (a card number, a key or a token), whatever its field is called. A test types a secret from the
9
+ * environment (SAYWRIGHT_SECRET_PASSWORD), never from its own file.
10
+ */
11
+ /** Shown in place of a secret. */
12
+ export declare const MASK = "\u2022\u2022\u2022";
13
+ /** A generated test reads the secret named PASSWORD from the environment variable SAYWRIGHT_SECRET_PASSWORD. */
14
+ export declare const SECRET_ENV_PREFIX = "SAYWRIGHT_SECRET_";
15
+ /** A value as it may be shown: a dot for each character, at most twelve, so its length is a hint and nothing more. */
16
+ export declare function dots(value: string): string;
17
+ /** A name, id or label as plain words: “cardNumber”, “card_number”, “cc-number” → “card number”, “cc number”. */
18
+ export declare function words(text: string): string;
19
+ /** Whether a field described so (its type, name, id, autocomplete, label and placeholder, in one text) holds a secret. */
20
+ export declare function isSecretHint(hint: string): boolean;
21
+ /**
22
+ * What a secret is called in a test, from its field's label (or name): “Enter your password” → PASSWORD, “Card
23
+ * number” → CARD_NUMBER, “New password” → NEW_PASSWORD, “Password (8 characters or more)” → PASSWORD; “Confirm
24
+ * password” is PASSWORD too, so one value fills both.
25
+ */
26
+ export declare function secretName(label: string): string;
27
+ /** A secret's name as an environment variable can take it: capitals, digits and underscores. */
28
+ export declare function secretEnvName(name: string): string;
29
+ /** Text with every card number (kept: its last four digits) and every key- or token-shaped string in it masked. */
30
+ export declare function maskSecrets(text: string): string;
31
+ /**
32
+ * Whether a typed value is a secret by its look alone: a card number, a key or a token, or a long random string (of
33
+ * letters in both cases and digits, as keys are; or 32 hexadecimal digits or more). A slug, a sentence, an email
34
+ * address or a coupon code is not.
35
+ */
36
+ export declare function looksSecret(value: string): boolean;
37
+ /** Whether a page's path is a sign-in step's (/login, /verify, /auth/callback…): a code asked for there lets someone in. */
38
+ export declare function isAuthPath(pathname: string): boolean;
39
+ /**
40
+ * Whether a field described so asks for a code that lets someone in, by its look: called a code or a PIN (“Code”,
41
+ * “Enter the 6-digit code we emailed you”), not a coupon or a postal code, and short and numeric (its input mode,
42
+ * type, pattern or length), or asked for on a sign-in step's page.
43
+ */
44
+ export declare function isCodeField(hint: string, field: {
45
+ numeric: boolean;
46
+ shortMax: boolean;
47
+ }, pathname?: string): boolean;
48
+ /**
49
+ * An address with the secrets in it masked: the values of parameters named like a token, key, password or session
50
+ * (in its query, and in its hash: `#access_token=…`), sign-in codes, and key- or token-shaped parts of its path.
51
+ * Works on whole and relative addresses, and never throws.
52
+ */
53
+ export declare function redactUrl(url: string): string;
54
+ /**
55
+ * A session event as it may be kept: addresses with their secrets masked (see redactUrl), card numbers and keys
56
+ * masked in every text it carries (errors, console lines, response bodies, what the screen said), and a typed value
57
+ * that is a secret (by its field's name, or its look) as dots. The bridge records events so; the studio scrubs
58
+ * what arrives again, for events an older plugin recorded. Changes the event in place, and returns it.
59
+ */
60
+ export declare function scrubEvent<T extends {
61
+ type: string;
62
+ }>(event: T): T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saywright/vite",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Vite plugin for Saywright: traces every element of your running React app to its file and line, so you can point at it and say what to change. Dev server only.",
5
5
  "keywords": [
6
6
  "vite",