cyborg-hunter 0.5.0 → 0.7.2
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/CHANGELOG.md +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +43 -14
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/src/cli/renderers/tab-timeline.js +0 -149
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// src/replay/capture-dom.js
|
|
2
|
+
// Tier-2 ("dom") capture: initial-DOM serialization, MutationObserver →
|
|
3
|
+
// patch log, initial stylesheet capture, guard-friction pre-scramble hook.
|
|
4
|
+
//
|
|
5
|
+
// The serializer is a hand-rolled tree walker (clean-room; no dependency on
|
|
6
|
+
// outerHTML) so it can (a) strip/redact during the walk and (b) run against
|
|
7
|
+
// duck-typed fixture trees in node tests.
|
|
8
|
+
//
|
|
9
|
+
// Node addressing: a path is the list of childNodes indices from the capture
|
|
10
|
+
// root down to the node ([1,1] = root.childNodes[1].childNodes[1]). The
|
|
11
|
+
// viewer resolves paths against its reconstructed tree; paths are relative
|
|
12
|
+
// to the SNAPSHOT state at each point in the patch sequence, which holds as
|
|
13
|
+
// long as patches are applied in order from the initial DOM.
|
|
14
|
+
|
|
15
|
+
var ELEMENT_NODE = 1;
|
|
16
|
+
var TEXT_NODE = 3;
|
|
17
|
+
|
|
18
|
+
// Elements never serialized: executable or replay-irrelevant. IFRAME is NOT
|
|
19
|
+
// here — frames serialize as inert size-preserving placeholders (see
|
|
20
|
+
// serializeNode) so the surrounding layout doesn't collapse in the viewer.
|
|
21
|
+
var SKIP_TAGS = { SCRIPT: true, NOSCRIPT: true };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Serialization-stable node references. Child-index paths are not stable
|
|
25
|
+
* across HTML reparsing (the parser drops comments, merges text nodes, and
|
|
26
|
+
* inserts elements like <tbody>), so every serialized ELEMENT is stamped
|
|
27
|
+
* with a marker attribute instead. The attribute name embeds a per-recording
|
|
28
|
+
* nonce so page CSS written before the recording existed cannot target it
|
|
29
|
+
* ([data-chn-*]{display:none} attacks) and pre-existing page attributes
|
|
30
|
+
* cannot collide. Ids live in a WeakMap keyed by the LIVE node — the live
|
|
31
|
+
* DOM is never mutated (stamping real attributes would echo through the
|
|
32
|
+
* MutationObserver and interfere with the host page). The viewer harvests
|
|
33
|
+
* markers into an out-of-band map and strips them before any measured layout.
|
|
34
|
+
*/
|
|
35
|
+
export function createMarkerRegistry(nonce) {
|
|
36
|
+
var ids = new WeakMap();
|
|
37
|
+
var next = 1;
|
|
38
|
+
return {
|
|
39
|
+
attr: 'data-chn-' + nonce,
|
|
40
|
+
refFor: function (node) {
|
|
41
|
+
var n = ids.get(node);
|
|
42
|
+
if (n == null) { n = next++; ids.set(node, n); }
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Void elements per the HTML spec — serialized without a closing tag.
|
|
49
|
+
var VOID_TAGS = {
|
|
50
|
+
AREA: true, BASE: true, BR: true, COL: true, EMBED: true, HR: true,
|
|
51
|
+
IMG: true, INPUT: true, LINK: true, META: true, SOURCE: true,
|
|
52
|
+
TRACK: true, WBR: true
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Valid attribute-name token; also refuses event-handler attributes
|
|
56
|
+
// (on*) as defense-in-depth — the viewer sandbox+CSP already block inline
|
|
57
|
+
// handlers, but the serialized artifact should not carry them at all.
|
|
58
|
+
var ATTR_NAME_RE = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
|
59
|
+
function isSerializableAttr(name) {
|
|
60
|
+
return ATTR_NAME_RE.test(name) && !/^on/i.test(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function escapeHtml(s) {
|
|
64
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
65
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isBait(node, opts) {
|
|
69
|
+
if (opts && opts.keepBait) return false;
|
|
70
|
+
if (!node || node.nodeType !== ELEMENT_NODE) return false;
|
|
71
|
+
if (node.id === 'ch-decoy') return true;
|
|
72
|
+
var attrs = node.attributes || [];
|
|
73
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
74
|
+
if (attrs[i].name === 'data-ch-role') return true;
|
|
75
|
+
if (attrs[i].name === 'data-ch-decoy') return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isPassword(node) {
|
|
81
|
+
return node.tagName === 'INPUT' &&
|
|
82
|
+
(node.type === 'password' || hasAttr(node, 'type', 'password'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// True when the node should be redacted in the DOM capture: a password field
|
|
86
|
+
// (always) or anything matching the configured redactSelector. Without this the
|
|
87
|
+
// DOM path (initial snapshot, value attributes, characterData/childList patches)
|
|
88
|
+
// leaked the content of fields the researcher explicitly marked for redaction,
|
|
89
|
+
// silently defeating redactSelector for everything except the RAF-coalesced
|
|
90
|
+
// input-value events.
|
|
91
|
+
function isRedacted(node, opts) {
|
|
92
|
+
if (!node) return false;
|
|
93
|
+
if (isPassword(node)) return true;
|
|
94
|
+
var sel = opts && opts.redactSelector;
|
|
95
|
+
if (!sel || typeof node.matches !== 'function') return false;
|
|
96
|
+
try { return node.matches(sel); } catch (e) { return false; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// True when the node is inside (or is) a redacted element. Walks ancestors so
|
|
100
|
+
// that mutation targets which are TEXT NODES (characterData records, added text
|
|
101
|
+
// nodes) — which have no matches() of their own — are correctly redacted when
|
|
102
|
+
// their containing element is. Without the walk, characterData on a redacted
|
|
103
|
+
// contenteditable would leak the typed text through mutation patches.
|
|
104
|
+
function isInRedactedSubtree(node, opts) {
|
|
105
|
+
var cur = node;
|
|
106
|
+
while (cur) {
|
|
107
|
+
if (cur.nodeType === ELEMENT_NODE && isRedacted(cur, opts)) return true;
|
|
108
|
+
cur = cur.parentNode;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasAttr(node, name, value) {
|
|
114
|
+
var attrs = node.attributes || [];
|
|
115
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
116
|
+
if (attrs[i].name === name) return value == null || attrs[i].value === value;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Serializes a DOM subtree to an HTML string.
|
|
123
|
+
* - strips scripts/iframes and (by default) honeypot/decoy nodes
|
|
124
|
+
* - prefers the `src` PROPERTY for images (always absolute) over the
|
|
125
|
+
* attribute (may be relative and unresolvable inside the viewer's srcdoc)
|
|
126
|
+
* - redacts password input values (marker attribute, no content)
|
|
127
|
+
*/
|
|
128
|
+
export function serializeDom(root, opts) {
|
|
129
|
+
opts = opts || {};
|
|
130
|
+
var out = [];
|
|
131
|
+
serializeNode(root, opts, out);
|
|
132
|
+
return out.join('');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Iframe → inert placeholder. Child documents are never captured (their
|
|
136
|
+
// events don't bubble to this document and the srcdoc CSP blocks frames
|
|
137
|
+
// anyway), but dropping the element entirely would collapse the surrounding
|
|
138
|
+
// layout and silently shift every element below it. The placeholder keeps
|
|
139
|
+
// the recorded footprint; the viewer shows a per-trial "iframe content not
|
|
140
|
+
// captured" warning when one is present.
|
|
141
|
+
// Current footprint of an iframe, for the placeholder and for footprint
|
|
142
|
+
// re-sync patches when the iframe's attributes later mutate.
|
|
143
|
+
function iframeFootprint(node) {
|
|
144
|
+
var w = null, h = null;
|
|
145
|
+
try {
|
|
146
|
+
if (typeof node.getBoundingClientRect === 'function') {
|
|
147
|
+
var r = node.getBoundingClientRect();
|
|
148
|
+
// A 0×0 rect is a VALID footprint (hidden iframe) — the placeholder
|
|
149
|
+
// must reproduce it, not invent a default-size box that shifts layout.
|
|
150
|
+
if (r) { w = Math.round(r.width || 0); h = Math.round(r.height || 0); }
|
|
151
|
+
}
|
|
152
|
+
} catch (e) { /* fall through to attributes */ }
|
|
153
|
+
if (w == null) {
|
|
154
|
+
w = parseInt(hasAttr(node, 'width') ? getAttrValue(node, 'width') : '', 10);
|
|
155
|
+
h = parseInt(hasAttr(node, 'height') ? getAttrValue(node, 'height') : '', 10);
|
|
156
|
+
if (!(w > 0)) w = 300; // spec default iframe size
|
|
157
|
+
if (!(h > 0)) h = 150;
|
|
158
|
+
}
|
|
159
|
+
return { w: w, h: h };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Layout-affecting computed properties copied onto the placeholder so an
|
|
163
|
+
// absolutely-positioned / block / floated / margined iframe doesn't collapse
|
|
164
|
+
// into a plain in-flow inline box and shift everything around it.
|
|
165
|
+
var IFRAME_LAYOUT_PROPS = ['position', 'top', 'right', 'bottom', 'left',
|
|
166
|
+
'margin', 'float', 'vertical-align', 'z-index'];
|
|
167
|
+
|
|
168
|
+
function iframeFootprintStyle(node) {
|
|
169
|
+
var f = iframeFootprint(node);
|
|
170
|
+
var css = '';
|
|
171
|
+
var display = 'inline-block';
|
|
172
|
+
try {
|
|
173
|
+
var view = node.ownerDocument && node.ownerDocument.defaultView;
|
|
174
|
+
if (view && typeof view.getComputedStyle === 'function') {
|
|
175
|
+
var cs = view.getComputedStyle(node);
|
|
176
|
+
// display: keep the computed value except 'inline' — a span needs
|
|
177
|
+
// inline-block (or stronger) to honor explicit width/height.
|
|
178
|
+
if (cs.display && cs.display !== 'inline') display = cs.display;
|
|
179
|
+
for (var i = 0; i < IFRAME_LAYOUT_PROPS.length; i++) {
|
|
180
|
+
var prop = IFRAME_LAYOUT_PROPS[i];
|
|
181
|
+
var v = cs.getPropertyValue(prop);
|
|
182
|
+
if (v && v !== 'auto' && v !== 'none' && v !== 'normal' &&
|
|
183
|
+
v !== 'baseline' && v !== '0px') {
|
|
184
|
+
css += prop + ':' + v + ';';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (e) { /* fall back to the plain inline-block footprint */ }
|
|
189
|
+
return 'display:' + display + ';' + css +
|
|
190
|
+
'width:' + f.w + 'px;height:' + f.h + 'px';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function serializeIframePlaceholder(node, opts, out) {
|
|
194
|
+
// <span>, not <div>: iframes are phrasing content, so the placeholder must
|
|
195
|
+
// be too — a div inside <p> would trigger parser reparenting (implicit </p>)
|
|
196
|
+
// and shift the reconstructed layout the placeholder exists to preserve.
|
|
197
|
+
out.push('<span data-ch-iframe=""');
|
|
198
|
+
if (opts.markers) {
|
|
199
|
+
out.push(' ' + opts.markers.attr + '="' + opts.markers.refFor(node) + '"');
|
|
200
|
+
}
|
|
201
|
+
out.push(' style="' + iframeFootprintStyle(node) + '"></span>');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getAttrValue(node, name) {
|
|
205
|
+
var attrs = node.attributes || [];
|
|
206
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
207
|
+
if (attrs[i].name === name) return attrs[i].value;
|
|
208
|
+
}
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function serializeNode(node, opts, out) {
|
|
213
|
+
if (!node) return;
|
|
214
|
+
if (node.nodeType === TEXT_NODE) {
|
|
215
|
+
out.push(escapeHtml(node.textContent || ''));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (node.nodeType !== ELEMENT_NODE) return; // comments, PIs: irrelevant
|
|
219
|
+
var tag = node.tagName;
|
|
220
|
+
if (SKIP_TAGS[tag]) return;
|
|
221
|
+
if (isBait(node, opts)) return;
|
|
222
|
+
if (tag === 'IFRAME') { serializeIframePlaceholder(node, opts, out); return; }
|
|
223
|
+
|
|
224
|
+
var lower = tag.toLowerCase();
|
|
225
|
+
out.push('<' + lower);
|
|
226
|
+
if (opts.markers) {
|
|
227
|
+
out.push(' ' + opts.markers.attr + '="' + opts.markers.refFor(node) + '"');
|
|
228
|
+
}
|
|
229
|
+
// Shadow roots are not serializable from the outside: the host's box
|
|
230
|
+
// survives but its rendered content does not. Mark it so the viewer can
|
|
231
|
+
// refuse to "verify" interactions against a hollow reconstruction.
|
|
232
|
+
if (node.shadowRoot) out.push(' data-ch-shadow=""');
|
|
233
|
+
|
|
234
|
+
var redactValue = isRedacted(node, opts);
|
|
235
|
+
var srcOverride = (tag === 'IMG' && node.src) ? node.src : null;
|
|
236
|
+
var wroteSrc = false;
|
|
237
|
+
|
|
238
|
+
var attrs = node.attributes || [];
|
|
239
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
240
|
+
var name = attrs[i].name;
|
|
241
|
+
var value = attrs[i].value;
|
|
242
|
+
if (!isSerializableAttr(name)) continue;
|
|
243
|
+
if (name === 'value' && redactValue) continue;
|
|
244
|
+
if (name === 'src' && srcOverride) { value = srcOverride; wroteSrc = true; }
|
|
245
|
+
out.push(' ' + name + '="' + escapeHtml(value) + '"');
|
|
246
|
+
}
|
|
247
|
+
if (srcOverride && !wroteSrc) out.push(' src="' + escapeHtml(srcOverride) + '"');
|
|
248
|
+
if (redactValue) out.push(' data-ch-redacted="true"');
|
|
249
|
+
out.push('>');
|
|
250
|
+
|
|
251
|
+
if (!VOID_TAGS[tag]) {
|
|
252
|
+
// A redacted element's text children (e.g. contenteditable content) are
|
|
253
|
+
// withheld too — otherwise typed text under a redactSelector match would
|
|
254
|
+
// leak straight into the snapshot despite the marker on the element.
|
|
255
|
+
if (!redactValue) {
|
|
256
|
+
var kids = node.childNodes || [];
|
|
257
|
+
for (var k = 0; k < kids.length; k++) serializeNode(kids[k], opts, out);
|
|
258
|
+
}
|
|
259
|
+
out.push('</' + lower + '>');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Child-index path from root to node; [] for the root itself; null if the
|
|
265
|
+
* node is not under the root (e.g. extension-injected DOM outside the
|
|
266
|
+
* experiment container).
|
|
267
|
+
*/
|
|
268
|
+
export function nodePath(node, root) {
|
|
269
|
+
var path = [];
|
|
270
|
+
var cur = node;
|
|
271
|
+
while (cur && cur !== root) {
|
|
272
|
+
var parent = cur.parentNode;
|
|
273
|
+
if (!parent) return null;
|
|
274
|
+
var idx = indexOfChild(parent, cur);
|
|
275
|
+
if (idx === -1) return null;
|
|
276
|
+
path.unshift(idx);
|
|
277
|
+
cur = parent;
|
|
278
|
+
}
|
|
279
|
+
return cur === root ? path : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function indexOfChild(parent, child) {
|
|
283
|
+
var kids = parent.childNodes || [];
|
|
284
|
+
for (var i = 0; i < kids.length; i++) if (kids[i] === child) return i;
|
|
285
|
+
return -1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Translates one MutationRecord into a JSON patch entry, or null when the
|
|
290
|
+
* mutation should not be recorded (bait subtree, node outside root).
|
|
291
|
+
*/
|
|
292
|
+
export function mutationToPatch(record, root, opts) {
|
|
293
|
+
var target = record.target;
|
|
294
|
+
// Walk up: mutations inside bait subtrees are never recorded.
|
|
295
|
+
var cur = target;
|
|
296
|
+
while (cur) {
|
|
297
|
+
if (isBait(cur, opts)) return null;
|
|
298
|
+
cur = cur.parentNode;
|
|
299
|
+
}
|
|
300
|
+
// With a marker registry, characterData mutations are recorded as the
|
|
301
|
+
// PARENT's resulting-children snapshot: text nodes are not parser-stable
|
|
302
|
+
// references (adjacent text nodes merge on reparse; empty ones vanish), so
|
|
303
|
+
// a text-node address can resolve to the wrong node in the reconstruction.
|
|
304
|
+
// The parent element IS stable via its marker, and a children snapshot is
|
|
305
|
+
// idempotent like every other childList patch.
|
|
306
|
+
if (record.type === 'characterData' && opts.markers) {
|
|
307
|
+
var parent = target.parentNode;
|
|
308
|
+
if (!parent || nodePath(parent, root) === null) return null;
|
|
309
|
+
record = { type: 'childList', target: parent };
|
|
310
|
+
target = parent;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
var path = nodePath(target, root);
|
|
314
|
+
if (path === null) return null;
|
|
315
|
+
|
|
316
|
+
// Marker reference: the parser-stable address the viewer resolves first;
|
|
317
|
+
// the child-index path stays as a diagnostic fallback. `tag` is the tag
|
|
318
|
+
// EXPECTED IN THE RECONSTRUCTION (that's what resolution validates) — for
|
|
319
|
+
// iframes that is the placeholder span, not the source tag.
|
|
320
|
+
var ref = opts.markers && target.nodeType === ELEMENT_NODE
|
|
321
|
+
? { n: opts.markers.refFor(target),
|
|
322
|
+
tag: target.tagName === 'IFRAME' ? 'span' : target.tagName.toLowerCase() }
|
|
323
|
+
: null;
|
|
324
|
+
|
|
325
|
+
if (record.type === 'childList') {
|
|
326
|
+
// State-snapshot patch: removed nodes are already detached (they have
|
|
327
|
+
// no address), so a faithful add/remove log is impossible. Serializing
|
|
328
|
+
// the target's RESULTING children makes application an idempotent
|
|
329
|
+
// innerHTML assignment — and backward seeks just rebuild from the
|
|
330
|
+
// initial DOM and re-apply patches in order.
|
|
331
|
+
//
|
|
332
|
+
// A childList mutation on (or inside) a redacted element emits empty
|
|
333
|
+
// children: serializeChildren walks the target's children directly, so it
|
|
334
|
+
// would otherwise leak text/inputs that serializeNode hides when it reaches
|
|
335
|
+
// the redacted element itself.
|
|
336
|
+
return Object.assign({
|
|
337
|
+
op: 'childList',
|
|
338
|
+
path: path,
|
|
339
|
+
html: isInRedactedSubtree(target, opts) ? '' : serializeChildren(target, opts)
|
|
340
|
+
}, ref || {});
|
|
341
|
+
}
|
|
342
|
+
if (record.type === 'attributes') {
|
|
343
|
+
// The reconstruction holds a placeholder SPAN where the iframe was, and
|
|
344
|
+
// width/height ATTRIBUTES don't size a span — so any attribute change on
|
|
345
|
+
// an iframe is translated into a fresh footprint style patch instead of
|
|
346
|
+
// forwarding an attribute the placeholder can't honor.
|
|
347
|
+
if (target.tagName === 'IFRAME') {
|
|
348
|
+
return Object.assign(
|
|
349
|
+
{ op: 'attributes', path: path, name: 'style', value: iframeFootprintStyle(target) },
|
|
350
|
+
ref || {});
|
|
351
|
+
}
|
|
352
|
+
var name = record.attributeName;
|
|
353
|
+
if (!isSerializableAttr(name)) return null;
|
|
354
|
+
var value = typeof target.getAttribute === 'function'
|
|
355
|
+
? target.getAttribute(name) : null;
|
|
356
|
+
// Never leak the value attribute of a redacted field (password or
|
|
357
|
+
// redactSelector match, including via a redacted ancestor).
|
|
358
|
+
if (name === 'value' && isInRedactedSubtree(target, opts)) value = null;
|
|
359
|
+
return Object.assign({ op: 'attributes', path: path, name: name, value: value }, ref || {});
|
|
360
|
+
}
|
|
361
|
+
if (record.type === 'characterData') {
|
|
362
|
+
// characterData targets are TEXT NODES, so redaction must consult the
|
|
363
|
+
// containing element(s): typing into a redacted contenteditable must not
|
|
364
|
+
// carry the typed text through the patch.
|
|
365
|
+
var text = isInRedactedSubtree(target, opts) ? '' : (target.textContent || '');
|
|
366
|
+
return { op: 'characterData', path: path, value: text };
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Serializes only the CHILDREN of a node (innerHTML semantics) — used by
|
|
372
|
+
// the childList state-snapshot patch.
|
|
373
|
+
function serializeChildren(node, opts) {
|
|
374
|
+
var out = [];
|
|
375
|
+
var kids = node.childNodes || [];
|
|
376
|
+
for (var i = 0; i < kids.length; i++) serializeNode(kids[i], opts, out);
|
|
377
|
+
return out.join('');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Initial stylesheet capture: css text where readable, href fallback for
|
|
382
|
+
* cross-origin sheets (viewer inlines css; hrefs render as absolute links).
|
|
383
|
+
*/
|
|
384
|
+
export function captureStylesheets(doc) {
|
|
385
|
+
var out = [];
|
|
386
|
+
var sheets = (doc && doc.styleSheets) || [];
|
|
387
|
+
for (var i = 0; i < sheets.length; i++) {
|
|
388
|
+
var sheet = sheets[i];
|
|
389
|
+
try {
|
|
390
|
+
var rules = sheet.cssRules;
|
|
391
|
+
var css = [];
|
|
392
|
+
for (var r = 0; r < rules.length; r++) css.push(rules[r].cssText);
|
|
393
|
+
out.push({ css: css.join('\n') });
|
|
394
|
+
} catch (e) {
|
|
395
|
+
// Cross-origin sheet: record the (absolute) href so the viewer can
|
|
396
|
+
// at least link it; content is unreadable from this origin.
|
|
397
|
+
out.push({ href: sheet.href || null });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Attaches tier-2 capture to a recorder:
|
|
405
|
+
* - snapshots initial DOM + stylesheets at session start and at each
|
|
406
|
+
* startTrial (recorder calls snapshotTrial via the returned hooks)
|
|
407
|
+
* - MutationObserver → 'mutation' events
|
|
408
|
+
* - guard-friction cooperation: onViolation('start') fires synchronously
|
|
409
|
+
* BEFORE obfuscateContent() (pinned by contract test), so the clean-DOM
|
|
410
|
+
* snapshot taken inside the callback is genuinely pre-scramble.
|
|
411
|
+
*/
|
|
412
|
+
export function attachDomCapture(rec, env) {
|
|
413
|
+
env = env || {};
|
|
414
|
+
var doc = env.doc || document;
|
|
415
|
+
var win = env.win || window;
|
|
416
|
+
var now = env.now || function () { return performance.now(); };
|
|
417
|
+
var MutationObserverImpl = env.MutationObserver ||
|
|
418
|
+
(typeof MutationObserver !== 'undefined' ? MutationObserver : null);
|
|
419
|
+
// Per-recording marker nonce (see createMarkerRegistry). Registered on the
|
|
420
|
+
// recorder so capture-trace can reference the same registry for interaction
|
|
421
|
+
// anchors and the serializer can emit the attribute name on the wire.
|
|
422
|
+
var markers = createMarkerRegistry(
|
|
423
|
+
(Math.random().toString(16).slice(2, 10) || 'fallback0'));
|
|
424
|
+
if (typeof rec.setMarkers === 'function') {
|
|
425
|
+
rec.setMarkers(markers);
|
|
426
|
+
rec.setMarkerAttr(markers.attr);
|
|
427
|
+
}
|
|
428
|
+
var opts = { keepBait: rec.config.keepBait, redactSelector: rec.config.redactSelector,
|
|
429
|
+
markers: markers };
|
|
430
|
+
|
|
431
|
+
function resolveRoot() {
|
|
432
|
+
var r = rec.config.root;
|
|
433
|
+
if (typeof r === 'string') {
|
|
434
|
+
try { return doc.querySelector(r) || doc.body; } catch (e) { return doc.body; }
|
|
435
|
+
}
|
|
436
|
+
return r || doc.body;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
rec.setStylesheets(captureStylesheets(doc));
|
|
441
|
+
} catch (e) { rec.captureFailure('stylesheets', e); }
|
|
442
|
+
|
|
443
|
+
// Snapshot the initial DOM at the start of every trial (explicit or
|
|
444
|
+
// implicit) — this is the frame the viewer reconstructs and patches.
|
|
445
|
+
//
|
|
446
|
+
// The initial snapshot is the single largest capture source and is stored on
|
|
447
|
+
// the trial (not pushed as an event), so the recorder's per-event size cap
|
|
448
|
+
// can't see it. Enforce the cap HERE at assignment: a snapshot longer than
|
|
449
|
+
// maxCharsPerTrial is dropped (with a captureFailure) rather than retained,
|
|
450
|
+
// so a giant DOM can't bypass the resource/payload limit. A dropped snapshot
|
|
451
|
+
// makes that trial's replay show "no DOM captured" rather than blowing up.
|
|
452
|
+
rec.onTrialStart(function (trial) {
|
|
453
|
+
try {
|
|
454
|
+
var html = serializeDom(resolveRoot(), opts);
|
|
455
|
+
var cap = rec.config.maxCharsPerTrial;
|
|
456
|
+
if (cap != null && html.length > cap) {
|
|
457
|
+
rec.captureFailure('dom_snapshot', new Error(
|
|
458
|
+
'initial DOM snapshot length ' + html.length + ' exceeds maxCharsPerTrial ' +
|
|
459
|
+
cap + '; dropped to bound payload size'));
|
|
460
|
+
trial.initialDom = '';
|
|
461
|
+
} else {
|
|
462
|
+
trial.initialDom = html;
|
|
463
|
+
}
|
|
464
|
+
} catch (e) {
|
|
465
|
+
rec.captureFailure('dom_snapshot', e);
|
|
466
|
+
trial.initialDom = '';
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (MutationObserverImpl) {
|
|
471
|
+
var observer = new MutationObserverImpl(function (records) {
|
|
472
|
+
try {
|
|
473
|
+
var root = resolveRoot();
|
|
474
|
+
// Intra-batch dedup: ONE childList patch per TARGET NODE, emitted at
|
|
475
|
+
// the target's FIRST record position. A single task appending N
|
|
476
|
+
// children to one container delivers N childList records, and each
|
|
477
|
+
// patch serializes the target's FULL resulting children — O(N²)
|
|
478
|
+
// work on the participant's machine without dedup. Serialization
|
|
479
|
+
// happens at callback time (final state), so every record for a
|
|
480
|
+
// target would carry identical html — only ORDER matters, and
|
|
481
|
+
// first-occurrence order guarantees a node created in-batch has its
|
|
482
|
+
// ancestor's creating patch emitted BEFORE any patch targeting the
|
|
483
|
+
// new node (observer records are chronological). Keyed by node
|
|
484
|
+
// IDENTITY, not path (path-keyed dedup was refuted: same-batch
|
|
485
|
+
// index aliasing). attributes/characterData records never skipped.
|
|
486
|
+
// Dedup key = the EFFECTIVE childList target: in marker mode,
|
|
487
|
+
// characterData records become parent-level childList snapshots
|
|
488
|
+
// inside mutationToPatch, so N text rewrites on one node are the
|
|
489
|
+
// same O(N²) storm as N appends and must dedup on the PARENT.
|
|
490
|
+
var seenChildListTargets = new Set();
|
|
491
|
+
var effectiveChildListTarget = function (record) {
|
|
492
|
+
if (record.type === 'childList') return record.target;
|
|
493
|
+
if (record.type === 'characterData' && opts.markers &&
|
|
494
|
+
record.target && record.target.parentNode) {
|
|
495
|
+
return record.target.parentNode;
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
};
|
|
499
|
+
for (var i = 0; i < records.length; i++) {
|
|
500
|
+
var key = effectiveChildListTarget(records[i]);
|
|
501
|
+
if (key) {
|
|
502
|
+
if (seenChildListTargets.has(key)) continue;
|
|
503
|
+
seenChildListTargets.add(key);
|
|
504
|
+
}
|
|
505
|
+
var patch = mutationToPatch(records[i], root, opts);
|
|
506
|
+
if (patch) rec.pushEvent('mutation', patch, now());
|
|
507
|
+
}
|
|
508
|
+
} catch (e) { rec.captureFailure('mutations', e); }
|
|
509
|
+
});
|
|
510
|
+
try {
|
|
511
|
+
observer.observe(resolveRoot(), {
|
|
512
|
+
childList: true, attributes: true, characterData: true, subtree: true
|
|
513
|
+
});
|
|
514
|
+
// Registered through the listener registry with the observer marker so
|
|
515
|
+
// recorder.destroy() disconnects it (same convention as core monitor).
|
|
516
|
+
rec.addListener(
|
|
517
|
+
{ addEventListener: function () {}, removeEventListener: function () {} },
|
|
518
|
+
'_mutation_observer', observer, { _isObserver: true });
|
|
519
|
+
} catch (e) { rec.captureFailure('mutations', e); }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Guard-friction cooperation: snapshot the clean DOM synchronously when a
|
|
523
|
+
// violation starts (before friction scrambles content), so the analyst can
|
|
524
|
+
// see exactly what the participant saw at the moment of violation.
|
|
525
|
+
if (win.GuardFriction && typeof win.GuardFriction.onViolation === 'function') {
|
|
526
|
+
try {
|
|
527
|
+
// Late-subscription hardening: if a violation is ALREADY in progress
|
|
528
|
+
// when replay attaches (friction started first — e.g. standalone
|
|
529
|
+
// wiring order, or a participant who was out of fullscreen from the
|
|
530
|
+
// very beginning), its phase:'start' emission is long gone. Synthesize
|
|
531
|
+
// it from friction's current state so the recording never silently
|
|
532
|
+
// misses an ongoing violation.
|
|
533
|
+
// Local invariant guard: assembly (index.js) only attaches captures
|
|
534
|
+
// after startSession(), but make that self-evident here — synthesize
|
|
535
|
+
// only when the recorder is actually recording, so no wiring change
|
|
536
|
+
// can ever route this through a pre-session pushEvent.
|
|
537
|
+
var recState = rec.getState().state;
|
|
538
|
+
if ((recState === 'session' || recState === 'trial') &&
|
|
539
|
+
typeof win.GuardFriction.getCurrentState === 'function') {
|
|
540
|
+
var gs = win.GuardFriction.getCurrentState();
|
|
541
|
+
if (gs && gs.in_violation) {
|
|
542
|
+
rec.pushEvent('ch:guard_violation', {
|
|
543
|
+
reason: gs.current_reason || 'unknown',
|
|
544
|
+
phase: 'start',
|
|
545
|
+
synthesized_at_subscribe: true,
|
|
546
|
+
// NOT pre_scramble_dom: in enforcement mode the page may already
|
|
547
|
+
// be scrambled by the time we subscribe — this snapshot is
|
|
548
|
+
// whatever the DOM looks like right now, and its name must not
|
|
549
|
+
// overclaim (an analyst could otherwise read scrambled content
|
|
550
|
+
// as what the participant "really saw" pre-violation).
|
|
551
|
+
dom_at_subscribe: serializeDom(resolveRoot(), opts)
|
|
552
|
+
}, now());
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
win.GuardFriction.onViolation(function (violation) {
|
|
556
|
+
try {
|
|
557
|
+
if (violation && violation.phase === 'start') {
|
|
558
|
+
rec.pushEvent('ch:guard_violation', {
|
|
559
|
+
reason: violation.reason,
|
|
560
|
+
phase: 'start',
|
|
561
|
+
pre_scramble_dom: serializeDom(resolveRoot(), opts)
|
|
562
|
+
}, now());
|
|
563
|
+
} else if (violation && violation.phase) {
|
|
564
|
+
rec.pushEvent('ch:guard_violation', {
|
|
565
|
+
reason: violation.reason, phase: violation.phase,
|
|
566
|
+
duration_ms: violation.duration != null ? violation.duration : null
|
|
567
|
+
}, now());
|
|
568
|
+
}
|
|
569
|
+
} catch (e) { rec.captureFailure('guard_violation', e); }
|
|
570
|
+
});
|
|
571
|
+
} catch (e) { rec.captureFailure('guard_violation', e); }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// (No return value: all wiring goes through recorder hooks.)
|
|
575
|
+
}
|