@saywright/vite 0.1.0 → 0.2.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/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;
@@ -162,16 +180,22 @@ export type SessionEventBody = {
162
180
  el: SessionTarget;
163
181
  count: number;
164
182
  disabled?: boolean;
165
- } | {
183
+ }
184
+ /** `masked`: a secret (`value` is a dot per character); `secret`: the name a test would read it under (PASSWORD). */
185
+ | {
166
186
  type: 'input';
167
187
  el: SessionTarget;
168
188
  value: string;
169
189
  masked: boolean;
170
- } | {
190
+ secret?: string;
191
+ }
192
+ /** `options`: what each option chosen in a list says (several, in a list that takes more than one); `value` is the first. */
193
+ | {
171
194
  type: 'change';
172
195
  el: SessionTarget;
173
196
  value: string;
174
197
  checked?: boolean;
198
+ options?: string[];
175
199
  } | {
176
200
  type: 'submit';
177
201
  el: SessionTarget;
@@ -351,10 +375,20 @@ export type StudioToBridge = Base & ({
351
375
  type: 'describe';
352
376
  reqId: number;
353
377
  id: number;
354
- } | {
378
+ }
379
+ /**
380
+ * `check`: the locator is for a check that the element should show `expected`, so it isn't found by words the fix
381
+ * changes; with `state`, of whether it is checked or chosen, so it is the checkbox, radio button or list it belongs
382
+ * to (its label, an option of it, pointed at).
383
+ */
384
+ | {
355
385
  type: 'locator';
356
386
  reqId: number;
357
387
  id: number;
388
+ check?: {
389
+ expected: string;
390
+ state?: boolean;
391
+ };
358
392
  }
359
393
  /** 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
394
  | {
@@ -382,11 +416,13 @@ export type StudioToBridge = Base & ({
382
416
  type: 'replay';
383
417
  reqId: number;
384
418
  locator: string;
419
+ /** `select`: the options to choose, by what they say or their value (all of them, in a list that takes several). */
385
420
  step?: {
386
421
  kind: 'click' | 'fill' | 'press';
387
422
  value?: string;
388
423
  key?: string;
389
424
  checked?: boolean;
425
+ select?: string[];
390
426
  };
391
427
  read?: boolean;
392
428
  like?: {
@@ -420,6 +456,11 @@ export type StudioToBridge = Base & ({
420
456
  type: 'record';
421
457
  on: boolean;
422
458
  }
459
+ /** Write test: send the steps still waiting (text being typed) now, then reply; a check said next comes after them. */
460
+ | {
461
+ type: 'flush';
462
+ reqId: number;
463
+ }
423
464
  /** Report the real mouse position while the user interacts with the app directly (Use / Record modes). */
424
465
  | {
425
466
  type: 'mouse';
@@ -482,6 +523,11 @@ export type BridgeToStudio = Base & ({
482
523
  locator?: LocatorInfo | null;
483
524
  /** What an element shows (replay, with `read`). */
484
525
  text?: string;
526
+ /** A state check's control: whether it is checked now. */
527
+ checked?: boolean;
528
+ /** A state check on an option of a list: what that option says; and what the options chosen now say. */
529
+ option?: string;
530
+ chosen?: string[];
485
531
  /** A page review's results. */
486
532
  review?: PageReview;
487
533
  /** What Autopilot sees of the page. */
@@ -545,14 +591,17 @@ export interface PageControl {
545
591
  role: string;
546
592
  /** What a person would call it: its label or its text. */
547
593
  name: string;
548
- /** A field: its type, what it holds now (never a password's value), whether it must be filled, its choices. */
594
+ /** 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
595
  field?: {
550
596
  type: string;
551
597
  value: string;
552
598
  required: boolean;
553
599
  checked?: boolean;
554
600
  options?: string[];
601
+ secret?: boolean;
555
602
  };
603
+ /** A field in a form: what pressing Enter in it sends the form with (its default button's name, else the form's). */
604
+ submits?: string;
556
605
  disabled: boolean;
557
606
  /** Outside what the page shows now: reached by scrolling. */
558
607
  offscreen: boolean;
@@ -664,4 +713,3 @@ export declare function parseSrc(src: string): {
664
713
  line: number;
665
714
  col: number;
666
715
  } | 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.0",
3
+ "version": "0.2.0",
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",