clarity-js 0.6.37 → 0.6.40

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/src/layout/dom.ts CHANGED
@@ -5,7 +5,7 @@ import config from "@src/core/config";
5
5
  import hash from "@src/core/hash";
6
6
  import * as internal from "@src/diagnostic/internal";
7
7
  import * as region from "@src/layout/region";
8
- import selector from "@src/layout/selector";
8
+ import * as selector from "@src/layout/selector";
9
9
  import * as mutation from "@src/layout/mutation";
10
10
  import * as extract from "@src/data/extract";
11
11
  let index: number = 1;
@@ -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,11 +44,13 @@ 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();
49
51
  privacyMap = new WeakMap();
50
52
  fraudMap = new WeakMap();
53
+ selector.reset();
51
54
  }
52
55
 
53
56
  // We parse new root nodes for any regions or masked nodes in the beginning (document) and
@@ -234,37 +237,37 @@ function privacy(node: Node, value: NodeValue, parent: NodeValue): void {
234
237
  metadata.privacy = Privacy.Text;
235
238
  break;
236
239
  case tag === Constant.TextTag:
237
- // If it's a text node belonging to a STYLE or TITLE tag or one of SCRUB_EXCEPTIONS, then capture content
240
+ // If it's a text node belonging to a STYLE or TITLE tag or one of scrub exceptions, then capture content
238
241
  let pTag = parent && parent.data ? parent.data.tag : Constant.Empty;
239
- let pSelector = parent && parent.selector ? parent.selector[Selector.Stable] : Constant.Empty;
240
- metadata.privacy = pTag === Constant.StyleTag || pTag === Constant.TitleTag || override.some(x => pSelector.indexOf(x) >= 0) ? Privacy.None : current;
242
+ let pSelector = parent && parent.selector ? parent.selector[Selector.Default] : Constant.Empty;
243
+ let tags : string[] = [Constant.StyleTag, Constant.TitleTag, Constant.SvgStyle];
244
+ metadata.privacy = tags.includes(pTag) || override.some(x => pSelector.indexOf(x) >= 0) ? Privacy.None : current;
241
245
  break;
242
246
  case Constant.Type in attributes:
243
247
  // If this node has an explicit type assigned to it, go through masking rules to determine right privacy setting
244
- metadata.privacy = inspect(attributes[Constant.Type], metadata);
248
+ metadata.privacy = inspect(attributes[Constant.Type], maskInput, metadata);
245
249
  break;
246
250
  case tag === Constant.InputTag && current === Privacy.None:
247
251
  // If even default privacy setting is to not mask, we still scan through input fields for any sensitive information
248
252
  let field: string = Constant.Empty;
249
253
  Object.keys(attributes).forEach(x => field += attributes[x].toLowerCase());
250
- metadata.privacy = inspect(field, metadata);
254
+ metadata.privacy = inspect(field, maskInput, metadata);
251
255
  break;
252
256
  case current === Privacy.Sensitive && tag === Constant.InputTag:
257
+ // Look through class names to aggressively mask content
258
+ metadata.privacy = inspect(attributes[Constant.Class], maskText, metadata);
253
259
  // If it's a button or an input option, make an exception to disable masking
254
260
  metadata.privacy = maskDisable.indexOf(attributes[Constant.Type]) >= 0 ? Privacy.None : current;
255
261
  break;
256
262
  case current === Privacy.Sensitive:
257
263
  // In a mode where we mask sensitive information by default, look through class names to aggressively mask content
258
- metadata.privacy = inspect(attributes[Constant.Class], metadata);
259
- break;
260
- default:
261
- metadata.privacy = parent ? parent.metadata.privacy : metadata.privacy;
264
+ metadata.privacy = inspect(attributes[Constant.Class], maskText, metadata);
262
265
  break;
263
266
  }
264
267
  }
265
268
 
