小Cの已经记不起来的博客

数据脱敏 的十种打开方式:工程实践笔记

最近给一个数据导出工具改需求,原本只是把 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. 固定假值替换:测试环境专用

测试库不要真实数据,但又不能空着,不然页面、校验、长度全炸。手机号换成 1390000000113900000002,姓名换成 用户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-3435-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 视图、网关响应,五套地方都得改。

所以如果只选一条原则,就是:先想清楚“谁看、看哪里、能不能还原、要保留什么能力”。这玩意儿没想清楚就写函数,通常只会把明文从数据库里拿出来,再换个地方继续裸奔。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放