数据脱敏 的十种打开方式:工程实践笔记
最近给一个数据导出工具改需求,原本只是把 MySQL 导 CSV 给运营看,后来安全来了一句“手机号身份证不能明文”,测试又说“日志里别打出 token”,产品再说“字段长度、格式不能变,不然我 Excel 一列全废”。结果改到最后,我发现数据脱敏根本不是写个 replace("*") 就能交差。
下面是十种我在工程里实际碰到或者用过的打开方式。有些做法很糙,有些比较正经,主要是记录一下当时为什么选它、坑在哪。
1. 最偷懒的掩码:只给人看,不给自己看
手机号 13812345678 改成 138****5678,身份证 110105199001011234 改成 110105********1234。这种适合后台列表展示,适合给外包/运营看,不适合导出给数据分析。
代码:
import re
def mask_phone(s):
return re.sub(r'(?<=^(\d{3}))\d{4}(?=\d{4}$)', '****', str(s))
def mask_id_card(s):
s = str(s)
if len(s) == 18:
return s[:6] + '*' * 8 + s[-4:]
if len(s) == 15:
return s[:6] + '*' * 3 + s[-4:]
return s
坑:前缀后缀别留太多。比如身份证前6是地区码,后4可能和出生日期或校验有关,组合起来可能能猜。另外,掩码字段不要继续拿去精确 join,否则分析的时候才发现数据全废。
2. 固定假值替换:测试环境专用
测试库不要真实数据,但又不能空着,不然页面、校验、长度全炸。手机号换成 13900000001、13900000002,姓名换成 用户001、用户002,地址换成 测试路1号。
这个比随机假值稳定,方便复现问题。
from faker import Faker
fake = Faker("zh_CN")
def fake_phone(idx: int) -> str:
return f"139{idx % 100000000:08d}"
def fake_name(idx: int) -> str:
return f"测试用户{idx:04d}"
坑:139... 这种号段如果撞上真实手机号怎么办?最好用非运营商保留号段,或者统一前缀比如 19000000001 再跟业务确认。另外,假身份证如果要过实名校验,得生成合法校验位,不然接口直接报错。
3. 泛化:精确值别保留
年龄、收入、订单金额、地理位置,这类不一定要精确。运营要看 28 岁还是 35 岁吗?大多数时候只需要 25-34、35-44。地址也不需要门牌号,到市、区就够了。
SQL 里很常见:
SELECT
id,
CASE
WHEN age BETWEEN 18 AND 24 THEN '18-24'
WHEN age BETWEEN 25 AND 34 THEN '25-34'
WHEN age BETWEEN 35 AND 44 THEN '35-44'
ELSE '45+'
END AS age_bucket,
CONCAT(city, ' / ', district) AS region
FROM users;
坑:泛化之后样本变少,做用户画像、AB 实验、分位数计算会受影响。别一边要求脱敏,一边又要原来那种“能算出精确 P99”的效果。
4. 哈希加盐:看起来安全,实际要会算
有些字段不想保留原值,但又要能关联,比如同一个手机号在不同系统里都要映射到同一个用户。第一反应是 SHA256(phone),结果很容易被撞。11 位手机号去掉号段限制之后,空间并没有大到哪儿去。
更稳一点:
import hmac
import hashlib
import os
salt = os.environ["PII_PEPPER"].encode()
def pii_hash(phone: str) -> str:
return hmac.new(salt, phone.encode(), hashlib.sha256).hexdigest()
坑:盐/pepper 不要和数据库放一起备份,也不要写死在代码仓库里。哈希值如果拿去反查,权限系统必须卡死,不然等于换个更长的明文。
5. AES 加密:可逆脱敏,适合授权场景
如果客服、合规、运营审批之后需要还原原文,那就不是掩码能解决的了,得加密。别用 AES-ECB,至少 AES-GCM,带 nonce。
Python 小例子:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
def encrypt(value: str, key: bytes) -> str:
nonce = os.urandom(12)
gcm = AESGCM(key)
ct = gcm.encrypt(nonce, value.encode(), None)
return f"v1:{nonce.hex()}:{ct.hex()}"
def decrypt(token: str, key: bytes) -> str:
_, nonce_hex, ct_hex = token.split(":")
gcm = AESGCM(key)
return gcm.decrypt(bytes.fromhex(nonce_hex), bytes.fromhex(ct_hex), None).decode()
坑:加密之后基本告别普通索引、模糊搜索、GROUP BY。手机号查 13812345678 不能像原来一样查密文,除非你额外建盲索引,那就是另一套麻烦事了。
6. Tokenization:把敏感值搬出去
这个是我觉得工程上最舒服的一种,就是库里不存手机号,只存 phone_token。真实手机号和 token 的映射单独放一个小服务或者权限更高的数据库里。
表设计大概这样:
orders(
id,
user_id,
phone_token VARCHAR(64),
amount
)
pii_service(
token,
phone_encrypted,
created_at,
purpose,
approver
)
坑:批量导出会多一次 join,或者要调用令牌服务。令牌服务挂了,客服就查不了手机号;权限没做好,token 又变成新的敏感数据。别把 token 设计成可自增,不然又给人猜到了。
7. 数据库视图动态脱敏:给只读账号看
有些报表账号确实不该看原文,直接给他一个视图:
CREATE VIEW v_users_report AS
SELECT
id,
user_name,
CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) AS phone,
CONCAT(LEFT(id_card, 6), '********', RIGHT(id_card, 4)) AS id_card,
age_bucket
FROM users;
GRANT SELECT ON v_users_report TO report_ro;
REVOKE SELECT ON users FROM report_ro;
坑:权限要真的收回,别只建视图,底层表还能 SELECT。另外应用代码要是写死了 SELECT * FROM users,视图救不了你。MariaDB/MySQL 社区里有些列脱敏方案依赖版本,别文档没看就上线。
8. 日志脱敏:最容易漏,也最容易挨打
接口日志、异常日志、请求体日志,这些东西比导出文件更难管,因为开发随手一个 log.info("req={}", req) 就能把手机号、身份证、银行卡、token 全打出来。
Java Logback 里可以用 pattern 先兜底:
<encoder>
<pattern>%d %-5level [%thread] %logger - %replace(%msg){'1[3-9]\d{9}', '13****5678'}%n</pattern>
</encoder>
但说实话,只靠正则兜底不够。请求体是 JSON、异常栈里有参数、URL 查询串里也可能带手机号,都得处理。更稳的是在序列化阶段、MDC 阶段或者网关层统一过滤。
坑:日志采集到 ES 之后,历史数据可能已经明文了。别等安全平台扫出来,才开始查谁把身份证打进 debug 日志。
9. 接口响应脱敏:别信前端“反正我会处理”
有些前端说“后端给我明文,我页面展示时再打星号”,这种基本等于没有脱敏。只要响应里有原文,浏览器 devtools、接口测试、抓包工具全能看到。
Java Jackson 可以按字段加脱敏:
public class PhoneSerializer extends JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException {
if (value != null && value.length() == 11) {
gen.writeString(value.substring(0, 3) + "****" + value.substring(7));
} else {
gen.writeString(value);
}
}
}
字段上:
@JsonSerialize(using = PhoneSerializer.class)
private String phone;
坑:接口分页、导出、内部调用是不是也走同一个 DTO?如果同一个字段给报表接口用掩码,给内部服务用明文,那就别图省事共用一个 DTO 了,不然迟早炸。
10. ETL/导出脱敏:最后一道闸
数据从生产库到报表库、到数据湖、到外部供应商,导出这一步最容易失控。我的做法是:导出脚本不要直接读原表,先过一层脱敏配置。
大概思路:
# desensitize_export.py
FIELD_RULES = {
"users.phone": "mask_phone",
"users.id_card": "mask_id_card",
"orders.receiver_address": "truncate_to_city",
}
然后导出的时候逐行改:
for row in rows:
for field, rule in FIELD_RULES.items():
if "." in field:
_, col = field.split(".")
row[col] = apply_rule(rule, row[col])
write_csv(row)
坑:ETL 工具里的“任务日志”本身会不会把源数据打出来?中间临时表是不是明文?外部系统是不是只要一次完整数据,而不是每次增量?这些问题不提前想,导出文件脱敏完了,临时库又给你漏回去。
11. 顺手记几个别踩的坑
上面十种不是十个标准答案,更多是我在不同地方见过的做法。真实项目里一般会混着用:展示掩码、测试假值、报表泛化、接口 token、导出加密/令牌、日志过滤。
我踩过最蠢的一个坑是:以为 phone_mask 加了一列就完事了,结果原表里 phone 还在,BI 报表直接查原表。另一个坑是:哈希之后忘了告诉分析同学,他们拿着新字段去关联老字段,JOIN 出来全为空。还有一个坑是:脱敏规则写死在代码里,运营过来说“地址现在只到区,太粗了,能不能到街道”,最后发现改的是导出代码、测试环境、日志 filter、BI 视图、网关响应,五套地方都得改。
所以如果只选一条原则,就是:先想清楚“谁看、看哪里、能不能还原、要保留什么能力”。这玩意儿没想清楚就写函数,通常只会把明文从数据库里拿出来,再换个地方继续裸奔。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。