Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions extension/src/entrypoints/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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 = '';
Expand Down Expand Up @@ -531,15 +565,20 @@ 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) || "",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

try {
const xpath = getXPath(targetElement);
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions workflows/tests/test_redaction.py
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
4 changes: 3 additions & 1 deletion workflows/workflow_use/controller/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ScrollDeterministicAction,
SelectDropdownOptionDeterministicAction,
)
from workflow_use.workflow.redaction import redact_step_value

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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)

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/workflow_use/controller/service.py, line 142:

<comment>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.</comment>

<file context>
@@ -138,7 +139,8 @@ async def input(
 				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)
</file context>
Fix with cubic

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:
Expand Down
55 changes: 55 additions & 0 deletions workflows/workflow_use/workflow/redaction.py
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
9 changes: 4 additions & 5 deletions workflows/workflow_use/workflow/semantic_executor.py
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

Expand All @@ -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

Expand Down Expand Up @@ -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})")

Expand Down Expand Up @@ -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}"

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Replay of a recorded telephone or cc-* credit-card input can still write its raw value to INFO logs and error reports. This call only uses a hint regex that does not inspect the recorded input type/semantic metadata or recognize tel and cc-number/cc-csc/cc-exp, so the shared redactor should be extended before relying on it here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/workflow_use/workflow/semantic_executor.py, line 1535:

<comment>Replay of a recorded telephone or `cc-*` credit-card input can still write its raw value to INFO logs and error reports. This call only uses a hint regex that does not inspect the recorded input type/semantic metadata or recognize `tel` and `cc-number`/`cc-csc`/`cc-exp`, so the shared redactor should be extended before relying on it here.</comment>

<file context>
@@ -1531,7 +1532,7 @@ async def input_executor():
 			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)
</file context>
Fix with cubic

logger.info(msg)
return ActionResult(extracted_content=msg, include_in_memory=True)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading