@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/bridge.js +1188 -210
- package/dist/index.js +47 -0
- package/dist/protocol.d.ts +57 -5
- package/dist/protocol.js +333 -5
- package/dist/redact.d.ts +62 -0
- package/package.json +1 -1
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";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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/
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 };
|
package/dist/redact.d.ts
ADDED
|
@@ -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.
|
|
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",
|