"""信息脱敏(CS-C-12,BASE-08):手机号 / 身份证 / 姓名 / 银行卡四类敏感信息脱敏。 运行时机(方案 §7.2,两处防线): 1. LLM 输入前:core_ro 结构化查询结果经 ``mask_dict_fields`` 按字段名批量脱敏后再拼 prompt, 防止敏感信息进入 LLM 上下文或日志; 2. API 返回前:回复文本经 ``mask_text`` 正则兜底扫描(与 compliance_guard 并行的最终防线)。 脱敏规则(方案 §7.1): - 手机号:保留前 3 后 4,中间 4 个 *(``138****5678``) - 身份证:保留前 3 后 4(18 位中间 11 个 *;兼容 15 位老证、末位 X) - 姓名:只保留姓(``张三``→``张*``、``张明明``→``张**``) - 银行卡:仅保留末 4 位,分隔符(空格/连字符)原样保留(``**** **** **** 7890``) 通用约定:None / 空串原样返回;值中已含 ``*`` 视为已脱敏直接放行(防重复打码); 不符合目标格式的值原样返回(不破坏原始数据)。 """ from __future__ import annotations import re from typing import Any, Callable # --------------------------------------------------------------------------- # 单字段脱敏 # --------------------------------------------------------------------------- def mask_phone(phone: str | None) -> str | None: """手机号脱敏:保留前 3 后 4。非 11 位标准手机号原样返回。""" if not phone or "*" in phone: return phone m = re.fullmatch(r"\s*(1[3-9]\d)(\d{4})(\d{4})\s*", phone) if not m: return phone return f"{m.group(1)}****{m.group(3)}" def mask_id_card(id_no: str | None) -> str | None: """身份证脱敏:保留前 3 后 4。支持 18 位(末位可为 X)与 15 位老证。""" if not id_no or "*" in id_no: return id_no s = id_no.strip().upper() # 末位 x 归一为 X # 18 位:前 3 + 中间 11 位打码 + 后 4(末位可能为 X) m = re.fullmatch(r"(\d{3})(\d{11})(\d{3}[0-9X])", s) if m: return f"{m.group(1)}{'*' * 11}{m.group(3)}" # 15 位老证:前 3 + 中间 8 位打码 + 后 4 m = re.fullmatch(r"(\d{3})(\d{8})(\d{4})", s) if m: return f"{m.group(1)}{'*' * 8}{m.group(3)}" return id_no def mask_name(name: str | None) -> str | None: """姓名脱敏:只保留姓,其余字符全部打 *(``张三``→``张*``、``张明明``→``张**``)。""" if not name or "*" in name: return name s = name.strip() if len(s) <= 1: return s return s[0] + "*" * (len(s) - 1) def mask_bank_card(card: str | None) -> str | None: """银行卡脱敏:仅保留末 4 位数字,分隔符(空格/连字符)原样保留。 数字位数不足 8 的不当作卡号处理,原样返回。 """ if not card or "*" in card: return card s = card.strip() digit_count = sum(1 for c in s if c.isdigit()) if digit_count < 8: return card keep_from = digit_count - 4 # 第几个数字起保留(从 0 计) out: list[str] = [] digit_idx = 0 for c in s: if c.isdigit(): out.append(c if digit_idx >= keep_from else "*") digit_idx += 1 else: out.append(c) # 分隔符原样保留 return "".join(out) # --------------------------------------------------------------------------- # 自由文本自动扫描(API 返回前兜底) # --------------------------------------------------------------------------- # 顺序敏感:先长后短。18 位身份证同样满足银行卡 16~19 位长度,必须先替换身份证; # 所有模式带数字边界断言,防止 11 位手机号从 16 位卡号中被误切。 _ID_CARD_18_RE = re.compile(r"(? str: """银行卡正则命中片段的替换函数:复用 mask_bank_card 的逐字符逻辑。""" return mask_bank_card(m.group(0)) # type: ignore[arg-type,return-value] def mask_text(text: str | None) -> str | None: """自由文本兜底脱敏:自动扫描并打码身份证 / 银行卡 / 手机号。 姓名不在自由文本中识别(正则无法可靠区分姓名与普通词语,误伤率高); 姓名脱敏只在结构化字段由 ``mask_name`` / ``mask_dict_fields`` 处理。 """ if not text: return text # 18 位身份证 → 银行卡(16~19 位,含分隔符)→ 15 位老证 → 11 位手机号 text = _ID_CARD_18_RE.sub(lambda m: f"{m.group(1)}{'*' * 11}{m.group(3)}", text) text = _BANK_CARD_RE.sub(_mask_bank_card_match, text) text = _ID_CARD_15_RE.sub(lambda m: f"{m.group(1)}{'*' * 8}{m.group(3)}", text) text = _PHONE_RE.sub(lambda m: f"{m.group(1)}****{m.group(3)}", text) return text # --------------------------------------------------------------------------- # 结构化数据批量脱敏(LLM 输入前,core_ro 查询结果) # --------------------------------------------------------------------------- # 字段名 → 脱敏类型。core_ro 返回的行是扁平 dict,按列名命中即脱敏; # 库中 *_mask 列本已脱敏,命中后会被 mask_* 的 "*" 检查直接放行(纵深防御)。 DEFAULT_FIELD_MASKERS: dict[str, str] = { # 手机号 "phone": "phone", "phone_mask": "phone", "mobile": "phone", # 身份证 "id_no": "id_card", "id_no_mask": "id_card", "id_card": "id_card", # 姓名 "name": "name", "display_name": "name", "customer_name": "name", "counterparty_name": "name", "payer_name": "name", # 银行卡 / 账号 "bank_card": "bank_card", "card_no": "bank_card", "account_no": "bank_card", "counterparty_account_mask": "bank_card", } _MASKER_FUNCS: dict[str, Callable[[str | None], str | None]] = { "phone": mask_phone, "id_card": mask_id_card, "name": mask_name, "bank_card": mask_bank_card, } def mask_dict_fields( data: Any, field_maskers: dict[str, str] | None = None, ) -> Any: """对 core_ro 结构化查询结果按字段名批量脱敏。 - 入参可为 dict(单行)或 list[dict](多行,如 list_holdings / list_trades 结果); - 仅处理字符串值,非字符串(Decimal/date/int 等)原样保留; - 返回浅拷贝,不修改入参; - ``field_maskers`` 可自定义 {字段名: 脱敏类型},脱敏类型取 phone / id_card / name / bank_card;默认用 :data:`DEFAULT_FIELD_MASKERS`。 """ mapping = field_maskers or DEFAULT_FIELD_MASKERS if isinstance(data, list): return [mask_dict_fields(item, mapping) if isinstance(item, dict) else item for item in data] if not isinstance(data, dict): return data out = dict(data) for field, masker_name in mapping.items(): if field in out and isinstance(out[field], str): out[field] = _MASKER_FUNCS[masker_name](out[field]) return out