@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
|
-
|
|
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-
|
|
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
|
|
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 (
|
|
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-
|
|
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-
|
|
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 (
|
|
292
|
-
|
|
293
|
-
|
|
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