-
Notifications
You must be signed in to change notification settings - Fork 345
fix(security): stop credential/PII leaks at capture, persistence and replay #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Sensitive replay inputs can still be logged verbatim when the selector is generic or uses standard credit-card autocomplete hints, because redaction receives only the action params and its heuristic misses those cases. Passing the matched element's type/autocomplete/name metadata into redaction (and covering the standard hints) would preserve masking for these fields. Prompt for AI agents |
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Replay of a recorded telephone or Prompt for AI agents |
||
| 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.