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>
207 lines
7.4 KiB
Python
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 ""
|
|
),
|
|
}
|