humanbehavior-js
Version:
SDK for HumanBehavior session and event recording
521 lines (452 loc) • 19.9 kB
text/typescript
// 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;