266
- function inspect(input: string, metadata: NodeMeta): Privacy {
267
- if (input && maskText.some(x => input.indexOf(x) >= 0)) {
269
+ function inspect(input: string, lookup: string[], metadata: NodeMeta): Privacy {
270
+ if (input && lookup.some(x => input.indexOf(x) >= 0)) {
268
271
  return Privacy.Text;
269
272
  }
270
273
  return metadata.privacy;
@@ -297,10 +300,11 @@ function updateSelector(value: NodeValue): void {
297
300
  let prefix = parent ? parent.selector : null;
298
301
  let d = value.data;
299
302
  let p = position(parent, value);
300
- let s: SelectorInput = { tag: d.tag, prefix, position: p, attributes: d.attributes };
301
- value.selector = [selector(s), selector(s, true)];
303
+ let s: SelectorInput = { id: value.id, tag: d.tag, prefix, position: p, attributes: d.attributes };
304
+ value.selector = [selector.get(s, Selector.Alpha), selector.get(s, Selector.Beta)];
302
305
  value.hash = value.selector.map(x => x ? hash(x) : null) as [string, string];
303
306
  value.hash.forEach(h => hashMap[h] = value.id);
307
+ // Match fragment configuration against both alpha and beta hash
304
308
  if (value.hash.some(h => extract.fragments.indexOf(h) !== -1)) {
305
309
  value.fragment = value.id;
306
310
  }
@@ -62,7 +62,7 @@ export default async function (type: Event, timer: Timer = null, ts: number = nu
62
62
  if (value.parent && active) { tokens.push(value.parent); }
63
63
  if (value.previous && active) { tokens.push(value.previous); }
64
64
  tokens.push(suspend ? Constant.SuspendMutationTag : data[key]);
65
- if (box && box.length === 2) { tokens.push(`${Constant.Box}${str(box[0])}.${str(box[1])}`); }
65
+ if (box && box.length === 2) { tokens.push(`${Constant.Hash}${str(box[0])}.${str(box[1])}`); }
66
66
  break;
67
67
  case "attributes":
68
68
  for (let attr in data[key]) {
@@ -1,12 +1,17 @@
1
1
  import { Character } from "../../types/data";
2
2
  import { Constant, Selector, SelectorInput } from "../../types/layout";
3
3
 
4
- const TAGS = ["DIV", "TR", "P", "LI", "UL", "A", "BUTTON"];
4
+ const excludeClassNames = Constant.ExcludeClassNames.split(Constant.Comma);
5
+ let selectorMap: { [selector: string]: number[] } = {};
5
6
 
6
- export default function(input: SelectorInput, beta: boolean = false): string {
7
+ export function reset(): void {
8
+ selectorMap = {};
9
+ }
10
+
11
+ export function get(input: SelectorInput, type: Selector): string {
7
12
  let a = input.attributes;
8
- let prefix = input.prefix ? input.prefix[beta ? Selector.Beta : Selector.Stable] : null;
9
- let suffix = beta || ((a && !(Constant.Class in a)) || TAGS.indexOf(input.tag) >= 0) ? `:nth-of-type(${input.position})` : Constant.Empty;
13
+ let prefix = input.prefix ? input.prefix[type] : null;
14
+ let suffix = type === Selector.Alpha ? `${Constant.Tilde}${input.position-1}` : `:nth-of-type(${input.position})`;
10
15
  switch (input.tag) {
11
16
  case "STYLE":
12
17
  case "TITLE":
@@ -19,22 +24,28 @@ export default function(input: SelectorInput, beta: boolean = false): string {
19
24
  return Constant.HTML;
20
25
  default:
21
26
  if (prefix === null) { return Constant.Empty; }
22
- prefix = `${prefix}>`;
27
+ prefix = `${prefix}${Constant.Separator}`;
23
28
  input.tag = input.tag.indexOf(Constant.SvgPrefix) === 0 ? input.tag.substr(Constant.SvgPrefix.length) : input.tag;
24
29
  let selector = `${prefix}${input.tag}${suffix}`;
25
- let classes = Constant.Class in a && a[Constant.Class].length > 0 ? a[Constant.Class].trim().split(/\s+/) : null;
26
- if (beta) {
27
- // In beta mode, update selector to use "id" field when available. There are two exceptions:
28
- // (1) if "id" appears to be an auto generated string token, e.g. guid or a random id containing digits
29
- // (2) if "id" appears inside a shadow DOM, in which case we continue to prefix up to shadow DOM to prevent conflicts
30
- let id = Constant.Id in a && a[Constant.Id].length > 0 ? a[Constant.Id] : null;
31
- classes = input.tag !== Constant.BodyTag && classes ? classes.filter(c => !hasDigits(c)) : [];
32
- selector = classes.length > 0 ? `${prefix}${input.tag}.${classes.join(".")}${suffix}` : selector;
33
- selector = id && hasDigits(id) === false ? `${getDomPrefix(prefix)}#${id}` : selector;
34
- } else {
35
- // Otherwise, fallback to stable mode, where we include class names as part of the selector
36
- selector = classes ? `${prefix}${input.tag}.${classes.join(".")}${suffix}` : selector;
37
- }
30
+ let id = Constant.Id in a && a[Constant.Id].length > 0 ? a[Constant.Id] : null;
31
+ let classes = input.tag !== Constant.BodyTag && Constant.Class in a && a[Constant.Class].length > 0 ? a[Constant.Class].trim().split(/\s+/).filter(c => filter(c)).join(Constant.Period) : null;
32
+ if (classes && classes.length > 0) {
33
+ if (type === Selector.Alpha) {
34
+ // In Alpha mode, update selector to use class names, with relative positioning within the parent id container.
35
+ // If the node has valid class name(s) then drop relative positioning within the parent path to keep things simple.
36
+ let key = `${getDomPath(prefix)}${input.tag}${Constant.Dot}${classes}`;
37
+ if (!(key in selectorMap)) { selectorMap[key] = []; }
38
+ if (selectorMap[key].indexOf(input.id) < 0) { selectorMap[key].push(input.id); }
39
+ selector = `${key}${Constant.Tilde}${selectorMap[key].indexOf(input.id)}`;
40
+ } else {
41
+ // In Beta mode, we continue to look at query selectors in context of the full page
42
+ selector = `${prefix}${input.tag}.${classes}${suffix}`
43
+ }
44
+ }
45
+ // Update selector to use "id" field when available. There are two exceptions:
46
+ // (1) if "id" appears to be an auto generated string token, e.g. guid or a random id containing digits
47
+ // (2) if "id" appears inside a shadow DOM, in which case we continue to prefix up to shadow DOM to prevent conflicts
48
+ selector = id && filter(id) ? `${getDomPrefix(prefix)}${Constant.Hash}${id}` : selector;
38
49
  return selector;
39
50
  }
40
51
  }
@@ -44,19 +55,28 @@ function getDomPrefix(prefix: string): string {
44
55
  const iframeDomStart = prefix.lastIndexOf(`${Constant.IFramePrefix}${Constant.HTML}`);
45
56
  const domStart = Math.max(shadowDomStart, iframeDomStart);
46
57
 
47
- if (domStart < 0) {
48
- return "";
49
- }
58
+ if (domStart < 0) { return Constant.Empty; }
50
59
 
51
- const domEnd = prefix.indexOf(">", domStart) + 1;
52
- return prefix.substr(0, domEnd);
60
+ return prefix.substring(0, prefix.indexOf(Constant.Separator, domStart) + 1);
61
+ }
62
+
63
+ function getDomPath(input: string): string {
64
+ let parts = input.split(Constant.Separator);
65
+ for (let i = 0; i < parts.length; i++) {
66
+ let tIndex = parts[i].indexOf(Constant.Tilde);
67
+ let dIndex = parts[i].indexOf(Constant.Dot);
68
+ parts[i] = parts[i].substring(0, dIndex > 0 ? dIndex : (tIndex > 0 ? tIndex : parts[i].length));
69
+ }
70
+ return parts.join(Constant.Separator);
53
71
  }
54
72
 
55
- // Check if the given input string has digits or not
56
- function hasDigits(value: string): boolean {
73
+ // Check if the given input string has digits or excluded class names
74
+ function filter(value: string): boolean {
75
+ if (!value) { return false; } // Do not process empty strings
76
+ if (excludeClassNames.some(x => value.toLowerCase().indexOf(x) >= 0)) { return false; }
57
77
  for (let i = 0; i < value.length; i++) {
58
78
  let c = value.charCodeAt(i);
59
- if (c >= Character.Zero && c <= Character.Nine) { return true };
79
+ if (c >= Character.Zero && c <= Character.Nine) { return false };
60
80
  }
61
- return false;
81
+ return true;
62
82
  }
package/test/core.test.ts CHANGED
@@ -1,6 +1,6 @@
1
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
+ let click = clicks(decoded)[0];
37
+ let input = inputs(decoded)[0];
36
38
 
37
39
  // Non-sensitive fields continue to pass through with sensitive bits masked off
38
- assert.equal(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
43
  assert.equal(address, "•••••• ••••• ••••• ••••• ••••• •••••");
42
44
  assert.equal(email.attributes.value, "••••• •••• •••• ••••");
43
45
  assert.equal(password.attributes.value, "••••• ••••");
44
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,6 +58,8 @@ 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
65
  assert.equal(heading, "• ••••• ••••• ••••• ••••• •••••");
@@ -59,6 +67,10 @@ describe('Core Tests', () => {
59
67
  assert.equal(email.attributes.value, "••••• •••• •••• ••••");
60
68
  assert.equal(password.attributes.value, "••••• ••••");
61
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
76
  it('should unmask all text in relaxed mode', async () => {
@@ -69,6 +81,8 @@ 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
88
  assert.equal(heading, "Thanks for your order #2AB700GH");
@@ -78,14 +92,24 @@ describe('Core Tests', () => {
78
92
  // Sensitive fields are still masked
79
93
  assert.equal(email.attributes.value, "••••• •••• •••• ••••");
80
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");
81
99
  });
82
100
 
83
101
  it('should respect mask config even in relaxed mode', async () => {
84
102
  let encoded: string[] = await markup(page, "core.html", { mask: ["#mask"], unmask: ["body"] });
85
103
  let decoded = encoded.map(x => decode(x));
86
104
  let subtree = text(decoded, "child");
105
+ let click = clicks(decoded)[0];
106
+ let input = inputs(decoded)[0];
87
107
 
88
108
  // Masked sub-trees continue to stay masked
89
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");
90
114
  });
91
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
 
@@ -9,7 +9,7 @@
9
9
  <p id="two" class="address-details">1 Microsoft Way, Redmond, WA - 98052</p>
10
10
  </div>
11
11
  <div id="mask">
12
- <span id="child">Hello World</span>
12
+ <span id="child">Hello Wor1d</span>
13
13
  </div>
14
14
  <form name="login">
15
15
  <input type="email" id="eml" title="Email" value="random@email.test">
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/index.d.ts CHANGED
@@ -18,12 +18,17 @@ interface Clarity {
18
18
  metadata: (callback: Data.MetadataCallback, wait?: boolean) => void;
19
19
  }
20
20
 
21
+ interface Selector {
22
+ get: (input: Layout.SelectorInput, type: Layout.Selector) => string;
23
+ reset: () => void;
24
+ }
25
+
21
26
  interface Helper {
22
27
  get: (node: Node) => Layout.NodeValue;
23
28
  getNode: (id: number) => Node;
24
29
  hash: (input: string) => string;
25
30
  lookup: (hash: string) => number;
26
- selector: (input: Layout.SelectorInput, beta?: boolean) => string;
31
+ selector: Selector;
27
32
  }
28
33
 
29
34
  declare const clarity: Clarity;
package/types/layout.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Privacy } from "@clarity-types/core";
2
- import { BooleanFlag } from "@clarity-types/data";
3
2
 
4
3
  /* Enum */
5
4
 
@@ -12,8 +11,9 @@ export const enum Source {
12
11
  }
13
12
 
14
13
  export const enum Selector {
15
- Stable = 0,
16
- Beta = 1
14
+ Alpha = 0,
15
+ Beta = 1,
16
+ Default = 1
17
17
  }
18
18
 
19
19
  export const enum InteractionState {
@@ -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
 
@@ -44,7 +45,10 @@ export const enum Constant {
44
45
  Href = "href",
45
46
  Src = "src",
46
47
  Srcset = "srcset",
47
- Box = "#",
48
+ Hash = "#",
49
+ Dot = ".",
50
+ Separator = ">",
51
+ Tilde = "~",
48
52
  Bang = "!",
49
53
  Period = ".",
50
54
  Comma = ",",
@@ -90,7 +94,9 @@ export const enum Constant {
90
94
  Content = "content",
91
95
  Generator = "generator",
92
96
  ogType = "og:type",
93
- ogTitle = "og:title"
97
+ ogTitle = "og:title",
98
+ SvgStyle = "svg:style",
99
+ ExcludeClassNames = "load,active,fixed,visible,focus,show,collaps,animat"
94
100
  }
95
101
 
96
102
  export const enum JsonLD {
@@ -138,6 +144,7 @@ export interface Attributes {
138
144
  }
139
145
 
140
146
  export interface SelectorInput {
147
+ id: number;
141
148
  tag: string;
142
149
  prefix: [string, string];
143
150
  position: number;