clarity-js 0.6.36 → 0.6.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clarity-js",
3
- "version": "0.6.36",
3
+ "version": "0.6.39",
4
4
  "description": "An analytics library that uses web page interactions to generate aggregated insights",
5
5
  "author": "Microsoft Corp.",
6
6
  "license": "MIT",
package/src/core/scrub.ts CHANGED
@@ -2,6 +2,12 @@ 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
+ const catchallRegex = /\S/gi;
6
+ let unicodeRegex = true;
7
+ let digitRegex = null;
8
+ let letterRegex = null;
9
+ let currencyRegex = null;
10
+
5
11
  export default function(value: string, hint: string, privacy: Privacy, mangle: boolean = false): string {
6
12
  if (value) {
7
13
  switch (privacy) {
@@ -12,9 +18,9 @@ export default function(value: string, hint: string, privacy: Privacy, mangle: b
12
18
  case Layout.Constant.TextTag:
13
19
  case "value":
14
20
  case "placeholder":
15
- return redact(value);
21
+ case "click":
16
22
  case "input":
17
- return mangleToken(value);
23
+ return redact(value);
18
24
  }
19
25
  return value;
20
26
  case Privacy.Text:
@@ -53,7 +59,7 @@ function mangleText(value: string): string {
53
59
  }
54
60
 
55
61
  function mask(value: string): string {
56
- return value.replace(/\S/gi, Data.Constant.Mask);
62
+ return value.replace(catchallRegex, Data.Constant.Mask);
57
63
  }
58
64
 
59
65
  function mangleToken(value: string): string {
@@ -67,10 +73,22 @@ function mangleToken(value: string): string {
67
73
 
68
74
  function redact(value: string): string {
69
75
  let spaceIndex = -1;
76
+ let gap = 0;
70
77
  let hasDigit = false;
71
78
  let hasEmail = false;
72
79
  let hasWhitespace = false;
73
80
  let array = null;
81
+
82
+ // Initialize unicode regex, if supported by the browser
83
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes
84
+ if (unicodeRegex && digitRegex === null) {
85
+ try {
86
+ digitRegex = new RegExp("\\p{N}", "gu");
87
+ letterRegex = new RegExp("\\p{L}", "gu");
88
+ currencyRegex = new RegExp("\\p{Sc}", "gu");
89
+ } catch { unicodeRegex = false; }
90
+ }
91
+
74
92
  for (let i = 0; i < value.length; i++) {
75
93
  let c = value.charCodeAt(i);
76
94
  hasDigit = hasDigit || (c >= Data.Character.Zero && c <= Data.Character.Nine); // Check for digits in the current word
@@ -82,7 +100,18 @@ function redact(value: string): string {
82
100
  // Performance optimization: Lazy load string -> array conversion only when required
83
101
  if (hasDigit || hasEmail) {
84
102
  if (array === null) { array = value.split(Data.Constant.Empty); }
85
- mutate(array, spaceIndex, hasWhitespace ? i : i + 1);
103
+ // Work on a token at a time so we don't have to apply regex to a larger string
104
+ let token = value.substring(spaceIndex + 1, hasWhitespace ? i : i + 1);
105
+ // Check if unicode regex is supported, otherwise fallback to calling mask function on this token
106
+ if (unicodeRegex && currencyRegex !== null) {
107
+ // Do not redact information if the token contains a currency symbol
108
+ token = token.match(currencyRegex) ? token : token.replace(letterRegex, Data.Constant.Letter).replace(digitRegex, Data.Constant.Digit);
109
+ } else {
110
+ token = mask(token);
111
+ }
112
+ // Merge token back into array at the right place
113
+ array.splice(spaceIndex + 1 - gap, token.length, token);
114
+ gap += token.length - 1;
86
115
  }
87
116
  // Reset digit and email flags after every word boundary, except the beginning of string
88
117
  if (hasWhitespace) {
@@ -94,9 +123,3 @@ function redact(value: string): string {
94
123
  }
95
124
  return array ? array.join(Data.Constant.Empty) : value;
96
125
  }
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
- }
@@ -1,2 +1,2 @@
1
- let version = "0.6.36";
1
+ let version = "0.6.39";
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, "mousewheel", mouse.bind(this, Event.MouseWheel, root), true);
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 && maskText.some(x => input.indexOf(x) >= 0)) {
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/test/core.test.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { expect } from 'chai';
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
- expect(heading, "Thanks for your order •••••••••");
40
+ assert.equal(heading, "Thanks for your order #••••••••");
39
41
 
40
42
  // Sensitive fields, including input fields, are randomized and masked
41
- expect(address, "•••••• ••••• ••••• ••••• ••••• •••••");
42
- expect(email.attributes.value, "••••• •••• •••• ••••");
43
- expect(password.attributes.value, "••••• ••••");
44
- expect(search.attributes.value, "••••• •••• ••••");
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
- expect(heading, "• ••••• ••••• ••••• ••••• •••••");
58
- expect(address, "•••••• ••••• ••••• ••••• ••••• •••••");
59
- expect(email.attributes.value, "••••• •••• •••• ••••");
60
- expect(password.attributes.value, "••••• ••••");
61
- expect(search.attributes.value, "••••• •••• ••••");
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 mask all text in relaxed mode', async () => {
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
- expect(heading, "Thanks for your order #2AB700GH");
75
- expect(address, "1 Microsoft Way, Redmond, WA - 98052");
76
- expect(search.attributes.value, "hello world");
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
- expect(email.attributes.value, "••••• •••• •••• ••••");
80
- expect(password.attributes.value, "••••• ••••");
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.waitForFunction("payloads && payloads.length > 1");
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:
@@ -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 world">
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,secret,pass,social,ssn,name,code,dob,cell,mob,contact,hidden,account,cvv,ccv,email,tel,phone,address,addr,card,zip",
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