Files
skill-template/tools/ui_inspector/redaction.py
chendelian 633ecff4e2 chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:19:14 +08:00

207 lines
7.4 KiB
Python

"""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"(?<!\d)(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)")
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
_TOKENISH_RE = re.compile(r"\b(?:Bearer\s+)?[A-Za-z0-9_\-]{24,}\b")
REDACTED = "[REDACTED]"
def is_sensitive_field_name(name: str) -> 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 ""
),
}