"""Sensitive-data redaction for Inspector artifacts.""" from __future__ import annotations import re from typing import Any, Optional # Field / attribute name hints (case-insensitive). _SENSITIVE_NAME_RE = re.compile( r"(password|passwd|pwd|token|secret|cookie|authorization|auth|otp|" r"captcha|verify.?code|verification.?code|sms.?code|pin)", re.I, ) _PHONE_RE = re.compile(r"(? bool: return bool(_SENSITIVE_NAME_RE.search(name or "")) def looks_sensitive_value(value: str) -> bool: s = value or "" if not s: return False if _PHONE_RE.search(s) or _EMAIL_RE.search(s) or _TOKENISH_RE.fullmatch(s.strip()): return True if "password" in s.lower() or "secret" in s.lower(): return True return False def sanitize_artifact(value: Any, *, sensitive_hint: bool = False) -> Any: """Recursively sanitize event/evidence data before it is persisted. ``redact_input_payload`` protects the normalized Action input, but recorder events also contain copies of the same value in target snapshots, ancestry and raw_event. All persisted copies must pass through one boundary. """ if isinstance(value, dict): input_type = str(value.get("input_type") or value.get("type") or "").lower() field_name = str( value.get("name") or value.get("field") or value.get("automation_id") or "" ) local_sensitive = bool( sensitive_hint or input_type == "password" or is_sensitive_field_name(field_name) ) out: dict[str, Any] = {} for key, item in value.items(): key_sensitive = local_sensitive or is_sensitive_field_name(str(key)) if key in { "name", "automation_id", "class_name", "control_type", "framework_id", "path_hint", "logical_name", } and isinstance(item, str) and not key_sensitive: # Machine identifiers can be long token-like strings (for # example conversation_list_container) without being secrets. # Still remove explicit personal data embedded in a label. out[key] = _EMAIL_RE.sub(REDACTED, _PHONE_RE.sub(REDACTED, item)) continue if key in {"value", "text", "input", "typed", "content", "plaintext"}: if item in (None, ""): out[key] = item elif key_sensitive or looks_sensitive_value(str(item)): out[key] = REDACTED else: out[key] = redact_text(str(item)) else: out[key] = sanitize_artifact(item, sensitive_hint=key_sensitive) if local_sensitive: out["sensitive"] = True return out if isinstance(value, list): return [sanitize_artifact(item, sensitive_hint=sensitive_hint) for item in value] if isinstance(value, tuple): return [sanitize_artifact(item, sensitive_hint=sensitive_hint) for item in value] if isinstance(value, str): if sensitive_hint or looks_sensitive_value(value): return REDACTED return redact_text(value) return value def redact_text(value: str, *, force: bool = False) -> str: if value is None: return "" s = str(value) if force: return REDACTED s = _PHONE_RE.sub(REDACTED, s) s = _EMAIL_RE.sub(REDACTED, s) return s def redact_input_payload( payload: dict[str, Any], *, field_name: str = "", input_type: str = "", ) -> tuple[dict[str, Any], bool]: """ Return (redacted_copy, was_sensitive). Never leave raw password/token in action_flow / catalog. """ sensitive = False out = dict(payload or {}) itype = (input_type or out.get("input_type") or "").lower() fname = field_name or str(out.get("name") or out.get("field") or "") if itype == "password" or is_sensitive_field_name(fname): sensitive = True for key in ("text", "value", "input", "typed", "content"): if key in out and out[key] not in (None, ""): out[key] = REDACTED out["redacted"] = True return out, True for key in ("text", "value", "input", "typed", "content"): if key not in out: continue raw = out.get(key) if raw is None: continue s = str(raw) if looks_sensitive_value(s) or is_sensitive_field_name(str(out.get("name") or "")): sensitive = True force = ("@" in s) or bool(_PHONE_RE.search(s)) or bool(_TOKENISH_RE.search(s)) out[key] = redact_text(s, force=force) if _TOKENISH_RE.fullmatch(s.strip()) or looks_sensitive_value(s): out[key] = REDACTED if sensitive: out["redacted"] = True return out, sensitive def redact_element_fields(el: dict[str, Any]) -> tuple[dict[str, Any], bool]: out = dict(el or {}) sensitive = False name = str(out.get("name") or out.get("automation_id") or "") role = str(out.get("control_type") or out.get("role") or "") input_type = str(out.get("input_type") or out.get("type") or "") if input_type == "password" or is_sensitive_field_name(name): sensitive = True if out.get("value"): out["value"] = REDACTED if out.get("text"): out["text"] = REDACTED out["sensitive"] = True return out, True for key in ("value", "text", "name"): if key not in out or out[key] in (None, ""): continue s = str(out[key]) # Stable element names/automation ids are locator structure. Long # machine identifiers (OperationAndSendMessageRegion, generated DOM # ids, etc.) must not be destroyed merely because they look token-like. # Personal data embedded in a visible name is still scrubbed. if key == "name": cleaned = _EMAIL_RE.sub(REDACTED, _PHONE_RE.sub(REDACTED, s)) out[key] = cleaned sensitive = sensitive or cleaned != s elif looks_sensitive_value(s): sensitive = True out[key] = REDACTED if sensitive: out["sensitive"] = True # Edit controls with password-ish class if "password" in role.lower() or "password" in str(out.get("class_name") or "").lower(): sensitive = True out["value"] = REDACTED out["sensitive"] = True return out, sensitive def redact_locatable_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]: any_sens = False out: list[dict[str, Any]] = [] for r in rows or []: cleaned, sens = redact_element_fields(r if isinstance(r, dict) else {}) any_sens = any_sens or sens out.append(cleaned) return out, any_sens def screenshot_sensitivity_meta( *, may_contain_sensitive: bool, save_sensitive_screenshots: bool = False, ) -> dict[str, Any]: return { "may_contain_sensitive_data": bool(may_contain_sensitive), "save_sensitive_screenshots": bool(save_sensitive_screenshots), "note": ( "截图暂时无法可靠脱敏;敏感页请关闭截图或人工审查。" if may_contain_sensitive else "" ), }