feat(orders): T11.2 敏感字段展示脱敏工具 + to_dict 三态策略
- 新增 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 负责'展示形态'(部分遮罩),两者正交互补。
This commit is contained in:
141
data/orders/masking.py
Normal file
141
data/orders/masking.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""订单敏感字段展示脱敏工具 (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",
|
||||
]
|
||||
@@ -11,9 +11,17 @@ import random
|
||||
import string
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from .crypto import encrypt, decrypt, hash_for_index
|
||||
from .masking import mask_sensitive_dict
|
||||
|
||||
|
||||
# 解密策略:
|
||||
# - True : 完整明文(权限内接口使用,如后台人工核对)
|
||||
# - False : 完全移除明文字段(对外公开统计/审计日志)
|
||||
# - "mask": 部分遮罩(列表/详情默认,138****1234)
|
||||
DecryptPolicy = Union[bool, str]
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
@@ -145,13 +153,22 @@ class Order:
|
||||
data.pop("candidate_id_card_enc", None)
|
||||
return cls(**data)
|
||||
|
||||
def to_dict(self, decrypt_sensitive: bool = True) -> dict[str, Any]:
|
||||
def to_dict(self, decrypt_sensitive: DecryptPolicy = "mask") -> dict[str, Any]:
|
||||
"""导出为字典。
|
||||
|
||||
decrypt_sensitive=True 时敏感字段以明文返回(API 响应);False 时只返回 hash。
|
||||
decrypt_sensitive 取值:
|
||||
- True : 敏感字段以明文返回(权限内 API,如后台人工核对)
|
||||
- False : 完全移除明文字段(对外公开统计/审计日志,仅保留 hash)
|
||||
- "mask" : 部分遮罩(列表/详情默认,如 138****1234,推荐)
|
||||
|
||||
当传入未知字符串时,回退为 "mask",保证前端拿到的是遮罩而非明文 — 默认安全。
|
||||
"""
|
||||
data = asdict(self)
|
||||
if not decrypt_sensitive:
|
||||
if decrypt_sensitive is True:
|
||||
return data
|
||||
if decrypt_sensitive is False:
|
||||
data.pop("customer_phone", None)
|
||||
data.pop("candidate_id_card", None)
|
||||
return data
|
||||
return data
|
||||
# 默认 / "mask" / 其他字符串:走遮罩路径
|
||||
return mask_sensitive_dict(data)
|
||||
|
||||
246
data/orders/tests/test_masking.py
Normal file
246
data/orders/tests/test_masking.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""masking 模块单元测试 (T11.2).
|
||||
|
||||
覆盖:
|
||||
- 各 mask 函数的正常路径与边界(None / 空串 / 非字符串 / 非标准长度)
|
||||
- Order.to_dict 的 mask 模式串联通路
|
||||
- 默认安全:未知 decrypt_sensitive 值回退 mask 而非明文
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-unit-tests")
|
||||
|
||||
from data.orders.models import Order
|
||||
from data.orders.masking import (
|
||||
mask_id_card,
|
||||
mask_name,
|
||||
mask_phone,
|
||||
mask_sensitive_dict,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------- mask_phone ----------------------
|
||||
|
||||
|
||||
def test_mask_phone_standard_11_digits():
|
||||
assert mask_phone("13800001234") == "138****1234"
|
||||
|
||||
|
||||
def test_mask_phone_strip_prefix_and_separator():
|
||||
assert mask_phone("+86 138-0000-1234") == "138****1234"
|
||||
assert mask_phone(" 13800001234 ") == "138****1234"
|
||||
|
||||
|
||||
def test_mask_phone_none_returns_none():
|
||||
assert mask_phone(None) is None
|
||||
|
||||
|
||||
def test_mask_phone_empty_returns_empty():
|
||||
assert mask_phone("") == ""
|
||||
assert mask_phone(" ") == ""
|
||||
|
||||
|
||||
def test_mask_phone_non_string_returns_none():
|
||||
assert mask_phone(13800001234) is None # type: ignore[arg-type]
|
||||
assert mask_phone(["13800001234"]) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_mask_phone_short_falls_back_to_partial_mask():
|
||||
"""7 位临界值:走遮罩(至少 1 个 *),8-10 位类似。"""
|
||||
out7: str = mask_phone("1234567") or "" # type: ignore[assignment]
|
||||
assert out7.startswith("123")
|
||||
assert out7.endswith("4567")
|
||||
assert "*" in out7
|
||||
# 6 位及以下 — 长度不足,原样返回
|
||||
assert mask_phone("123456") == "123456"
|
||||
|
||||
|
||||
def test_mask_phone_keeps_non_standard_length_prefix_suffix():
|
||||
"""长度 >= 7 但不是 11 位(如带分机 12 位)也走遮罩 — 至少遮 1 个 *。"""
|
||||
# 12 位带分机号 — 至少 1 个 *,前 3 后 4 保留
|
||||
out: str = mask_phone("138000012345") or "" # type: ignore[assignment]
|
||||
assert out.startswith("138*")
|
||||
assert out.endswith("2345")
|
||||
assert "*" in out
|
||||
|
||||
|
||||
# ---------------------- mask_id_card ----------------------
|
||||
|
||||
|
||||
def test_mask_id_card_18_digits_keeps_district_and_tail():
|
||||
assert mask_id_card("430102200501011234") == "430102********1234"
|
||||
|
||||
|
||||
def test_mask_id_card_18_with_x_checksum_lowercased_ok():
|
||||
"""末位 X/x 视为合法的身份证校验位 — 遮罩保留末 4 位(含 X)。"""
|
||||
assert mask_id_card("43010220050101123X") == "430102********123X"
|
||||
|
||||
|
||||
def test_mask_id_card_15_legacy_format():
|
||||
assert mask_id_card("430102050101123") == "430102******123"
|
||||
|
||||
|
||||
def test_mask_id_card_none_returns_none():
|
||||
assert mask_id_card(None) is None
|
||||
|
||||
|
||||
def test_mask_id_card_empty_returns_empty():
|
||||
assert mask_id_card("") == ""
|
||||
assert mask_id_card(" ") == ""
|
||||
|
||||
|
||||
def test_mask_id_card_non_string_returns_none():
|
||||
assert mask_id_card(123) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_mask_id_card_short_below_threshold_unchanged():
|
||||
"""< 13 位视为非身份证 — 原样返回,避免误遮短串。"""
|
||||
assert mask_id_card("4301021234") == "4301021234"
|
||||
assert mask_id_card("12345") == "12345"
|
||||
|
||||
|
||||
def test_mask_id_card_non_standard_length_partial_mask():
|
||||
"""13-14 位非标准长度:前 6 后 4 保留,中段至少 2 个 *。"""
|
||||
out: str = mask_id_card("4301022005011") or "" # type: ignore[assignment] # 13 位
|
||||
assert out.startswith("430102")
|
||||
assert out.endswith("5011") # 末 4 位
|
||||
assert "*" in out
|
||||
|
||||
|
||||
# ---------------------- mask_name ----------------------
|
||||
|
||||
|
||||
def test_mask_name_one_char_unchanged():
|
||||
assert mask_name("张") == "张"
|
||||
|
||||
|
||||
def test_mask_name_two_chars_surname_plus_star():
|
||||
assert mask_name("张三") == "张*"
|
||||
|
||||
|
||||
def test_mask_name_three_chars_surname_given_name_partial():
|
||||
"""3 字姓名 → 姓 + 1 个 * + 名末字(如"张三丰" → "张*丰")。"""
|
||||
assert mask_name("张三丰") == "张*丰"
|
||||
|
||||
|
||||
def test_mask_name_four_chars_collapsed_to_surname_two_stars():
|
||||
"""4+ 字姓名 → 姓 + 2 个 *(不暴露名长度信息)。"""
|
||||
assert mask_name("欧阳明月") == "欧**"
|
||||
assert mask_name("上官婉儿") == "上**"
|
||||
|
||||
|
||||
def test_mask_name_none_returns_none():
|
||||
assert mask_name(None) is None
|
||||
|
||||
|
||||
def test_mask_name_empty_returns_empty():
|
||||
assert mask_name("") == ""
|
||||
assert mask_name(" ") == ""
|
||||
|
||||
|
||||
def test_mask_name_english_fully_masked():
|
||||
assert mask_name("Alice") == "*****"
|
||||
assert mask_name("Bo") == "**"
|
||||
|
||||
|
||||
def test_mask_name_mixed_chinese_and_digits_treats_as_non_cjk():
|
||||
"""含非中日韩字符 → 全遮,不暴露字符数。"""
|
||||
assert mask_name("张3") == "**"
|
||||
assert mask_name("John 张") == "******" # 6 字符全遮
|
||||
|
||||
|
||||
# ---------------------- mask_sensitive_dict ----------------------
|
||||
|
||||
|
||||
def test_mask_sensitive_dict_handles_all_known_fields():
|
||||
data = {
|
||||
"customer_phone": "13800001234",
|
||||
"candidate_id_card": "430102200501011234",
|
||||
"customer_name": "张三",
|
||||
"candidate_name": "李四光",
|
||||
"customer_phone_hash": "abc",
|
||||
"amount_cents": 1000,
|
||||
}
|
||||
out = mask_sensitive_dict(data)
|
||||
assert out["customer_phone"] == "138****1234"
|
||||
assert out["candidate_id_card"] == "430102********1234"
|
||||
assert out["customer_name"] == "张*"
|
||||
assert out["candidate_name"] == "李*光" # 3 字姓名 → 姓 + * + 名末字
|
||||
assert out["customer_phone_hash"] == "abc"
|
||||
assert out["amount_cents"] == 1000
|
||||
|
||||
|
||||
def test_mask_sensitive_dict_keeps_none_fields_none():
|
||||
data = {"customer_phone": None, "candidate_id_card": None}
|
||||
out = mask_sensitive_dict(data)
|
||||
assert out["customer_phone"] is None
|
||||
assert out["candidate_id_card"] is None
|
||||
|
||||
|
||||
def test_mask_sensitive_dict_does_not_mutate_input():
|
||||
data = {"customer_phone": "13800001234"}
|
||||
out = mask_sensitive_dict(data)
|
||||
assert data["customer_phone"] == "13800001234" # 原字典未变
|
||||
assert out["customer_phone"] == "138****1234"
|
||||
|
||||
|
||||
def test_mask_sensitive_dict_non_dict_passthrough():
|
||||
assert mask_sensitive_dict("not a dict") == "not a dict" # type: ignore[arg-type]
|
||||
assert mask_sensitive_dict(None) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------- Order.to_dict mask 集成 ----------------------
|
||||
|
||||
|
||||
def _make_order() -> Order:
|
||||
return Order(
|
||||
id="GKO-20260612-MASK",
|
||||
source="web",
|
||||
service_version="basic",
|
||||
customer_name="张三",
|
||||
customer_phone="13800001234",
|
||||
candidate_name="李四光",
|
||||
candidate_id_card="430102200501011234",
|
||||
)
|
||||
|
||||
|
||||
def test_order_to_dict_default_is_mask():
|
||||
"""不传参 = mask 模式 — 默认安全。"""
|
||||
order = _make_order()
|
||||
d = order.to_dict()
|
||||
assert d["customer_phone"] == "138****1234"
|
||||
assert d["candidate_id_card"] == "430102********1234"
|
||||
assert d["customer_name"] == "张*"
|
||||
assert d["candidate_name"] == "李*光" # 3 字姓名规则
|
||||
# hash 字段保留
|
||||
assert "customer_phone_hash" in d
|
||||
|
||||
|
||||
def test_order_to_dict_mask_explicit_string():
|
||||
order = _make_order()
|
||||
d = order.to_dict(decrypt_sensitive="mask")
|
||||
assert d["customer_phone"] == "138****1234"
|
||||
|
||||
|
||||
def test_order_to_dict_unknown_string_falls_back_to_mask():
|
||||
"""默认安全:未知策略值回退 mask,而非明文。"""
|
||||
order = _make_order()
|
||||
d = order.to_dict(decrypt_sensitive="plaintext-oops") # type: ignore[arg-type]
|
||||
assert d["customer_phone"] == "138****1234"
|
||||
|
||||
|
||||
def test_order_to_dict_true_returns_plaintext():
|
||||
order = _make_order()
|
||||
d = order.to_dict(decrypt_sensitive=True)
|
||||
assert d["customer_phone"] == "13800001234"
|
||||
assert d["candidate_id_card"] == "430102200501011234"
|
||||
|
||||
|
||||
def test_order_to_dict_false_strips_sensitive():
|
||||
order = _make_order()
|
||||
d = order.to_dict(decrypt_sensitive=False)
|
||||
assert "customer_phone" not in d
|
||||
assert "candidate_id_card" not in d
|
||||
assert "customer_phone_hash" in d
|
||||
Reference in New Issue
Block a user