UNPKG

humanbehavior-js

Version:

SDK for HumanBehavior session and event recording

521 lines (452 loc) 19.9 kB
// Redaction functionality for sensitive input fields // This module provides methods to configure rrweb's built-in masking // Uses CSS selectors and classes for reliable redaction without event corruption import { logDebug, logWarn } from './utils/logger'; // Check if we're in a browser environment const isBrowser = typeof window !== 'undefined'; export interface RedactionOptions { redactedText?: string; excludeSelectors?: string[]; userFields?: string[]; // Fields that the user wants to redact } export class RedactionManager { private redactedText: string = '[REDACTED]'; private userSelectedFields: Set<string> = new Set(); // User-selected fields to redact private excludeSelectors: string[] = [ '[data-no-redact="true"]', '.human-behavior-no-redact' ]; constructor(options?: RedactionOptions) { if (options?.redactedText) { this.redactedText = options.redactedText; } if (options?.excludeSelectors) { this.excludeSelectors = [...this.excludeSelectors, ...options.excludeSelectors]; } if (options?.userFields) { this.setFieldsToRedact(options.userFields); } } /** * Set specific fields to be redacted using CSS selectors * These selectors are used to configure rrweb's built-in masking * @param fields Array of CSS selectors for fields to redact */ public setFieldsToRedact(fields: string[]): void { this.userSelectedFields.clear(); fields.forEach(field => this.userSelectedFields.add(field)); if (fields.length > 0) { logDebug(`Redaction: Active for ${fields.length} field(s):`, fields); // Debug: Check if elements exist fields.forEach(selector => { const elements = document.querySelectorAll(selector); logDebug(`Redaction: Found ${elements.length} element(s) for selector '${selector}'`); elements.forEach((el, index) => { logDebug(`Redaction: Element ${index} for '${selector}':`, el); }); }); } else { logDebug('Redaction: Disabled - no fields selected'); } } /** * Check if redaction is currently active (has fields selected) */ public isActive(): boolean { return this.userSelectedFields.size > 0; } /** * Get the currently selected fields for redaction */ public getSelectedFields(): string[] { return Array.from(this.userSelectedFields); } /** * Process an event and redact sensitive data if needed * NOTE: This method is no longer used - events are handled directly by rrweb * Kept for backward compatibility but not called in the current implementation */ public processEvent(event: any): any { // Only process if we have fields selected for redaction if (this.userSelectedFields.size === 0) { return event; } // Clone the event to avoid modifying the original const processedEvent = JSON.parse(JSON.stringify(event)); // Handle different event types if (processedEvent.type === 3) { // IncrementalSnapshot if (processedEvent.data.source === 5) { // Input event const shouldRedact = this.isFieldSelected(processedEvent.data); if (shouldRedact) { logDebug('Redaction: Processing input event for redaction'); this.redactInputEvent(processedEvent.data); } } // Also check for other sources that might contain text changes else if (processedEvent.data.source === 0) { // DOM mutations this.redactDOMEvent(processedEvent.data); } // Handle other sources that might contain text else if (processedEvent.data.source === 2) { // Mouse/Touch interaction this.redactMouseEvent(processedEvent.data); } } else if (processedEvent.type === 2) { // FullSnapshot this.redactFullSnapshot(processedEvent.data); } return processedEvent; } /** * Redact sensitive data in input events */ private redactInputEvent(inputData: any): void { // Check if this input event is from a field we want to redact if (!this.isFieldSelected(inputData)) { return; } logDebug('Redaction: Redacting input event with text:', inputData.text); // Redact all text-related properties that could contain input data const textProperties = ['text', 'value', 'content', 'data', 'input', 'textContent']; textProperties.forEach(prop => { if (inputData[prop] !== undefined && typeof inputData[prop] === 'string') { inputData[prop] = this.redactedText; logDebug(`Redaction: Redacted property '${prop}'`); } }); // Also check for any other string properties that might contain input data Object.keys(inputData).forEach(key => { if (typeof inputData[key] === 'string' && inputData[key].length > 0) { inputData[key] = this.redactedText; logDebug(`Redaction: Redacted additional property '${key}'`); } }); // Handle nested objects that might contain text data if (inputData.attributes && typeof inputData.attributes === 'object') { if (inputData.attributes.value && typeof inputData.attributes.value === 'string') { inputData.attributes.value = this.redactedText; logDebug('Redaction: Redacted nested value attribute'); } } logDebug('Redaction: Input event redaction complete'); } /** * Redact sensitive data in DOM mutation events */ private redactDOMEvent(domData: any): void { // Check for text changes in DOM mutations if (domData.texts && Array.isArray(domData.texts)) { domData.texts.forEach((textChange: any) => { if (textChange.text && typeof textChange.text === 'string' && this.shouldRedactDOMChange(textChange)) { textChange.text = this.redactedText; } }); } // Also check for attribute changes that might contain input data if (domData.attributes && Array.isArray(domData.attributes)) { domData.attributes.forEach((attrChange: any) => { if (attrChange.attributes && attrChange.attributes.value && typeof attrChange.attributes.value === 'string' && this.shouldRedactDOMChange(attrChange)) { attrChange.attributes.value = this.redactedText; } }); } // Check for any other properties that might contain text data if (domData.adds && Array.isArray(domData.adds)) { domData.adds.forEach((add: any) => { if (add.node && add.node.textContent && typeof add.node.textContent === 'string' && this.shouldRedactDOMChange(add)) { add.node.textContent = this.redactedText; } }); } } /** * Check if a DOM change should be redacted based on its ID */ private shouldRedactDOMChange(changeData: any): boolean { if (!isBrowser) return false; try { // Check if this change has an ID that we can use to find the element const elementId = changeData.id; if (elementId !== undefined) { // Try to find the element by data-rrweb-id attribute let element = document.querySelector(`[data-rrweb-id="${elementId}"]`) as HTMLElement; if (element) { return this.shouldRedactElement(element); } } // Also check for nodeId which is another way rrweb identifies elements const nodeId = changeData.nodeId; if (nodeId !== undefined) { const element = document.querySelector(`[data-rrweb-id="${nodeId}"]`) as HTMLElement; if (element) { return this.shouldRedactElement(element); } } return false; } catch (e) { logWarn('Error checking if DOM change should be redacted:', e); return false; } } /** * Redact sensitive data in mouse/touch interaction events */ private redactMouseEvent(mouseData: any): void { // Mouse events typically don't contain text data, but check for any text properties if (mouseData.text && typeof mouseData.text === 'string' && this.isFieldSelected(mouseData)) { mouseData.text = this.redactedText; } } /** * Redact sensitive data in full snapshot events */ private redactFullSnapshot(snapshotData: any): void { if (snapshotData.node && snapshotData.node.type === 2) { // Element node this.redactNode(snapshotData.node); } } /** * Recursively redact sensitive data in DOM nodes */ private redactNode(node: any): void { if (!node) return; // Check if this node should be redacted if (node.type === 2 && node.tagName && (node.tagName.toLowerCase() === 'input' || node.tagName.toLowerCase() === 'textarea')) { // Check if this input/textarea should be redacted if (this.shouldRedactNode(node)) { // Redact value attribute if (node.attributes && node.attributes.value) { node.attributes.value = this.redactedText; } // Redact text content if (node.textContent) { node.textContent = this.redactedText; } } } // Recursively process child nodes if (node.childNodes && Array.isArray(node.childNodes)) { node.childNodes.forEach((childNode: any) => { this.redactNode(childNode); }); } } /** * Check if a node should be redacted based on its attributes */ private shouldRedactNode(node: any): boolean { if (!node.attributes) return false; // Check if any of our selectors would match this node for (const selector of this.userSelectedFields) { if (this.selectorMatchesNode(selector, node)) { return true; } } return false; } /** * Check if a CSS selector would match a node based on its attributes */ private selectorMatchesNode(selector: string, node: any): boolean { if (!node.attributes) return false; // Create a temporary element to test the selector try { const tempElement = document.createElement(node.tagName || 'div'); // Copy attributes from the node to the temp element if (node.attributes) { Object.keys(node.attributes).forEach(key => { tempElement.setAttribute(key, node.attributes[key]); }); } // Test if the selector matches this element return tempElement.matches(selector); } catch (e) { // If matches() is not supported or fails, fall back to basic attribute checking return this.basicSelectorMatch(selector, node); } } /** * Basic selector matching for environments where matches() is not available */ private basicSelectorMatch(selector: string, node: any): boolean { if (!node.attributes) return false; // Handle simple selectors like 'input[type="password"]' if (selector.includes('input[type=')) { const typeMatch = selector.match(/input\[type="([^"]+)"\]/); if (typeMatch && node.tagName === 'input' && node.attributes.type === typeMatch[1]) { return true; } } // Handle ID selectors like '#email' if (selector.startsWith('#')) { const id = selector.substring(1); return node.attributes.id === id; } // Handle class selectors like '.sensitive-field' if (selector.startsWith('.')) { const className = selector.substring(1); return node.attributes.class && node.attributes.class.includes(className); } // Handle tag selectors like 'input' if (!selector.includes('[') && !selector.includes('.')) { return node.tagName && node.tagName.toLowerCase() === selector.toLowerCase(); } return false; } /** * Check if an event is from a field that should be redacted */ private isFieldSelected(eventData: any): boolean { if (!isBrowser) return false; try { // For input events (source 5), we need to determine if this is a sensitive field if (eventData.source === 5) { // Input event const elementId = eventData.id; if (elementId !== undefined) { // Try to find the element by data-rrweb-id attribute let element = document.querySelector(`[data-rrweb-id="${elementId}"]`) as HTMLElement; if (element) { return this.shouldRedactElement(element); } // Fallback: Try to find by nodeId if available if (eventData.nodeId !== undefined) { element = document.querySelector(`[data-rrweb-id="${eventData.nodeId}"]`) as HTMLElement; if (element) { return this.shouldRedactElement(element); } } // More aggressive approach: Check all elements that match our selectors // and see if any of them are currently focused or have the same ID for (const selector of this.userSelectedFields) { const matchingElements = document.querySelectorAll(selector); if (matchingElements.length > 0) { // Check if any of these elements are currently focused for (const el of matchingElements) { if (el === document.activeElement) { logDebug('Redaction: Found focused element matching selector:', selector); return true; } } } } // If we still can't find it, try a more direct approach // Look for any input element that might be the active one const activeElement = document.activeElement; if (activeElement && this.shouldRedactElement(activeElement as HTMLElement)) { logDebug('Redaction: Active element should be redacted'); return true; } return false; } } // For other event types, try to find the element const elementId = eventData.id; if (elementId !== undefined) { // First try to find by data-rrweb-id attribute let element = document.querySelector(`[data-rrweb-id="${elementId}"]`) as HTMLElement; if (element) { return this.shouldRedactElement(element); } } // Also check for nodeId which is another way rrweb identifies elements const nodeId = eventData.nodeId; if (nodeId !== undefined) { const element = document.querySelector(`[data-rrweb-id="${nodeId}"]`) as HTMLElement; if (element) { return this.shouldRedactElement(element); } } // For DOM mutations, check if the target element should be redacted if (eventData.target && eventData.target.id) { const element = document.querySelector(`[data-rrweb-id="${eventData.target.id}"]`) as HTMLElement; if (element) { return this.shouldRedactElement(element); } } return false; } catch (e) { logWarn('Error checking if field should be redacted:', e); return false; } } /** * Get CSS selectors for rrweb masking configuration * Used to configure rrweb's maskTextSelector option */ public getMaskTextSelector(): string | null { if (this.userSelectedFields.size === 0) { return null; } return Array.from(this.userSelectedFields).join(','); } /** * Check if an element should be redacted (for rrweb maskTextFn/maskInputFn) */ public shouldRedactElement(element: HTMLElement): boolean { if (this.userSelectedFields.size === 0) { return false; } // Check if any selector matches this element for (const selector of this.userSelectedFields) { try { if (element.matches(selector)) { return true; } } catch (e) { // Invalid selector, skip logWarn(`Invalid selector: ${selector}`); } } return false; } /** * Apply rrweb masking classes to DOM elements * Adds 'rr-mask' class to elements that should be redacted * This enables rrweb's built-in masking functionality */ public applyRedactionClasses(): void { if (this.userSelectedFields.size === 0) { return; } // Remove existing redaction classes document.querySelectorAll('.rr-mask').forEach(element => { element.classList.remove('rr-mask'); }); // Add redaction classes to matching elements this.userSelectedFields.forEach(selector => { try { const elements = document.querySelectorAll(selector); elements.forEach(element => { element.classList.add('rr-mask'); }); logDebug(`Applied rr-mask class to ${elements.length} element(s) for selector: ${selector}`); } catch (e) { logWarn(`Invalid selector: ${selector}`); } }); } /** * Get the original value of a redacted element (for debugging) */ public getOriginalValue(element: HTMLElement): string | undefined { if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { return element.value; } return undefined; } /** * Check if an element is currently being redacted */ public isElementRedacted(element: HTMLElement): boolean { return this.shouldRedactElement(element); } } // Export a default instance export const redactionManager = new RedactionManager(); // Export the class for custom instances export default RedactionManager;