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 一个团队把分级做成了“给每张表打一个标签”,但线上仍然到处是明文手机号。问题出在哪?

▸ 答案:分级只完成了“定义”,没完成“执行”。分级必须落到四件可检查的事上:① 存储加密(字段级,可验证:查表结构/加密列);② 展示脱敏(可验证:抓包看接口返回);③ 访问审计(可验证:谁在什么时候查了这批数据);④ 留存与删除(可验证:过期数据是否真的被清理)。没有这四项,标签只是一份文档。落地检查建议用“抽样抓包 + 库表扫描 + 审计日志”三件套,而不是问“我们分过级了吗”。

关联