@loxel.dev/pharos-browser 0.6.0 → 0.7.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 CHANGED
@@ -318,6 +318,90 @@ you can verify what a recorder would send before turning one on.
318
318
 
319
319
  ## Version history
320
320
 
321
+ - `0.7.0` — **a marker on a shadow host now protects its shadow root** (#821),
322
+ and the verification helper sees what the recorder sees (#822).
323
+
324
+ `hasMarkerAncestor` walked `parentElement`, which is `null` at a shadow
325
+ boundary, so `pharos-mask`/`pharos-exclude` on a web component's host
326
+ protected nothing inside it — while rrweb recorded that content regardless.
327
+ Established empirically before the fix: rrweb captures shadow-root text
328
+ unconditionally (no recorder option gates it), and `maskTextHook` *is*
329
+ invoked for those nodes, so the decision function was the sole failure
330
+ point. The walk now re-roots through `getRootNode()`/`.host`, bounded
331
+ against a malformed tree.
332
+
333
+ `collectRecordedStrings` did not traverse shadow DOM either. That was not a
334
+ compensating margin — **the auditor's blind spot lined up exactly with the
335
+ recorder's leak surface**, so an app could run the helper, get a clean
336
+ report, and be shipping shadow-root content. It now traverses, and
337
+ `pathOf()` renders a boundary crossing as `>>>` rather than truncating the
338
+ path to `''` (which was indistinguishable from a root-level row).
339
+
340
+ **Why MINOR, not PATCH:** two consumer-visible outputs change. Content
341
+ inside a marked shadow host is now masked where it previously shipped in
342
+ the clear, and `collectRecordedStrings` reports strings it previously
343
+ omitted — so a pinned audit snapshot will differ. Both changes are in the
344
+ safe direction, but a consumer sees different output either way.
345
+
346
+ `STRUCTURAL_ATTRS` is unchanged in membership and is now derived from a
347
+ justification map: each entry declares a `kind` — `closed-enumeration`
348
+ (which must name its finite vocabulary) or `cost-tradeoff` (which must
349
+ carry `meetsImpossibilityBar: false`). `class` is recorded honestly as the
350
+ second kind. The gate cannot verify a claim's truth; that stays human
351
+ review.
352
+
353
+ **The first round of that fix only reached the FULL SNAPSHOT.** rrweb
354
+ decides whether to consult `maskTextFn` in `needMaskingText`, which returns
355
+ "nothing to mask" for any node that is not an element and has no parent
356
+ *element* — and a text node whose parent is a `ShadowRoot` is exactly that.
357
+ So on the **incremental** streams (`characterData`, `childList` adds) the
358
+ decision function was never called at all and a **marked** host still
359
+ shipped `shadowRoot.textContent = user.name` in the clear once recording had
360
+ started. An element-wrapped `<p>` inside the same shadow root did not
361
+ reproduce it (rrweb takes its element branch there), which is how it
362
+ shipped. The recorder now post-processes both streams through the same
363
+ policy, and applies it over rrweb's answer in the snapshot too — in the
364
+ masking direction only, never unmasking.
365
+
366
+ **`pharos-exclude` across a shadow boundary shipped attributes.** rrweb's
367
+ `isBlocked` uses `closest()`, which does not cross a boundary, so an add
368
+ into an excluded host's shadow root was not dropped — and the scrub pass
369
+ judges a serialized node through a detached surrogate that by construction
370
+ has no host and no ancestry. Measured: `<p title="SECRET">TEXT</p>` appended
371
+ into an excluded host's shadow root shipped its `title` verbatim while its
372
+ text was masked. Such adds are now dropped, judged against the **live**
373
+ parent the mutation names by id.
374
+
375
+ **Snapshot noise to expect on your first re-run:** shadow roots almost
376
+ always contain a `<style>`, and `collectRecordedStrings` now reaches it and
377
+ reports its CSS text as an ordinary text row. That is not a leak — author
378
+ CSS is not user content — but a pinned audit snapshot will grow by more than
379
+ the app's own strings.
380
+
381
+ **Still not covered:**
382
+ - **Closed shadow roots**, which rrweb cannot reach either (`n.shadowRoot`
383
+ is `null`), so neither the recorder nor the helper sees them.
384
+ - **The full snapshot's own answer for a bare shadow text node is rrweb's,
385
+ not the policy's**, and it flips on the host's shape:
386
+ `needMaskingText` returns `false` for an element with no child nodes, so
387
+ whether a shadow subtree inherits masking depends on whether the host
388
+ happens to have light-DOM children. The policy is now applied over the
389
+ top, but only ever to mask MORE — so an **unmarked** host's bare shadow
390
+ text can be masked in the snapshot while `collectRecordedStrings` reports
391
+ it. The report is the truthful one: the recorder ships that string
392
+ verbatim on the incremental stream.
393
+ - **An excluded host's shadow subtree can still leave structural traces.**
394
+ Content is dropped, but rrweb's mirror holds nodes this SDK removed from
395
+ the `adds` stream, so a later `removes` entry can carry their ids. Ids and
396
+ tree shape, never text or attributes.
397
+ - Light-DOM content projected through a native `<slot>` is **no longer**
398
+ on this list: it was measured and is covered. A slotted node is an
399
+ ordinary light-DOM child of the host — rrweb serializes it under the host
400
+ and the helper reaches it via `el.children` — so the two agree with no
401
+ slot-specific handling, and a marker on the host governs it through
402
+ ordinary light-DOM ancestry. There is a test for it so it does not have to
403
+ be re-derived.
404
+
321
405
  - `0.6.0` — **first npm release, as `@loxel.dev/pharos-browser`.** Ships built
322
406
  output with an `exports` map, so it resolves as an ordinary package.
323
407
 
@@ -7,12 +7,23 @@ function maskText(_text) {
7
7
  // src/privacy/markers.ts
8
8
  var EXCLUDE_CLASS = "pharos-exclude";
9
9
  var MASK_CLASS = "pharos-mask";
10
+ var MAX_HOPS = 1000;
10
11
  function hasMarkerAncestor(el, cls) {
11
12
  let node = el;
13
+ let hops = 0;
12
14
  while (node) {
13
15
  if (node.classList?.contains(cls))
14
16
  return true;
15
- node = node.parentElement;
17
+ const parentEl = node.parentElement;
18
+ if (parentEl) {
19
+ node = parentEl;
20
+ } else {
21
+ const root = node.getRootNode();
22
+ node = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot ? root.host : null;
23
+ }
24
+ hops += 1;
25
+ if (hops > MAX_HOPS)
26
+ return true;
16
27
  }
17
28
  return false;
18
29
  }
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  scrubUrl,
28
28
  shouldExclude,
29
29
  shouldMaskByMarker
30
- } from "./index-bc4bw3ba.js";
30
+ } from "./index-yabengpp.js";
31
31
  import {
32
32
  __require
33
33
  } from "./index-c3taa3cg.js";
@@ -71,17 +71,74 @@ function parseStack(stack) {
71
71
  return frames;
72
72
  }
73
73
  // src/privacy/collect.ts
74
- var STRUCTURAL_ATTRS = new Set(["class", "type", "contenteditable"]);
74
+ var STRUCTURAL_ATTR_JUSTIFICATIONS = {
75
+ type: {
76
+ kind: "closed-enumeration",
77
+ claim: "The `type` attribute's value space is a fixed, closed enumeration defined by the HTML Living Standard, scoped per element. No element accepts an application-supplied free-text value there — an out-of-vocabulary `type` is simply not a recognized state.",
78
+ vocabulary: [
79
+ "text",
80
+ "button",
81
+ "checkbox",
82
+ "color",
83
+ "date",
84
+ "datetime-local",
85
+ "email",
86
+ "file",
87
+ "hidden",
88
+ "image",
89
+ "month",
90
+ "number",
91
+ "password",
92
+ "radio",
93
+ "range",
94
+ "reset",
95
+ "search",
96
+ "submit",
97
+ "tel",
98
+ "time",
99
+ "url",
100
+ "week",
101
+ "1",
102
+ "a",
103
+ "A",
104
+ "i",
105
+ "I"
106
+ ]
107
+ },
108
+ contenteditable: {
109
+ kind: "closed-enumeration",
110
+ claim: "The `contenteditable` attribute's value space is a closed enumeration defined by the HTML Living Standard. Any other string is not a recognized keyword state, so this attribute cannot carry free text.",
111
+ vocabulary: ["true", "false", "plaintext-only", "inherit", ""]
112
+ },
113
+ class: {
114
+ kind: "cost-tradeoff",
115
+ claim: "class has no closed vocabulary and CAN carry templated content in principle, so it does not clear the content-impossibility bar the enumeration entries clear. It is kept anyway as a stated cost/benefit call: every element has a class attribute, and treating it as content would drown every snapshot in noise.",
116
+ meetsImpossibilityBar: false
117
+ }
118
+ };
119
+ var STRUCTURAL_ATTRS = new Set(Object.keys(STRUCTURAL_ATTR_JUSTIFICATIONS));
120
+ var MAX_PATH_HOPS = 1000;
75
121
  function pathOf(el, root) {
76
122
  const parts = [];
77
123
  let node = el;
124
+ let hops = 0;
78
125
  while (node && node !== root) {
79
126
  const parent = node.parentElement;
80
- if (!parent)
127
+ if (parent) {
128
+ const index = Array.prototype.indexOf.call(parent.children, node) + 1;
129
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
130
+ node = parent;
131
+ } else {
132
+ const shadowRoot = node.getRootNode();
133
+ if (!(typeof ShadowRoot !== "undefined" && shadowRoot instanceof ShadowRoot))
134
+ break;
135
+ const index = Array.prototype.indexOf.call(shadowRoot.children, node) + 1;
136
+ parts.unshift(">>>", `${node.tagName.toLowerCase()}:nth-child(${index})`);
137
+ node = shadowRoot.host;
138
+ }
139
+ hops += 1;
140
+ if (hops > MAX_PATH_HOPS)
81
141
  break;
82
- const index = Array.prototype.indexOf.call(parent.children, node) + 1;
83
- parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
84
- node = parent;
85
142
  }
86
143
  return parts.join(" > ");
87
144
  }
@@ -92,8 +149,8 @@ function collectRecordedStrings(root) {
92
149
  if (decision === "exclude")
93
150
  return;
94
151
  const path = pathOf(el, root);
152
+ const valueIsMasked = masksValue(el);
95
153
  if (decision === "record" || decision === "mask") {
96
- const valueIsMasked = masksValue(el);
97
154
  for (const attr of Array.from(el.attributes)) {
98
155
  const name = attr.name.toLowerCase();
99
156
  if (STRUCTURAL_ATTRS.has(name))
@@ -119,6 +176,22 @@ function collectRecordedStrings(root) {
119
176
  }
120
177
  for (const child of Array.from(el.children))
121
178
  visit(child);
179
+ if (el.shadowRoot) {
180
+ const shadowPath = path ? `${path} > >>>` : ">>>";
181
+ for (const child of Array.from(el.shadowRoot.childNodes)) {
182
+ if (child.nodeType === 1) {
183
+ visit(child);
184
+ continue;
185
+ }
186
+ if (child.nodeType !== 3)
187
+ continue;
188
+ if (decision !== "record" || valueIsMasked)
189
+ continue;
190
+ const text = (child.textContent ?? "").trim();
191
+ if (text)
192
+ out.push({ value: text, path: shadowPath, origin: "text" });
193
+ }
194
+ }
122
195
  };
123
196
  visit(root);
124
197
  return out;
@@ -400,7 +473,7 @@ class PharosBrowserClient {
400
473
  async startReplay(options = {}) {
401
474
  if (this.closed)
402
475
  return null;
403
- const { startReplayUpload } = await import("./wire-992wvzs1.js");
476
+ const { startReplayUpload } = await import("./wire-p2kvy6dd.js");
404
477
  if (this.closed)
405
478
  return null;
406
479
  const handle = startReplayUpload({
@@ -4,5 +4,16 @@ export interface RecordedString {
4
4
  origin: 'text' | 'value' | 'attribute';
5
5
  attribute?: string;
6
6
  }
7
+ type StructuralAttrJustification = {
8
+ readonly kind: 'closed-enumeration';
9
+ readonly claim: string;
10
+ readonly vocabulary: readonly string[];
11
+ } | {
12
+ readonly kind: 'cost-tradeoff';
13
+ readonly claim: string;
14
+ readonly meetsImpossibilityBar: false;
15
+ };
16
+ export declare const STRUCTURAL_ATTR_JUSTIFICATIONS: Readonly<Record<string, StructuralAttrJustification>>;
7
17
  export declare const STRUCTURAL_ATTRS: Set<string>;
8
18
  export declare function collectRecordedStrings(root: Element): RecordedString[];
19
+ export {};
@@ -43,6 +43,8 @@ export interface SerializedNode {
43
43
  tagName?: string;
44
44
  attributes?: Record<string, unknown>;
45
45
  childNodes?: SerializedNode[];
46
+ /** rrweb-snapshot's text payload — present on `type === SERIALIZED_TEXT`. */
47
+ textContent?: string;
46
48
  id?: number;
47
49
  [key: string]: unknown;
48
50
  }
@@ -26,7 +26,7 @@ import {
26
26
  resolveConfig,
27
27
  scrubAttribute,
28
28
  scrubUrl
29
- } from "./index-bc4bw3ba.js";
29
+ } from "./index-yabengpp.js";
30
30
  import"./index-c3taa3cg.js";
31
31
 
32
32
  // src/replay/recorder.ts
@@ -146,8 +146,34 @@ function isStyleDiff(value) {
146
146
  return typeof value === "object" && value !== null && !Array.isArray(value);
147
147
  }
148
148
  var SERIALIZED_ELEMENT = 2;
149
+ var SERIALIZED_TEXT = 3;
149
150
  var SOURCE_MUTATION = 0;
150
151
  var SOURCE_INPUT = 5;
152
+ function shadowHostOf(node) {
153
+ if (node && typeof ShadowRoot !== "undefined" && node instanceof ShadowRoot)
154
+ return node.host;
155
+ return null;
156
+ }
157
+ function governingElementOfTextNode(node) {
158
+ if (!node)
159
+ return null;
160
+ return node.parentElement ?? shadowHostOf(node.parentNode);
161
+ }
162
+ function governingElementOfParent(parent) {
163
+ if (!parent)
164
+ return null;
165
+ if (parent.nodeType === 1)
166
+ return parent;
167
+ return shadowHostOf(parent);
168
+ }
169
+ function addLandsInExcludedSubtree(parent) {
170
+ if (!parent)
171
+ return true;
172
+ const el = governingElementOfParent(parent);
173
+ if (el)
174
+ return decide(el) === "exclude";
175
+ return false;
176
+ }
151
177
  function shouldMaskTextOf(el) {
152
178
  if (!el)
153
179
  return true;
@@ -271,14 +297,33 @@ function scrubSerializedNode(node, doc) {
271
297
  scrubSerializedNode(child, doc);
272
298
  }
273
299
  }
300
+ function maskUnjudgedShadowText(node, ctx) {
301
+ const children = node.childNodes;
302
+ if (!children)
303
+ return;
304
+ let host;
305
+ for (const child of children) {
306
+ if (child.type === SERIALIZED_TEXT && child.isShadow === true && typeof child.textContent === "string") {
307
+ if (host === undefined) {
308
+ const live = typeof node.id === "number" ? ctx.resolveNode(node.id) : null;
309
+ host = live && live.nodeType === 1 ? live : null;
310
+ }
311
+ if (shouldMaskTextOf(host))
312
+ child.textContent = maskText(child.textContent);
313
+ }
314
+ maskUnjudgedShadowText(child, ctx);
315
+ }
316
+ }
274
317
  function scrubEventAttributes(event, ctx) {
275
318
  const e = event;
276
319
  const data = e.data;
277
320
  if (!data)
278
321
  return event;
279
322
  const node = data.node;
280
- if (node && typeof node === "object")
323
+ if (node && typeof node === "object") {
281
324
  scrubSerializedNode(node, ctx.doc);
325
+ maskUnjudgedShadowText(node, ctx);
326
+ }
282
327
  if (data.source === SOURCE_INPUT && typeof data.text === "string") {
283
328
  const live = ctx.resolveNode(data.id);
284
329
  const el = live && live.nodeType === 1 ? live : null;
@@ -286,11 +331,34 @@ function scrubEventAttributes(event, ctx) {
286
331
  }
287
332
  if (data.source !== SOURCE_MUTATION)
288
333
  return event;
334
+ const texts = data.texts;
335
+ if (Array.isArray(texts)) {
336
+ for (const entry of texts) {
337
+ if (typeof entry?.value !== "string")
338
+ continue;
339
+ if (shouldMaskTextOf(governingElementOfTextNode(ctx.resolveNode(entry.id)))) {
340
+ entry.value = maskText(entry.value);
341
+ }
342
+ }
343
+ }
289
344
  const adds = data.adds;
290
345
  if (Array.isArray(adds)) {
291
- for (const add of adds) {
292
- if (add?.node)
293
- scrubSerializedNode(add.node, ctx.doc);
346
+ for (let i = adds.length - 1;i >= 0; i--) {
347
+ const add = adds[i];
348
+ if (!add?.node)
349
+ continue;
350
+ const parent = typeof add.parentId === "number" ? ctx.resolveNode(add.parentId) : null;
351
+ if (addLandsInExcludedSubtree(parent)) {
352
+ adds.splice(i, 1);
353
+ continue;
354
+ }
355
+ if (add.node.type === SERIALIZED_TEXT && typeof add.node.textContent === "string") {
356
+ if (shouldMaskTextOf(governingElementOfParent(parent))) {
357
+ add.node.textContent = maskText(add.node.textContent);
358
+ }
359
+ }
360
+ scrubSerializedNode(add.node, ctx.doc);
361
+ maskUnjudgedShadowText(add.node, ctx);
294
362
  }
295
363
  }
296
364
  const attributes = data.attributes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loxel.dev/pharos-browser",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Browser client for Pharos — server-evaluated feature flags, error capture, and session replay.",
5
5
  "license": "MIT",
6
6
  "type": "module",