@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/README.md +4 -4
- package/dist/bridge.js +1110 -180
- package/dist/index.js +47 -0
- package/dist/protocol.d.ts +53 -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;
|
|
@@ -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/
|
|
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.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",
|