- 新增 data/orders/masking.py (141 行): - mask_phone: 11 位 → 138****1234;支持 +86/86 国家码剥离、空格/横杠分隔符 - mask_id_card: 18 位 → 430102********1234(保留前 6 行政区划 + 末 4);15 位老版兼容;13-14 位非标准也走前 6 后 4 遮罩 - mask_name: 1 字原样 / 2 字 张* / 3 字 张*丰 / 4+ 字 张**;非中日韩字符全遮 - mask_sensitive_dict: 一键遮罩订单字典中所有已知 PII 字段,不动 _enc/_hash 索引/密文 - 默认安全:None/空串/非字符串均不抛错,统一返回 None/'' - 扩展 Order.to_dict(decrypt_sensitive) 三态策略: - True : 完整明文(权限内接口使用,如后台人工核对) - False : 完全移除明文(对外公开统计/审计日志) - 'mask' : 部分遮罩(默认,推荐;列表/详情直接可用) - 未知字符串策略值回退为 mask(防误传 plaintext 导致明文泄露) - 新增 data/orders/tests/test_masking.py (246 行,32 个 pytest 用例): - 覆盖各 mask 函数边界(标准/带 +86/短串/非法输入) - 覆盖 Order.to_dict 三态与默认 mask 的串联通路 - 验证: - data/orders/tests 全套 112 用例通过(原 80 + 新增 32) - 全仓 344/344 通过 - ruff check 0 warning / ruff format --check 已规范 - 与 T4.1 落盘加密的关系:crypto 负责'落盘形态'(密文+hash), masking 负责'展示形态'(部分遮罩),两者正交互补。
142 lines
5.3 KiB
Python
142 lines
5.3 KiB
Python
"""订单敏感字段展示脱敏工具 (T11.2).
|
||
|
||
职责:把明文 PII 转成"可展示但不完整"的形态,用于 API 响应、UI 列表、日志输出。
|
||
|
||
与 crypto.py 的关系:
|
||
- crypto.py 负责"落盘形态"(AES 密文 / SHA-256 hash),目的是不让磁盘/数据库泄露明文。
|
||
- masking.py 负责"展示形态"(部分遮罩),目的是最小特权展示,不让前端/日志/截图拿到完整 PII。
|
||
- 两者正交:脱敏展示不替代落盘加密,解密后仍然可以再脱敏展示。
|
||
|
||
设计原则:
|
||
1. 默认安全:None / 空串 / 非法输入 → 返回 None,绝不抛错给前端。
|
||
2. 不依赖具体加密密钥:纯字符串处理,可在 API 序列化层、模板、日志过滤器、CSV 导出器等任意层复用。
|
||
3. 不丢语义:保留可识别前缀(如手机前 3 位、身份证前 6 位行政区划),便于运营核对。
|
||
4. 不可逆:无法从展示形态恢复原文(mask 后字符是"原始位数"而非真实值)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any, Optional
|
||
|
||
# 11 位中国手机号(已剔除 +86 / 空格 / 横杠后)
|
||
_PHONE_11 = re.compile(r"^\d{11}$")
|
||
# 18 位身份证(含 17 位 + 末位校验位 X/x)
|
||
_ID_CARD_18 = re.compile(r"^\d{17}[\dXx]$")
|
||
# 15 位老版身份证
|
||
_ID_CARD_15 = re.compile(r"^\d{15}$")
|
||
# 86 / +86 国家码前缀(中国大陆手机号常见形态)
|
||
_PHONE_PREFIX_86 = re.compile(r"^(\+?86)[\s\-]?")
|
||
|
||
|
||
def mask_phone(value: Optional[str]) -> Optional[str]:
|
||
"""手机号脱敏:138****1234。
|
||
|
||
接受:
|
||
- 11 位纯数字 → "前3位****后4位"
|
||
- 含 +86 / 86 / 空格 / 横杠 → 先剥离国家码与分隔再判定
|
||
- None / 空串 / 长度不足 → 原样返回(None / "")。
|
||
- 非字符串 → 返回 None(不抛错给前端)。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, str):
|
||
return None
|
||
s = value.strip()
|
||
if not s:
|
||
return ""
|
||
# 显式剥离 +86 / 86 国家码(只剥一次,避免误处理纯数字 86 开头)
|
||
s = _PHONE_PREFIX_86.sub("", s, count=1)
|
||
# 去掉剩余的空格 / - / + 分隔符
|
||
stripped = re.sub(r"[\s\-]", "", s)
|
||
if _PHONE_11.match(stripped):
|
||
return f"{stripped[:3]}****{stripped[-4:]}"
|
||
# 非标准形态:长度 >= 7 时遮中段(至少遮 1 个 *),否则原样
|
||
if len(stripped) >= 7:
|
||
return stripped[:3] + "*" * max(1, len(stripped) - 7) + stripped[-4:]
|
||
return s
|
||
|
||
|
||
def mask_id_card(value: Optional[str]) -> Optional[str]:
|
||
"""身份证脱敏:保留前 6 位行政区划 + 末 4 位。
|
||
|
||
18 位:430102********1234
|
||
15 位:430102******123(兼容老版)
|
||
非标准长度(13-14 位):按"前 6 后 4"遮中段,否则原样返回。
|
||
< 13 位视为非身份证,原样返回以免误遮。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, str):
|
||
return None
|
||
s = value.strip()
|
||
if not s:
|
||
return ""
|
||
if _ID_CARD_18.match(s):
|
||
return f"{s[:6]}********{s[-4:]}"
|
||
if _ID_CARD_15.match(s):
|
||
return f"{s[:6]}******{s[-3:]}"
|
||
if len(s) >= 13:
|
||
return s[:6] + "*" * max(2, len(s) - 10) + s[-4:]
|
||
return s
|
||
|
||
|
||
def mask_name(value: Optional[str]) -> Optional[str]:
|
||
"""姓名脱敏:保留姓氏。
|
||
|
||
规则:
|
||
- 1 字姓名 → 原样返回(已是最低信息粒度)。
|
||
- 2 字姓名 → 张*(姓 + 1 个 *)。
|
||
- 3 字姓名 → 张*丰(姓 + 名首字 + 1 个 *)。
|
||
- 4+ 字姓名 → 张**(姓 + 2 个 *,不暴露名长度)。
|
||
- 非中文字符(英文/数字/混合) → 全遮 *(不暴露字符数)。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, str):
|
||
return None
|
||
s = value.strip()
|
||
if not s:
|
||
return ""
|
||
# 非中日韩文字:按"全遮"处理
|
||
if not all("\u4e00" <= ch <= "\u9fff" for ch in s):
|
||
return "*" * len(s)
|
||
n = len(s)
|
||
if n == 1:
|
||
return s
|
||
if n == 2:
|
||
return s[0] + "*"
|
||
if n == 3:
|
||
return s[0] + "*" + s[2]
|
||
# 4 字及以上:保留首字 + 2 个 *(不暴露名长度信息)
|
||
return s[0] + "**"
|
||
|
||
|
||
def mask_sensitive_dict(data: dict[str, Any]) -> dict[str, Any]:
|
||
"""对订单字典中已知的敏感字段统一脱敏。
|
||
|
||
适配 Order.to_dict 输出:仅对明文 PII 字段做 mask,不修改 _enc / _hash 字段
|
||
(那些字段本就不应出现在 API 响应里;如果出现了,说明上游泄露 — 本函数不动它,
|
||
由调用方负责移除)。
|
||
"""
|
||
if not isinstance(data, dict):
|
||
return data
|
||
masked = dict(data)
|
||
if "customer_phone" in masked and masked["customer_phone"] is not None:
|
||
masked["customer_phone"] = mask_phone(masked["customer_phone"])
|
||
if "candidate_id_card" in masked and masked["candidate_id_card"] is not None:
|
||
masked["candidate_id_card"] = mask_id_card(masked["candidate_id_card"])
|
||
if "customer_name" in masked and masked["customer_name"] is not None:
|
||
masked["customer_name"] = mask_name(masked["customer_name"])
|
||
if "candidate_name" in masked and masked["candidate_name"] is not None:
|
||
masked["candidate_name"] = mask_name(masked["candidate_name"])
|
||
return masked
|
||
|
||
|
||
__all__ = [
|
||
"mask_phone",
|
||
"mask_id_card",
|
||
"mask_name",
|
||
"mask_sensitive_dict",
|
||
]
|