clarity-js 0.6.35 → 0.6.38
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/build/clarity.js +504 -488
- package/build/clarity.min.js +1 -1
- package/build/clarity.module.js +504 -488
- package/package.json +1 -1
- package/src/core/scrub.ts +23 -10
- package/src/core/timeout.ts +1 -1
- package/src/core/version.ts +1 -1
- package/src/interaction/pointer.ts +1 -1
- package/src/layout/dom.ts +10 -8
- package/src/layout/mutation.ts +1 -1
- package/src/layout/node.ts +1 -1
- package/test/core.test.ts +52 -19
- package/test/helper.ts +35 -2
- package/test/html/core.html +4 -1
- package/types/data.d.ts +3 -1
- package/types/layout.d.ts +2 -1
package/package.json
CHANGED
package/src/core/scrub.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { Privacy } from "@clarity-types/core";
|
|
|
2
2
|
import * as Data from "@clarity-types/data";
|
|
3
3
|
import * as Layout from "@clarity-types/layout";
|
|
4
4
|
|
|
5
|
+
// Regular expressions using unicode property escapes
|
|
6
|
+
// Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes
|
|
7
|
+
const digitRegex = /\p{N}/gu;
|
|
8
|
+
const letterRegex = /\p{L}/gu;
|
|
9
|
+
const currencyRegex = /\p{Sc}/u;
|
|
10
|
+
const catchallRegex = /\S/gi;
|
|
11
|
+
|
|
5
12
|
export default function(value: string, hint: string, privacy: Privacy, mangle: boolean = false): string {
|
|
6
13
|
if (value) {
|
|
7
14
|
switch (privacy) {
|
|
@@ -12,9 +19,9 @@ export default function(value: string, hint: string, privacy: Privacy, mangle: b
|
|
|
12
19
|
case Layout.Constant.TextTag:
|
|
13
20
|
case "value":
|
|
14
21
|
case "placeholder":
|
|
15
|
-
|
|
22
|
+
case "click":
|
|
16
23
|
case "input":
|
|
17
|
-
return
|
|
24
|
+
return redact(value);
|
|
18
25
|
}
|
|
19
26
|
return value;
|
|
20
27
|
case Privacy.Text:
|
|
@@ -53,7 +60,7 @@ function mangleText(value: string): string {
|
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
function mask(value: string): string {
|
|
56
|
-
return value.replace(
|
|
63
|
+
return value.replace(catchallRegex, Data.Constant.Mask);
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
function mangleToken(value: string): string {
|
|
@@ -67,6 +74,7 @@ function mangleToken(value: string): string {
|
|
|
67
74
|
|
|
68
75
|
function redact(value: string): string {
|
|
69
76
|
let spaceIndex = -1;
|
|
77
|
+
let gap = 0;
|
|
70
78
|
let hasDigit = false;
|
|
71
79
|
let hasEmail = false;
|
|
72
80
|
let hasWhitespace = false;
|
|
@@ -82,7 +90,18 @@ function redact(value: string): string {
|
|
|
82
90
|
// Performance optimization: Lazy load string -> array conversion only when required
|
|
83
91
|
if (hasDigit || hasEmail) {
|
|
84
92
|
if (array === null) { array = value.split(Data.Constant.Empty); }
|
|
85
|
-
|
|
93
|
+
// Work on a token at a time so we don't have to apply regex to a larger string
|
|
94
|
+
let token = value.substring(spaceIndex + 1, hasWhitespace ? i : i + 1);
|
|
95
|
+
// Check if unicode regex is supported, otherwise fallback to calling mask function on this token
|
|
96
|
+
if (currencyRegex.unicode) {
|
|
97
|
+
// Do not redact information if the token contains a currency symbol
|
|
98
|
+
token = token.match(currencyRegex) ? token : token.replace(letterRegex, Data.Constant.Letter).replace(digitRegex, Data.Constant.Digit);
|
|
99
|
+
} else {
|
|
100
|
+
token = mask(token);
|
|
101
|
+
}
|
|
102
|
+
// Merge token back into array at the right place
|
|
103
|
+
array.splice(spaceIndex + 1 - gap, token.length, token);
|
|
104
|
+
gap += token.length - 1;
|
|
86
105
|
}
|
|
87
106
|
// Reset digit and email flags after every word boundary, except the beginning of string
|
|
88
107
|
if (hasWhitespace) {
|
|
@@ -94,9 +113,3 @@ function redact(value: string): string {
|
|
|
94
113
|
}
|
|
95
114
|
return array ? array.join(Data.Constant.Empty) : value;
|
|
96
115
|
}
|
|
97
|
-
|
|
98
|
-
function mutate(array: string[], start: number, end: number): void {
|
|
99
|
-
for (let i = start + 1; i < end; i++) {
|
|
100
|
-
array[i] = Data.Constant.Mask;
|
|
101
|
-
}
|
|
102
|
-
}
|
package/src/core/timeout.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Event } from "@clarity-types/data";
|
|
2
2
|
import measure from "./measure";
|
|
3
3
|
|
|
4
|
-
export function setTimeout(handler: (event?: Event | boolean) => void, timeout
|
|
4
|
+
export function setTimeout(handler: (event?: Event | boolean) => void, timeout?: number, event?: Event): number {
|
|
5
5
|
return window.setTimeout(measure(handler), timeout, event);
|
|
6
6
|
}
|
|
7
7
|
|
package/src/core/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
let version = "0.6.
|
|
1
|
+
let version = "0.6.38";
|
|
2
2
|
export default version;
|
|
@@ -20,7 +20,7 @@ export function observe(root: Node): void {
|
|
|
20
20
|
bind(root, "mousedown", mouse.bind(this, Event.MouseDown, root), true);
|
|
21
21
|
bind(root, "mouseup", mouse.bind(this, Event.MouseUp, root), true);
|
|
22
22
|
bind(root, "mousemove", mouse.bind(this, Event.MouseMove, root), true);
|
|
23
|
-
bind(root, "
|
|
23
|
+
bind(root, "wheel", mouse.bind(this, Event.MouseWheel, root), true);
|
|
24
24
|
bind(root, "dblclick", mouse.bind(this, Event.DoubleClick, root), true);
|
|
25
25
|
bind(root, "touchstart", touch.bind(this, Event.TouchStart, root), true);
|
|
26
26
|
bind(root, "touchend", touch.bind(this, Event.TouchEnd, root), true);
|
package/src/layout/dom.ts
CHANGED
|
@@ -17,6 +17,7 @@ let override = [];
|
|
|
17
17
|
let unmask = [];
|
|
18
18
|
let updatedFragments: { [fragment: number]: string } = {};
|
|
19
19
|
let maskText = [];
|
|
20
|
+
let maskInput = [];
|
|
20
21
|
let maskDisable = [];
|
|
21
22
|
|
|
22
23
|
// The WeakMap object is a collection of key/value pairs in which the keys are weakly referenced
|
|
@@ -43,6 +44,7 @@ function reset(): void {
|
|
|
43
44
|
override = [];
|
|
44
45
|
unmask = [];
|
|
45
46
|
maskText = Mask.Text.split(Constant.Comma);
|
|
47
|
+
maskInput = Mask.Input.split(Constant.Comma);
|
|
46
48
|
maskDisable = Mask.Disable.split(Constant.Comma);
|
|
47
49
|
idMap = new WeakMap();
|
|
48
50
|
iframeMap = new WeakMap();
|
|
@@ -97,6 +99,7 @@ export function add(node: Node, parent: Node, data: NodeInfo, source: Source): v
|
|
|
97
99
|
regionId = regionId === null ? parentValue.region : regionId;
|
|
98
100
|
fragmentId = parentValue.fragment;
|
|
99
101
|
fraudId = fraudId === null ? parentValue.metadata.fraud : fraudId;
|
|
102
|
+
privacyId = parentValue.metadata.privacy;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
// If there's an explicit region attribute set on the element, use it to mark a region on the page
|
|
@@ -240,30 +243,29 @@ function privacy(node: Node, value: NodeValue, parent: NodeValue): void {
|
|
|
240
243
|
break;
|
|
241
244
|
case Constant.Type in attributes:
|
|
242
245
|
// If this node has an explicit type assigned to it, go through masking rules to determine right privacy setting
|
|
243
|
-
metadata.privacy = inspect(attributes[Constant.Type], metadata);
|
|
246
|
+
metadata.privacy = inspect(attributes[Constant.Type], maskInput, metadata);
|
|
244
247
|
break;
|
|
245
248
|
case tag === Constant.InputTag && current === Privacy.None:
|
|
246
249
|
// If even default privacy setting is to not mask, we still scan through input fields for any sensitive information
|
|
247
250
|
let field: string = Constant.Empty;
|
|
248
251
|
Object.keys(attributes).forEach(x => field += attributes[x].toLowerCase());
|
|
249
|
-
metadata.privacy = inspect(field, metadata);
|
|
252
|
+
metadata.privacy = inspect(field, maskInput, metadata);
|
|
250
253
|
break;
|
|
251
254
|
case current === Privacy.Sensitive && tag === Constant.InputTag:
|
|
255
|
+
// Look through class names to aggressively mask content
|
|
256
|
+
metadata.privacy = inspect(attributes[Constant.Class], maskText, metadata);
|
|
252
257
|
// If it's a button or an input option, make an exception to disable masking
|
|
253
258
|
metadata.privacy = maskDisable.indexOf(attributes[Constant.Type]) >= 0 ? Privacy.None : current;
|
|
254
259
|
break;
|
|
255
260
|
case current === Privacy.Sensitive:
|
|
256
261
|
// In a mode where we mask sensitive information by default, look through class names to aggressively mask content
|
|
257
|
-
metadata.privacy = inspect(attributes[Constant.Class], metadata);
|
|
258
|
-
break;
|
|
259
|
-
default:
|
|
260
|
-
metadata.privacy = parent ? parent.metadata.privacy : metadata.privacy;
|
|
262
|
+
metadata.privacy = inspect(attributes[Constant.Class], maskText, metadata);
|
|
261
263
|
break;
|
|
262
264
|
}
|
|
263
265
|
}
|
|
264
266
|
|
|
265
|
-
function inspect(input: string, metadata: NodeMeta): Privacy {
|
|
266
|
-
if (input &&
|
|
267
|
+
function inspect(input: string, lookup: string[], metadata: NodeMeta): Privacy {
|
|
268
|
+
if (input && lookup.some(x => input.indexOf(x) >= 0)) {
|
|
267
269
|
return Privacy.Text;
|
|
268
270
|
}
|
|
269
271
|
return metadata.privacy;
|
package/src/layout/mutation.ts
CHANGED
|
@@ -114,7 +114,7 @@ function handle(m: MutationRecord[]): void {
|
|
|
114
114
|
summary.track(Event.Mutation, now);
|
|
115
115
|
mutations.push({ time: now, mutations: m});
|
|
116
116
|
task.schedule(process, Priority.High).then((): void => {
|
|
117
|
-
|
|
117
|
+
setTimeout(doc.compute)
|
|
118
118
|
measure(region.compute)();
|
|
119
119
|
});
|
|
120
120
|
}
|
package/src/layout/node.ts
CHANGED
|
@@ -7,7 +7,7 @@ import * as interaction from "@src/interaction";
|
|
|
7
7
|
import * as mutation from "@src/layout/mutation";
|
|
8
8
|
import * as schema from "@src/layout/schema";
|
|
9
9
|
|
|
10
|
-
const IGNORE_ATTRIBUTES = ["title", "alt", "onload", "onfocus", "onerror"];
|
|
10
|
+
const IGNORE_ATTRIBUTES = ["title", "alt", "onload", "onfocus", "onerror", "data-drupal-form-submit-last"];
|
|
11
11
|
const newlineRegex = /[\r\n]+/g;
|
|
12
12
|
|
|
13
13
|
export default function (node: Node, source: Source): Node {
|
package/test/core.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assert } from 'chai';
|
|
2
2
|
import { Browser, Page } from 'playwright';
|
|
3
|
-
import { launch, markup, node, text } from './helper';
|
|
3
|
+
import { clicks, inputs, launch, markup, node, text } from './helper';
|
|
4
4
|
import { Data, decode } from "clarity-decode";
|
|
5
5
|
|
|
6
6
|
let browser: Browser;
|
|
@@ -33,15 +33,21 @@ describe('Core Tests', () => {
|
|
|
33
33
|
let email = node(decoded, "attributes.id", "eml");
|
|
34
34
|
let password = node(decoded, "attributes.id", "pwd");
|
|
35
35
|
let search = node(decoded, "attributes.id", "search");
|
|
36
|
-
|
|
36
|
+
let click = clicks(decoded)[0];
|
|
37
|
+
let input = inputs(decoded)[0];
|
|
38
|
+
|
|
37
39
|
// Non-sensitive fields continue to pass through with sensitive bits masked off
|
|
38
|
-
|
|
40
|
+
assert.equal(heading, "Thanks for your order #••••••••");
|
|
39
41
|
|
|
40
42
|
// Sensitive fields, including input fields, are randomized and masked
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
assert.equal(address, "•••••• ••••• ••••• ••••• ••••• •••••");
|
|
44
|
+
assert.equal(email.attributes.value, "••••• •••• •••• ••••");
|
|
45
|
+
assert.equal(password.attributes.value, "••••• ••••");
|
|
46
|
+
assert.equal(search.attributes.value, "hello •••••");
|
|
47
|
+
|
|
48
|
+
// Clicked text and input value should be consistent with uber masking configuration
|
|
49
|
+
assert.equal(click.data.text, "Hello •••••");
|
|
50
|
+
assert.equal(input.data.value, "query with •••••••");
|
|
45
51
|
});
|
|
46
52
|
|
|
47
53
|
it('should mask all text in strict mode', async () => {
|
|
@@ -52,16 +58,22 @@ describe('Core Tests', () => {
|
|
|
52
58
|
let email = node(decoded, "attributes.id", "eml");
|
|
53
59
|
let password = node(decoded, "attributes.id", "pwd");
|
|
54
60
|
let search = node(decoded, "attributes.id", "search");
|
|
61
|
+
let click = clicks(decoded)[0];
|
|
62
|
+
let input = inputs(decoded)[0];
|
|
55
63
|
|
|
56
64
|
// All fields are randomized and masked
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
assert.equal(heading, "• ••••• ••••• ••••• ••••• •••••");
|
|
66
|
+
assert.equal(address, "•••••• ••••• ••••• ••••• ••••• •••••");
|
|
67
|
+
assert.equal(email.attributes.value, "••••• •••• •••• ••••");
|
|
68
|
+
assert.equal(password.attributes.value, "••••• ••••");
|
|
69
|
+
assert.equal(search.attributes.value, "••••• •••• ••••");
|
|
70
|
+
|
|
71
|
+
// Clicked text and input value should also be masked in strict mode
|
|
72
|
+
assert.equal(click.data.text, "••••• •••• ••••");
|
|
73
|
+
assert.equal(input.data.value, "••••• •••• •••• ••••");
|
|
62
74
|
});
|
|
63
75
|
|
|
64
|
-
it('should
|
|
76
|
+
it('should unmask all text in relaxed mode', async () => {
|
|
65
77
|
let encoded: string[] = await markup(page, "core.html", { unmask: ["body"] });
|
|
66
78
|
let decoded = encoded.map(x => decode(x));
|
|
67
79
|
let heading = text(decoded, "one");
|
|
@@ -69,14 +81,35 @@ describe('Core Tests', () => {
|
|
|
69
81
|
let email = node(decoded, "attributes.id", "eml");
|
|
70
82
|
let password = node(decoded, "attributes.id", "pwd");
|
|
71
83
|
let search = node(decoded, "attributes.id", "search");
|
|
84
|
+
let click = clicks(decoded)[0];
|
|
85
|
+
let input = inputs(decoded)[0];
|
|
72
86
|
|
|
73
87
|
// Text flows through unmasked for non-sensitive fields, including input fields
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
assert.equal(heading, "Thanks for your order #2AB700GH");
|
|
89
|
+
assert.equal(address, "1 Microsoft Way, Redmond, WA - 98052");
|
|
90
|
+
assert.equal(search.attributes.value, "hello w0rld");
|
|
77
91
|
|
|
78
92
|
// Sensitive fields are still masked
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
assert.equal(email.attributes.value, "••••• •••• •••• ••••");
|
|
94
|
+
assert.equal(password.attributes.value, "••••• ••••");
|
|
95
|
+
|
|
96
|
+
// Clicked text and input value (non-sensitive) both come through without masking in relaxed mode
|
|
97
|
+
assert.equal(click.data.text, "Hello Wor1d");
|
|
98
|
+
assert.equal(input.data.value, "query with numb3rs");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should respect mask config even in relaxed mode', async () => {
|
|
102
|
+
let encoded: string[] = await markup(page, "core.html", { mask: ["#mask"], unmask: ["body"] });
|
|
103
|
+
let decoded = encoded.map(x => decode(x));
|
|
104
|
+
let subtree = text(decoded, "child");
|
|
105
|
+
let click = clicks(decoded)[0];
|
|
106
|
+
let input = inputs(decoded)[0];
|
|
107
|
+
|
|
108
|
+
// Masked sub-trees continue to stay masked
|
|
109
|
+
assert.equal(subtree, "••••• •••••");
|
|
110
|
+
|
|
111
|
+
// Clicked text is masked due to masked configuration while input value is not masked in relaxed mode
|
|
112
|
+
assert.equal(click.data.text, "••••• •••• ••••");
|
|
113
|
+
assert.equal(input.data.value, "query with numb3rs");
|
|
81
114
|
});
|
|
82
115
|
});
|
package/test/helper.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Core, Data, Layout } from "clarity-decode";
|
|
1
|
+
import { Core, Data, decode, Interaction, Layout } from "clarity-decode";
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as url from 'url';
|
|
4
4
|
import * as path from 'path';
|
|
@@ -25,10 +25,38 @@ export async function markup(page: Page, file: string, override: Core.Config = n
|
|
|
25
25
|
</body>
|
|
26
26
|
`));
|
|
27
27
|
await page.hover("#two");
|
|
28
|
-
await page.
|
|
28
|
+
await page.click("#child");
|
|
29
|
+
await page.locator('#search').fill('query with numb3rs');
|
|
30
|
+
await page.waitForFunction("payloads && payloads.length > 2");
|
|
29
31
|
return await page.evaluate('payloads');
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
export function clicks(decoded: Data.DecodedPayload[]): Interaction.ClickEvent[] {
|
|
35
|
+
let output: Interaction.ClickEvent[] = [];
|
|
36
|
+
for (let i = decoded.length - 1; i >= 0; i--) {
|
|
37
|
+
if (decoded[i].click) {
|
|
38
|
+
for (let j = 0; j < decoded[i].click.length;j++)
|
|
39
|
+
{
|
|
40
|
+
output.push(decoded[i].click[j]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function inputs(decoded: Data.DecodedPayload[]): Interaction.InputEvent[] {
|
|
48
|
+
let output: Interaction.InputEvent[] = [];
|
|
49
|
+
for (let i = decoded.length - 1; i >= 0; i--) {
|
|
50
|
+
if (decoded[i].input) {
|
|
51
|
+
for (let j = 0; j < decoded[i].input.length;j++)
|
|
52
|
+
{
|
|
53
|
+
output.push(decoded[i].input[j]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return output;
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
export function node(decoded: Data.DecodedPayload[], key: string, value: string | number, tag: string = null): Layout.DomData {
|
|
33
61
|
let sub = null;
|
|
34
62
|
|
|
@@ -78,6 +106,10 @@ function config(override: Core.Config): string {
|
|
|
78
106
|
const settings = {
|
|
79
107
|
delay: 100,
|
|
80
108
|
content: true,
|
|
109
|
+
fraud: [],
|
|
110
|
+
regions: [],
|
|
111
|
+
mask: [],
|
|
112
|
+
unmask: [],
|
|
81
113
|
upload: payload => { window["payloads"].push(payload); window["clarity"]("upgrade", "test"); }
|
|
82
114
|
}
|
|
83
115
|
|
|
@@ -100,6 +132,7 @@ function config(override: Core.Config): string {
|
|
|
100
132
|
case "unmask":
|
|
101
133
|
case "regions":
|
|
102
134
|
case "cookies":
|
|
135
|
+
case "fraud":
|
|
103
136
|
output += `${JSON.stringify(key)}: ${JSON.stringify(settings[key])},`;
|
|
104
137
|
break;
|
|
105
138
|
default:
|
package/test/html/core.html
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
<h1 id="one">Thanks for your order #2AB700GH</h1>
|
|
9
9
|
<p id="two" class="address-details">1 Microsoft Way, Redmond, WA - 98052</p>
|
|
10
10
|
</div>
|
|
11
|
+
<div id="mask">
|
|
12
|
+
<span id="child">Hello Wor1d</span>
|
|
13
|
+
</div>
|
|
11
14
|
<form name="login">
|
|
12
15
|
<input type="email" id="eml" title="Email" value="random@email.test">
|
|
13
16
|
<input type="password" id="pwd" title="Password" maxlength="16" value="passw0rd">
|
|
14
|
-
<input type="search" id="search" title="Search" value="hello
|
|
17
|
+
<input type="search" id="search" title="Search" value="hello w0rld">
|
|
15
18
|
</form>
|
|
16
19
|
</body>
|
|
17
20
|
</html>
|
package/types/data.d.ts
CHANGED
|
@@ -247,7 +247,9 @@ export const enum Constant {
|
|
|
247
247
|
UserId = "userId",
|
|
248
248
|
SessionId = "sessionId",
|
|
249
249
|
PageId = "pageId",
|
|
250
|
-
Mask = "•",
|
|
250
|
+
Mask = "•", // Placeholder character for explicitly masked content
|
|
251
|
+
Digit = "•", // Placeholder character for digits
|
|
252
|
+
Letter = "•", // Placeholder character for letters
|
|
251
253
|
SessionStorage = "sessionStorage",
|
|
252
254
|
Cookie = "cookie",
|
|
253
255
|
Navigation = "navigation",
|
package/types/layout.d.ts
CHANGED
|
@@ -29,7 +29,8 @@ export const enum RegionVisibility {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export const enum Mask {
|
|
32
|
-
Text = "password,
|
|
32
|
+
Text = "address,password,contact",
|
|
33
|
+
Input = "password,secret,pass,social,ssn,name,code,dob,cell,mob,contact,hidden,account,cvv,ccv,email,tel,phone,address,addr,card,zip",
|
|
33
34
|
Disable = "radio,checkbox,range,button,reset,submit"
|
|
34
35
|
}
|
|
35
36
|
|