38.5 数据分级与脱敏
一句话
分级回答“这份数据值多少保护成本”,脱敏回答“在保护的前提下还能不能用”;两者必须一起设计——去标识化不等于匿名化:去掉姓名后,只要“年龄 + 邮编 + 性别”还能唯一定位一个人,它仍然是个人信息。
四级分级:用“泄露后果”定级
| 级别 | 判据(泄露后的后果) | 典型数据 | 控制措施 |
|---|---|---|---|
| L1 公开 | 无后果 | 商品标题、公开价格 | 完整性校验 |
| L2 内部 | 轻微,影响内部秩序 | 内部文档、普通配置 | 访问控制、审计 |
| L3 敏感 | 可定位到个人,造成骚扰/歧视 | 手机号、地址、订单明细、设备 ID | 加密存储、字段级脱敏、最小化采集、留存期限 |
| L4 机密 | 造成财产/人身/合规重大损失 | 身份证号、银行卡、生物特征、健康记录、密码/密钥 | 强加密 + 密钥分离、动态脱敏、全量审计、单独授权 |
🔑 分级不是“给数据贴标签”,而是给每类数据决定四件事:谁能看(授权)、怎么存(加密)、怎么用(脱敏)、留多久(生命周期)。只贴标签不定措施,分级就是文档。
行业对具体字段有硬性要求(哪些必须加密、留存多久、能不能出境),主场在 3.4 行业规范 (金融 / 医疗 / 电商等);个人信息保护的合规口径(告知同意、最小必要、删除权)在 3.2 个人信息保护与 GDPR。
脱敏不是一种技术,是六种
| 技术 | 做法 | 可逆 | 适用 | 风险 |
|---|---|---|---|---|
| 掩码 | 138****8888 | 否 | 展示层(客服、日志) | 掩码规则不统一会泄露剩余位 |
| 替换 | 用假名/假手机号替换 | 否(但可映射) | 测试数据 | 假数据分布不对,测试失真 |
| 泛化 | 年龄 → 年龄段、邮编 → 前 3 位 | 否 | 统计分析 | 泛化不足 = 仍可识别(下面会算) |
| 令牌化 | 真值存保险库,外部只用 token | 是(有权限才可换回) | 支付卡号、跨系统关联 | 令牌映射表本身成了高价值目标 |
| 洗牌 | 同列内随机打乱 | 否 | 训练数据 | 破坏了行间关联(多列联合分析失效) |
| 差分隐私 | 加噪声,保证查询结果不可反推个体 | 否 | 统计发布、报表 | 参数没调好 = 噪声过大不可用,或过小仍可反推 |
⚠️ 最常被误用的两种:哈希当脱敏(手机号空间太小,可枚举还原,下面有实测)与假名化当匿名化(换个 ID 但保留了全部关联关系,仍属个人信息)。
代码:k-匿名算一遍,“去掉姓名”到底匿名了没有
package main
import (
"fmt"
"sort"
)
type record struct {
name string
age int
zip string
gender string
disease string
}
var rows = []record{
{"张伟", 34, "100081", "男", "感冒"},
{"李娜", 35, "100082", "女", "糖尿病"},
{"王芳", 36, "100083", "女", "高血压"},
{"刘强", 37, "100084", "男", "感冒"},
{"陈静", 38, "100085", "女", "糖尿病"},
{"杨洋", 39, "100086", "男", "高血压"},
{"赵敏", 40, "100087", "女", "感冒"},
{"黄磊", 41, "100088", "男", "糖尿病"},
{"周涛", 42, "100089", "男", "高血压"},
{"吴婷", 43, "100081", "女", "感冒"},
}
// 泛化:年龄按 10 岁归档,邮编只保留前 3 位
func generalize(r record) (string, string, string) {
return fmt.Sprintf("%d-%d", r.age/10*10, r.age/10*10+9), r.zip[:3], r.gender
}
func analyze(title string, keyFn func(record) string) {
groups := map[string][]record{}
for _, r := range rows {
k := keyFn(r)
groups[k] = append(groups[k], r)
}
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
minK, covered := 1<<30, 0
fmt.Printf("\n=== %s ===\n", title)
for _, k := range keys {
g := groups[k]
if len(g) < minK {
minK = len(g)
}
if len(g) >= 2 {
covered += len(g)
}
// 组内疾病种类:l-diversity 的粗略指标
ds := map[string]bool{}
for _, r := range g {
ds[r.disease] = true
}
fmt.Printf(" %-22s %d 条,疾病种类 %d\n", k, len(g), len(ds))
}
fmt.Printf(" 最小 k = %d;处于“≥2 人不可区分”组内的记录:%d/%d\n", minK, covered, len(rows))
}
func main() {
// ① 去掉姓名,保留精确准标识符(年龄+邮编+性别)
analyze("去掉姓名后(精确年龄 + 完整邮编 + 性别)", func(r record) string {
return fmt.Sprintf("%d-%s-%s", r.age, r.zip, r.gender)
})
// ② 泛化之后
analyze("泛化后(年龄按 10 岁归档 + 邮编前 3 位)", func(r record) string {
a, z, g := generalize(r)
return a + "-" + z + "-" + g
})
// ③ “哈希脱敏”手机号的真实强度
const (
phoneSpace = 7.0e9 // 11 位、首位 1、次位 3–9 的号段规模(量级)
)
fmt.Println("\n=== 手机号“哈希脱敏”的真实强度 ===")
fmt.Printf("号段空间约 %.1e\n", phoneSpace)
for _, rate := range []struct {
name string
hps float64
}{{"单机 CPU 1e8 次/秒", 1e8}, {"GPU 1e10 次/秒", 1e10}} {
sec := phoneSpace / rate.hps
fmt.Printf("无密钥 SHA-256(%s)→ 全量枚举约 %.1f 秒\n", rate.name, sec)
}
fmt.Println("HMAC-SHA256(密钥不出 KMS)→ 无密钥无法枚举;但同一手机号仍可被关联(同盐同值)")
}
本机实测(macOS,Apple M1,Go 1.27):
=== 去掉姓名后(精确年龄 + 完整邮编 + 性别) ===
34-100081-男 1 条,疾病种类 1
35-100082-女 1 条,疾病种类 1
36-100083-女 1 条,疾病种类 1
37-100084-男 1 条,疾病种类 1
38-100085-女 1 条,疾病种类 1
39-100086-男 1 条,疾病种类 1
40-100087-女 1 条,疾病种类 1
41-100088-男 1 条,疾病种类 1
42-100089-男 1 条,疾病种类 1
43-100081-女 1 条,疾病种类 1
最小 k = 1;处于“≥2 人不可区分”组内的记录:0/10
=== 泛化后(年龄按 10 岁归档 + 邮编前 3 位) ===
30-39-100-女 3 条,疾病种类 2
30-39-100-男 3 条,疾病种类 2
40-49-100-女 2 条,疾病种类 1
40-49-100-男 2 条,疾病种类 2
最小 k = 2;处于“≥2 人不可区分”组内的记录:10/10
=== 手机号“哈希脱敏”的真实强度 ===
号段空间约 7.0e+09
无密钥 SHA-256(单机 CPU 1e8 次/秒)→ 全量枚举约 70.0 秒
无密钥 SHA-256(GPU 1e10 次/秒)→ 全量枚举约 0.7 秒
HMAC-SHA256(密钥不出 KMS)→ 无密钥无法枚举;但同一手机号仍可被关联(同盐同值)
读数:
| 读数 | 含义 |
|---|---|
| 去掉姓名后,10 条记录 10 个唯一组合,k = 1 | 每条记录都能被唯一定位:去标识化 ≠ 匿名化 |
| 泛化后最小 k = 2,全部记录进入 ≥2 的组 | 泛化是有效的,但 k=2 意味着“至少 2 人不可区分”,仍不足以抵抗“知道对方邮编 + 年龄”的攻击 |
| 泛化后组内疾病种类只有 1–2 种 | l-diversity 不足:即使不知道是谁,也能推断“这组里 2/3 的概率是感冒” |
| 手机号无密钥哈希:CPU 70 秒、GPU 0.7 秒 | “哈希脱敏”对低熵字段(手机号、身份证)等于没有脱敏 |
🔑 结论:脱敏的强度取决于准标识符的组合数与分布,不是“删了姓名没有”。工程上用三个数字验收:① 最小 k(每组至少几人不可区分);② 敏感属性的多样性(l-diversity);③ 重识别所需的外部信息量(攻击者还知道什么)。
脱敏要在四层做,不是一处
| 层 | 做什么 | 漏了的后果 |
|---|---|---|
| 采集 | 最小化:不需要的字段不采 | 采了就得保护,且合规上无法解释用途 |
| 存储 | L3/L4 字段加密(信封加密,38.4);令牌化 | 库被拖走即全量泄露 |
| 展示 / 接口 | 掩码、按角色返回不同字段(列级权限,38.3) | 客服/运营越权看到全量 |
| 分析 / 测试 | 泛化、洗牌、差分隐私;测试库禁止直接拷贝生产 | 数仓与测试环境成为最大泄露源 |
⚠️ 日志是最容易漏的一层:请求体、异常堆栈、SQL 参数里都可能带手机号与身份证。日志脱敏的主场在 50.2 日志 (结构化 / trace_id / 分级 / 采样);测试数据的管理在 53.7 测试数据管理。
反模式
| 反模式 | 后果 |
|---|---|
| 用 MD5/SHA-256 直接哈希手机号/身份证当脱敏 | 空间小、可枚举(GPU 秒级),等同明文 |
| 只删姓名字段,保留精确年龄 + 邮编 | k=1,一条外部信息即可重识别(本节实测) |
| “假名化”后仍保留全部关联关系 | 仍属个人信息,合规上不构成匿名化 |
| 测试环境直接拷生产库 | 一次测试环境泄露 = 全量用户数据泄露 |
| 各系统各自定义分级 | 同一字段在 A 系统是 L2、B 系统是 L4,审计无法统一 |
| 脱敏不可逆但业务需要对账 | 对账失败,最后又存一份明文 |
| 只做展示层掩码,接口返回全量 | 抓包即拿到完整数据(越权类攻击见 38.6 常见攻击 (注入 / XSS / CSRF / SSRF / 越权 / 重放)) |
| 加密了就认为不用脱敏 | 密钥泄露即全明文;且内部人员有解密权 |
自检
Q1 一份数据“去掉了姓名、身份证、手机号,只保留年龄、邮编、性别、疾病”。它算匿名化了吗?用什么指标判断?
▸ 答案:不算。年龄 + 邮编 + 性别是典型的准标识符(quasi-identifier),组合起来往往能唯一定位个人(本节实测 k=1)。判断指标是 k-匿名:按准标识符分组,最小分组大小 k;k=1 表示每条记录都可被唯一识别,k≥3–5 才算有一定保护(还要看 l-diversity 与外部信息)。合规上,能通过“合理可能”的手段重识别出来的数据,仍属个人信息。
Q2 为什么手机号“哈希脱敏”不安全,而密码“哈希存储”是安全的?
▸ 答案:因为两者的输入空间不同。手机号只有约 7e9 种可能(且格式固定),攻击者可以枚举全部候选、逐个哈希比对,GPU 秒级完成;密码的空间是“人类选择的字符串”,虽然单条熵不高,但候选字典巨大且需要慢哈希(Argon2/bcrypt)抬高单次成本。另一个关键差异:密码哈希用每用户随机盐防止彩虹表与批量比对;手机号哈希如果为了“可关联”而用固定盐,就必然可被全量枚举。
Q3 令牌化与哈希都能“让外部系统拿不到真值”,什么时候必须用令牌化?
▸ 答案:当业务需要拿回真值时(对账、退款、客服核验、跨系统关联),哈希不可逆就用不了,必须用令牌化:真值存在受控的保险库,外部只流转 token,有权限的服务才能换回。代价是令牌映射表本身成了最高价值目标,必须独立加密、独立审计、最小权限访问。如果业务永远不需要真值(如统计),哈希/泛化/差分隐私更便宜。
Q4 一个团队把分级做成了“给每张表打一个标签”,但线上仍然到处是明文手机号。问题出在哪?
▸ 答案:分级只完成了“定义”,没完成“执行”。分级必须落到四件可检查的事上:① 存储加密(字段级,可验证:查表结构/加密列);② 展示脱敏(可验证:抓包看接口返回);③ 访问审计(可验证:谁在什么时候查了这批数据);④ 留存与删除(可验证:过期数据是否真的被清理)。没有这四项,标签只是一份文档。落地检查建议用“抽样抓包 + 库表扫描 + 审计日志”三件套,而不是问“我们分过级了吗”。