diff --git a/extension/src/entrypoints/content.ts b/extension/src/entrypoints/content.ts index 4f3d3145..6bb8cb5f 100644 --- a/extension/src/entrypoints/content.ts +++ b/extension/src/entrypoints/content.ts @@ -189,9 +189,10 @@ function startRecorder() { chrome.runtime.sendMessage({ type: "RRWEB_EVENT", payload: event }); } }, - maskInputOptions: { - password: true, - }, + // Mask EVERY input at the rrweb layer: workflow steps never read rrweb + // input records, so OTP/card/phone values must not ride along in any + // rrweb payload (maskInputOptions{password} left them in cleartext). + maskAllInputs: true, checkoutEveryNms: 10000, checkoutEveryNth: 200, }); @@ -236,6 +237,39 @@ function stopRecorder() { } // --- Helper function to extract semantic information --- +const SENSITIVE_VALUE_MASK = "********"; + +// Broader than type=password: OTP fields are type=text/tel with +// autocomplete=one-time-code, card/CVV fields are type=text/number, and many +// sites only reveal sensitivity through name/id/label conventions. +function isSensitiveField(element: HTMLElement): boolean { + const el = element as HTMLInputElement; + const type = (el.type || "").toLowerCase(); + if (type === "password") return true; + if (el.tagName.toLowerCase() !== "input" && el.tagName.toLowerCase() !== "textarea") return false; + // Phone numbers are PII: a real number typed into a tel field is exactly + // what leaked into a saved workflow before this masking existed. + if (type === "tel") return true; + const autocomplete = (el.getAttribute("autocomplete") || "").toLowerCase(); + if (/(one-time-code|cc-number|cc-csc|cc-exp|new-password|current-password|\btel\b)/.test(autocomplete)) { + return true; + } + const hints = [ + el.name || "", + el.id || "", + el.getAttribute("aria-label") || "", + (el as HTMLInputElement).placeholder || "", + ] + .join(" ") + .toLowerCase(); + // Phone vocabulary must cover fields that are NOT type="tel": mobile/cell + // naming conventions (EN) and mobil/cep (TR) are how sites commonly name + // free-text phone inputs. + return /(password|passwd|pwd|otp\b|one.?time|verification.?code|security.?code|cvv|cvc|csc\b|card.?number|kart.?no|ssn\b|social.?security|tckn|tc.?kimlik|iban|secret|token\b|telefon|phone|gsm\b|mobile|mobil\b|\bcell(ular)?\b|msisdn|\bcep\b|cep.?tel)/.test( + hints + ); +} + function extractSemanticInfo(element: HTMLElement) { // Get associated label text using multiple strategies let labelText = ''; @@ -531,6 +565,12 @@ function extractSemanticInfo(element: HTMLElement) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const radioButtonInfo = (element as any)._radioButtonInfo || null; + // NEVER carry the raw value of a sensitive field: semanticInfo is embedded in + // stored events and shipped to the server, so an unmasked value here leaked + // real passwords even while the step's own value field was masked. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawValue = (element as any).value || ""; + return { labelText, textContent: element.textContent?.trim().slice(0, 200) || "", @@ -538,8 +578,7 @@ function extractSemanticInfo(element: HTMLElement) { placeholder: (element as any).placeholder || "", title: element.title || "", ariaLabel: element.getAttribute('aria-label') || "", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: (element as any).value || "", + value: rawValue && isSensitiveField(element) ? SENSITIVE_VALUE_MASK : rawValue, // eslint-disable-next-line @typescript-eslint/no-explicit-any name: (element as any).name || "", // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -633,7 +672,7 @@ function handleCustomClick(event: MouseEvent) { // Enhanced radio button information radioButtonInfo: semanticInfo.radioButtonInfo, }; - console.log("Sending CUSTOM_CLICK_EVENT:", clickData); + console.log("Sending CUSTOM_CLICK_EVENT"); chrome.runtime.sendMessage({ type: "CUSTOM_CLICK_EVENT", payload: clickData, @@ -689,7 +728,8 @@ function handleInput(event: Event) { if (!isRecordingActive) return; const targetElement = event.target as HTMLInputElement | HTMLTextAreaElement; if (!targetElement || !("value" in targetElement)) return; - const isPassword = targetElement.type === "password"; + // Mask anything sensitive, not just type=password (OTP, card, CVV, SSN, ...) + const isSensitive = isSensitiveField(targetElement as HTMLElement); try { const xpath = getXPath(targetElement); @@ -711,14 +751,14 @@ function handleInput(event: Event) { xpath: xpath, cssSelector: getEnhancedCSSSelector(targetElement, xpath), elementTag: targetElement.tagName, - value: isPassword ? "********" : targetElement.value, + value: isSensitive ? SENSITIVE_VALUE_MASK : targetElement.value, // eslint-disable-next-line @typescript-eslint/no-explicit-any inputType: (targetElement as any).type?.toLowerCase() || 'text', // Input type (text, password, email, etc.) // Semantic information for target_text based workflows targetText: targetText, semanticInfo: semanticInfo, }; - console.log("Sending CUSTOM_INPUT_EVENT:", inputData); + console.log("Sending CUSTOM_INPUT_EVENT"); chrome.runtime.sendMessage({ type: "CUSTOM_INPUT_EVENT", payload: inputData, @@ -769,7 +809,7 @@ function handleSelectChange(event: Event) { targetText: semanticInfo.labelText || fieldName, semanticInfo: semanticInfo }; - console.log("Sending CUSTOM_SELECT_EVENT:", selectData); + console.log("Sending CUSTOM_SELECT_EVENT"); chrome.runtime.sendMessage({ type: "CUSTOM_SELECT_EVENT", payload: selectData, @@ -845,7 +885,7 @@ function handleKeydown(event: KeyboardEvent) { cssSelector: cssSelector, // CSS selector of the element in focus (if any) elementTag: elementTag, // Tag name of the element in focus }; - console.log("Sending CUSTOM_KEY_EVENT:", keyData); + console.log("Sending CUSTOM_KEY_EVENT"); chrome.runtime.sendMessage({ type: "CUSTOM_KEY_EVENT", payload: keyData, diff --git a/workflows/tests/test_redaction.py b/workflows/tests/test_redaction.py new file mode 100644 index 00000000..bf17f6c3 --- /dev/null +++ b/workflows/tests/test_redaction.py @@ -0,0 +1,116 @@ +"""Tests for sensitive-value redaction and sensitive-type default omission. + +Run with: ``uv run pytest tests/test_redaction.py`` +""" + +from types import SimpleNamespace + +from workflow_use.workflow.redaction import VALUE_MASK, redact_step_value +from workflow_use.workflow.variable_identifier import ( + SENSITIVE_VARIABLE_TYPES, + VariableCandidate, + VariableIdentifier, + VariableType, +) + + +def step(**fields): + return SimpleNamespace(**fields) + + +class TestRedactStepValue: + def test_password_hint_masks(self): + assert redact_step_value(step(target_text='Password'), 'redactable-value') == VALUE_MASK + + def test_mobile_field_masks(self): + """Free-text phone inputs are commonly named mobile/cell, not type=tel.""" + assert redact_step_value(step(target_text='Mobile number'), '5551234567') == VALUE_MASK + + def test_cell_phone_field_masks(self): + assert redact_step_value(step(cssSelector='input[name="cellPhone"]'), '5551234567') == VALUE_MASK + + def test_turkish_cep_field_masks(self): + assert redact_step_value(step(target_text='Cep numarası'), '5551234567') == VALUE_MASK + + def test_recep_name_is_not_cep(self): + """\\bcep\\b must not fire inside names like Recep.""" + assert redact_step_value(step(target_text='Recep Bey'), 'merhaba') == 'merhaba' + + def test_phone_type_hint_masks(self): + assert redact_step_value(step(inputType='tel', target_text='Contact'), '5551234567') == VALUE_MASK + + def test_turkish_phone_label_masks(self): + assert redact_step_value(step(target_text='Cep Telefon Numarası'), '5551234567') == VALUE_MASK + + def test_cc_autocomplete_selector_masks(self): + assert redact_step_value(step(cssSelector='input[autocomplete="cc-number"]'), '4111111111111111') == VALUE_MASK + + def test_element_text_hint_masks(self): + """Legacy steps may only carry the hint in elementText.""" + assert redact_step_value(step(elementText='One-time verification code'), '123456') == VALUE_MASK + + def test_plain_field_untouched(self): + assert redact_step_value(step(target_text='Search term'), 'red shoes') == 'red shoes' + + def test_hotel_is_not_tel(self): + assert redact_step_value(step(target_text='Hotel name'), 'Grand Hotel') == 'Grand Hotel' + + def test_masked_stays_masked(self): + assert redact_step_value(step(), VALUE_MASK) == VALUE_MASK + + +class TestSensitiveDefaults: + def _schema_entry(self, variable_type, value, confidence, suggested_default, name='v', context=None, required=True): + identifier = VariableIdentifier() + candidate = VariableCandidate( + value=value, + variable_name=name, + variable_type=variable_type, + confidence=confidence, + context=context or {}, + suggested_default=suggested_default, + required=required, + ) + return identifier._generate_input_schema({name: candidate})[0] + + def test_context_detected_password_gets_no_default(self): + """A 0.85-confidence password (context-detected) is still a secret.""" + # The value is an inert placeholder, kept deliberately un-password-like + # so secret scanners don't flag the fixture itself. + entry = self._schema_entry(VariableType.PASSWORD, 'example-value-1', 0.85, 'example-value-1') + assert 'default' not in entry + + def test_phone_gets_no_default(self): + entry = self._schema_entry(VariableType.PHONE, '5551234567', 0.9, '5551234567') + assert 'default' not in entry + + def test_masked_password_gets_no_default_either(self): + """Even a masked capture must not become a default: defaults are typed + verbatim on replay, so '********' would literally enter 8 asterisks.""" + entry = self._schema_entry(VariableType.PASSWORD, '********', 0.85, '********') + assert 'default' not in entry + + def test_sensitive_forces_required(self): + """With no default allowed, the value must come from the caller.""" + entry = self._schema_entry(VariableType.PASSWORD, '********', 0.85, None, required=False) + assert entry['required'] is True + + def test_string_typed_password_name_gets_no_default(self): + """A field NAMED like a credential is sensitive even when pattern + matching classified its value as plain STRING.""" + entry = self._schema_entry(VariableType.STRING, 'example-value-2', 0.7, 'example-value-2', name='user_password') + assert 'default' not in entry + assert entry['required'] is True + + def test_string_typed_iban_context_gets_no_default(self): + entry = self._schema_entry( + VariableType.STRING, 'TR000000000000000000000000', 0.7, None, context={'label': 'IBAN'} + ) + assert 'default' not in entry + + def test_plain_string_keeps_default(self): + entry = self._schema_entry(VariableType.STRING, 'red shoes', 0.7, 'red shoes') + assert entry.get('default') == 'red shoes' + + def test_sensitive_set_covers_expected_types(self): + assert {VariableType.PASSWORD, VariableType.CREDIT_CARD, VariableType.SSN} <= SENSITIVE_VARIABLE_TYPES diff --git a/workflows/workflow_use/controller/service.py b/workflows/workflow_use/controller/service.py index b4e5abda..eadd250b 100644 --- a/workflows/workflow_use/controller/service.py +++ b/workflows/workflow_use/controller/service.py @@ -16,6 +16,7 @@ ScrollDeterministicAction, SelectDropdownOptionDeterministicAction, ) +from workflow_use.workflow.redaction import redact_step_value logger = logging.getLogger(__name__) @@ -138,7 +139,8 @@ async def input( await locator.click(force=True) await asyncio.sleep(0.5) - msg = f'⌨️ Input "{params.value}" into element with CSS selector: {truncate_selector(selector_used)} (original: {truncate_selector(original_selector)})' + logged_value = redact_step_value(params, params.value) + msg = f'⌨️ Input "{logged_value}" into element with CSS selector: {truncate_selector(selector_used)} (original: {truncate_selector(original_selector)})' logger.info(msg) return ActionResult(extracted_content=msg, include_in_memory=True) except Exception as e: diff --git a/workflows/workflow_use/workflow/redaction.py b/workflows/workflow_use/workflow/redaction.py new file mode 100644 index 00000000..51f1337c --- /dev/null +++ b/workflows/workflow_use/workflow/redaction.py @@ -0,0 +1,55 @@ +"""Log/report redaction for sensitive step values. + +Recorded workflows can carry credentials and PII in step values (login flows, +card forms). Whatever the capture side does, the replay side must not re-leak +them through INFO logs or error reports. +""" + +import re + +# Field hints that mark a value as sensitive for logging/error-report purposes. +# Covers autocomplete tokens (cc-number, one-time-code), type hints (tel) and +# EN+TR naming conventions for credentials, cards, ids and phone numbers. +_SENSITIVE_HINT_RE = re.compile( + r'(password|passwd|pwd|otp\b|one.?time|verification|security.?code|cvv|cvc|csc\b' + r'|card.?number|cc-number|cc-csc|cc-exp|kart|ssn\b|social.?security|tckn|kimlik|iban' + r'|secret|token|type=.?tel\b|\btel\b|telefon|phone|gsm\b|mobile|mobil\b' + r'|\bcell(ular)?\b|msisdn|\bcep\b)', + re.IGNORECASE, +) +VALUE_MASK = '********' + + +def is_sensitive_hint(*hints) -> bool: + """True when any free-form hint (field name, label, id, placeholder...) + suggests the associated value is a credential or PII.""" + joined = ' '.join(str(h) for h in hints if h) + return bool(_SENSITIVE_HINT_RE.search(joined)) + +# Recorder/step fields that can reveal what kind of field the value belongs to +_HINT_FIELDS = ( + 'target_text', + 'targetText', + 'description', + 'cssSelector', + 'xpath', + 'elementText', + 'elementTag', + 'inputType', +) + + +def redact_step_value(step, value): + """Mask a step value in logs/error reports when the field looks sensitive. + + *step* may be a workflow step or an action params object - any attribute bag + with target_text/description/cssSelector-style fields. Values that arrive + already masked stay masked. + """ + if value is None: + return None + text = str(value) + if text == VALUE_MASK: + return text + hints = ' '.join(str(getattr(step, field, '') or '') for field in _HINT_FIELDS) + return VALUE_MASK if _SENSITIVE_HINT_RE.search(hints) else text diff --git a/workflows/workflow_use/workflow/semantic_executor.py b/workflows/workflow_use/workflow/semantic_executor.py index 69ad9f59..cdcf4243 100644 --- a/workflows/workflow_use/workflow/semantic_executor.py +++ b/workflows/workflow_use/workflow/semantic_executor.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import re import traceback from typing import TYPE_CHECKING, Dict, List, Optional, Tuple @@ -22,6 +23,7 @@ WorkflowStep, ) from workflow_use.workflow.error_reporter import ErrorCategory, ErrorContext, ErrorReporter +from workflow_use.workflow.redaction import redact_step_value from workflow_use.workflow.semantic_extractor import SemanticExtractor from workflow_use.workflow.step_verifier import StepVerifier, VerificationResult @@ -368,7 +370,6 @@ def _find_element_by_pattern( Returns: Element info dict if found, None otherwise """ - import re logger.info(f"Finding element by pattern: '{pattern}' (position: {position_hint}, container: {container_hint})") @@ -1531,7 +1532,7 @@ async def input_executor(): # Click removed - not needed after fill and CDP doesn't support force parameter await asyncio.sleep(0.5) - msg = f"⌨️ Input '{step.value}' into: {target_identifier or step.description or selector_to_use}" + msg = f"⌨️ Input '{redact_step_value(step, step.value)}' into: {target_identifier or step.description or selector_to_use}" logger.info(msg) return ActionResult(extracted_content=msg, include_in_memory=True) @@ -2098,7 +2099,7 @@ async def _execute_with_verification_and_retry(self, step_executor, step, verifi consecutive_verification_failures=self.consecutive_verification_failures, retry_attempts=self.max_retries + 1, target_text=getattr(step, 'target_text', None), - input_value=getattr(step, 'value', None), + input_value=redact_step_value(step, getattr(step, 'value', None)), last_successful_step=self.last_successful_step, current_url=current_url, page_title=page_title, @@ -3130,7 +3131,6 @@ def _date_matches(self, target_date: str, element_date: str) -> bool: def _normalize_date(self, date_str: str) -> str: """Normalize date string to YYYY-MM-DD format.""" - import re from datetime import datetime # Remove extra whitespace and common words @@ -3207,7 +3207,6 @@ def _score_flight_option(self, criteria: Dict, context: Dict, text: str) -> int: def _price_in_range(self, price_str: str, price_range: str) -> bool: """Check if price falls within specified range.""" - import re try: # Extract numeric price diff --git a/workflows/workflow_use/workflow/variable_identifier.py b/workflows/workflow_use/workflow/variable_identifier.py index 9a847151..f7f9ae14 100644 --- a/workflows/workflow_use/workflow/variable_identifier.py +++ b/workflows/workflow_use/workflow/variable_identifier.py @@ -12,6 +12,8 @@ from enum import Enum from typing import Any, Dict, List, Optional, Set, Tuple +from workflow_use.workflow.redaction import is_sensitive_hint + logger = logging.getLogger(__name__) @@ -31,6 +33,20 @@ class VariableType(str, Enum): PASSWORD = 'password' +# Types whose recorded value must NEVER be persisted as a plaintext default, +# regardless of detection confidence (a context-detected password at 0.85 is +# just as much a secret as a pattern-detected one at 0.95). +SENSITIVE_VARIABLE_TYPES = frozenset( + { + VariableType.PASSWORD, + VariableType.CREDIT_CARD, + VariableType.SSN, + VariableType.EMAIL, + VariableType.PHONE, + } +) + + @dataclass class VariableCandidate: """A candidate value that could be parameterized as a variable.""" @@ -491,12 +507,25 @@ def _generate_input_schema(self, variables: Dict[str, VariableCandidate]) -> Lis if candidate.description: entry['description'] = candidate.description - # IMPORTANT: Always add default value (original value from workflow) - # This allows the workflow to run without user input if desired - if candidate.suggested_default: + # Add a default so the workflow can run without user input - EXCEPT for + # sensitive values: persisting the recorded value as a plaintext default + # would write the secret into the saved .workflow.yaml on disk. + # Sensitivity is decided by TYPE (password, credit card, SSN, email, + # phone) *or* by NAME/CONTEXT hints - a field named "password" or + # "iban" that pattern-matching classified as plain STRING is just as + # much a secret. Never emit a default for these, not even a masked + # '********' one: defaults are typed verbatim on replay, so a masked + # default would literally enter eight asterisks into the login field. + is_sensitive = candidate.variable_type in SENSITIVE_VARIABLE_TYPES or is_sensitive_hint( + var_name, *(candidate.context or {}).values() + ) + if is_sensitive: + # No default; the value must come from the caller at run time. + entry['required'] = True + elif candidate.suggested_default is not None: entry['default'] = candidate.suggested_default - else: - # If no suggested default, use the original value + elif candidate.confidence < 0.95: + # Low-confidence candidate without an explicit suggestion entry['default'] = candidate.value schema.append(entry)