《互联网产品的服务端架构·规模化与架构演进》7.3 个人信息保护与审计

「回声」的客服为排查一笔退款,把包含手机号、地址和身份证号的工单导成 CSV 发到群里,随后又被同步进测试环境。本节把个人信息保护拆成最小化、脱敏和审计三件套,给出字段脱敏与审计条目的实现和测试,并复盘一次明文手机号从日志扩散到外部群的泄露。

一张导出的工单表

「回声」的客服系统支持按时间范围导出工单,方便分析退款原因。导出的 CSV 包含工单号、用户昵称、手机号、收货地址、订单备注和处理记录。这个功能上线两年,一直是客服主管做周报的主要工具。

某天,一位客服为了排查一笔重复退款,导出了最近三天的 1.2 万条工单(案例假设值),把文件发到了部门群里,请同事帮忙找相似案例。群里有人把文件转给了外部兼职,兼职又把它上传到了一个在线表格工具做透视。三天后,一位用户收到精准的诈骗电话,对方能报出他的手机号、收货地址和最近一笔订单金额。

用户投诉后,团队开始排查。他们先查了数据库权限,发现客服账号只能查单条工单,没有全量导出权限;再查应用日志,发现导出接口把每一行请求参数都打进了日志,其中包含手机号和地址;最后查审计系统,发现导出动作根本没有被记录。

更麻烦的是测试环境。为了复现问题,开发同学把这份 CSV 导入测试库,测试库的访问权限比生产宽松得多,几个外包同学都能连。明文数据就这样从生产扩散到了测试,又从测试扩散到了外部工具。

事后复盘,团队列了很多整改项:收紧导出权限、加脱敏、加审计、清理测试数据。但真正的问题是,这三个动作从一开始就没有被当成一个整体设计。只加权限,挡不住有权限的人导出;只加脱敏,挡不住日志和测试库;只加审计,事后能查到但伤害已经发生。

最小化、脱敏、审计是三件套

个人信息保护经常被简化成“加个脱敏函数”。脱敏当然重要,但它只解决“展示时看不看得见”,不解决“该不该收集”“谁读过”“数据流到了哪里”。一个可落地的方案至少要有三层:

字段是否必要存储形态展示/导出形态读取是否审计
用户 ID必要明文明文否
手机号必要(联系与找回)加密或独立密钥保留首尾,中间遮蔽是
身份证号仅实名场景必要加密,单独权限默认不展示是
收货地址必要(履约)明文,限制访问只展示到区县,详情按需是
订单备注可能含个人信息明文默认折叠,导出前扫描是
支付流水号必要(对账)明文明文否

这张表是案例假设值,不是合规清单。每一列都要由业务、法务和安全共同确认:字段是否真的必要、保存多久、谁能读、读了是否留痕。技术的职责是让这些约定在代码里可执行,而不是只在文档里存在。

最小化先从字段开始

最小化的第一刀是“不收集”。很多系统在注册时要求手机号、邮箱、生日、性别、职业,实际只用到手机号登录。每多一个字段,就多一份存储、导出、泄露和合规成本。字段应该按“没有它业务是否无法完成”来判断,而不是按“以后可能有用”来保留。

第二刀是“不返回”。接口默认不返回敏感字段,只有明确需要的页面才通过单独的权限和审计读取。列表页、搜索页、消息推送这些场景通常不需要完整手机号,返回脱敏值就够了。

第三刀是“不保留”。日志、临时文件、导出文件、测试数据都有生命周期。日志里的手机号应该在上报前脱敏;导出文件应该设置过期时间;测试环境应该使用合成数据,而不是生产数据的副本。

脱敏要按字段类型设计

手机号、身份证号、邮箱、地址的脱敏规则不同。手机号保留前三位和后四位,可以用于客服核对;身份证号保留前两位和后两位,中间全部遮蔽;邮箱保留首字符和域名;地址只展示到区县。用同一个“保留首尾各两位”的规则套所有字段,可能让身份证号仍然可被拼凑,也可能让地址失去可用性。

脱敏还要考虑长度。短字段如果保留首尾,等于完整暴露。例如四位验证码、六位邀请码、短用户名,保留首尾各两位就只剩中间一两位被遮住,攻击者很容易枚举。对长度不足的字段,应该全部遮蔽,而不是机械地保留首尾。

审计记录的是“谁读了什么”

审计不是访问日志的同义词。访问日志记录“哪个接口被调用了”,审计要记录“哪个主体、在什么时间、因为什么、读取了哪些敏感字段”。主体可能是员工、服务账号或第三方应用;原因可能是工单处理、对账、风控;字段要精确到名称,而不是只记一个“用户信息”。

审计记录本身也不能包含明文敏感值。否则审计表会变成第二个泄露源。记录字段名、脱敏值、主体、时间、请求 ID 和用途就够了;需要还原时,通过受控流程回到原始数据,而不是把明文复制到审计表。

导出是最容易失控的路径

在线页面有权限、有脱敏、有水印,导出文件一旦落地就失去了这些控制。导出应该单独设计:限制单次行数、要求填写用途、默认脱敏、生成带过期时间的下载链接、记录下载人和文件哈希。对于包含敏感字段的导出,还应该要求二次审批。

导出文件还要能被追踪。文件里可以嵌入不可见的批次号,下载记录里保存批次号与请求 ID 的对应关系。这样即使文件被转发,也能定位到是哪一次导出、由谁发起。水印和批次号不能阻止泄露,但能显著提高追责和止损效率。

测试环境不能复制生产

测试环境最常见的做法是“把生产库脱敏后导入”,但脱敏脚本本身可能漏字段,导入过程也可能留下中间文件。更稳的做法是合成数据:按真实数据的分布生成假手机号、假地址和假订单,保留结构而不保留真实值。

如果必须使用生产数据,至少要做到:导入前按字段白名单裁剪;脱敏在独立环境完成;中间文件加密并设置短期生命周期;测试库禁止外网访问;导入动作本身进入审计。任何一条缺失,测试环境都会成为泄露的薄弱环节。

删除和更正要有闭环

用户要求删除或更正个人信息时,系统要能回答“数据在哪、删了什么、还有没有副本”。这要求数据地图先存在:哪些表、哪些日志、哪些备份、哪些第三方系统存了这份数据。没有数据地图,删除请求只能删主库,日志和备份里的副本仍然存在。

删除不是物理删除所有副本,而是按保留义务分级处理:主库立即删除或匿名化;日志按保留期滚动;备份在下一个周期自然过期;第三方系统通过接口通知。每一步都要有记录,才能在上报或审计时说明处理范围。

明文手机号从日志扩散到外部群

事故的起点不是导出功能,而是一条被忽略的日志。导出接口为了排查问题,把请求参数整体序列化后写进了应用日志,其中包含手机号和收货地址。日志平台对所有应用开放查询权限,客服、开发、外包都能看到。

第二个扩散点是导出文件本身。导出接口返回的是明文 CSV,没有行数限制,没有用途校验,也没有下载审计。客服导出的 1.2 万条记录里,手机号和地址都是完整的。文件发到群里之后,任何转发都不再受系统控制。

第三个扩散点是测试环境。开发同学为了复现,把 CSV 导入测试库,没有脱敏,也没有清理。测试库的连接权限发给过外包团队,明文数据因此又多了一个出口。

三个扩散点对应三个不同的失效:日志没有脱敏、导出没有控制、测试没有隔离。只修其中任何一个,数据仍然会从另外两个口子流出。真正的修复必须同时覆盖采集、展示、导出、测试和审计。

修正方案包括:日志上报前统一经过脱敏中间件,敏感字段只保留脱敏值;导出接口限制单次 5000 行,要求填写用途并二次审批,默认脱敏,下载链接 24 小时过期;测试环境改用合成数据,禁止直接导入生产 CSV;所有敏感字段读取写入审计,审计表只存脱敏值和字段名。

上线后还做了一次回扫:用字段名和正则扫描历史日志,找出仍然包含明文手机号的记录,按保留期删除。回扫发现,除了导出接口,支付回调和一个早期的调试接口也在打明文手机号。这说明单点修复不够,必须有统一的字段识别和脱敏入口。

把脱敏和审计写成纯函数

配套代码 internal/guard/pii.go 只做两件事:对单个字段脱敏,把需要审计的字段整理成条目。它不连接数据库、不写日志、不判断权限,因为这些副作用会让“某个值为什么被展示”难以复现。

// file: internal/guard/pii.go
// tier: L1
// verify: go test ./internal/guard/

package guard

import (
	"fmt"
	"strings"
)

// Field 表示一个待展示或待导出的字段。
// Sensitive 为 true 时,值必须先脱敏再进入日志、导出或页面。
type Field struct {
	Name      string
	Value     string
	Sensitive bool
}

// Mask 对敏感字段脱敏:保留首尾各 2 个字符,中间用 * 替代。
// 非敏感字段原样返回;长度不超过 4 个字符时全部遮蔽,
// 因为再保留首尾就等于把原值完整暴露。按 rune 计数,
// 避免把中文或 emoji 截成半个字符。
func Mask(f Field) string {
	if !f.Sensitive {
		return f.Value
	}
	runes := []rune(f.Value)
	if len(runes) <= 4 {
		return strings.Repeat("*", len(runes))
	}
	return string(runes[:2]) + strings.Repeat("*", len(runes)-4) + string(runes[len(runes)-2:])
}

// Audit 返回本次访问需要记录的审计条目。敏感字段无论最终是否展示,
// 只要被读取就应留下记录;非敏感字段不进入审计列表。
// actor 为空时标记为 anonymous,避免出现无法归属的审计行。
func Audit(fields []Field, actor string) []string {
	if actor == "" {
		actor = "anonymous"
	}
	var out []string
	for _, f := range fields {
		if !f.Sensitive {
			continue
		}
		name := f.Name
		if name == "" {
			name = "<unnamed>"
		}
		out = append(out, fmt.Sprintf("actor=%s field=%s masked=%s", actor, name, Mask(f)))
	}
	return out
}

Mask 按 rune 而不是 byte 计数。手机号和身份证号都是 ASCII,用 byte 也能工作;但姓名、地址、备注里常有中文,按 byte 截断会把一个汉字切成半个,产生乱码甚至非法 UTF-8。脱敏函数应该默认处理 Unicode,而不是假设输入都是英文数字。

Mask 在长度不超过 4 时全部遮蔽。这个边界是有意的:如果保留首尾各 2 个字符,4 个字符的字段会完整暴露;3 个字符的字段甚至会出现“保留的部分比遮蔽的部分还多”。短字段的脱敏没有意义,全遮才是安全默认。

Mask 对非敏感字段原样返回,而不是统一脱敏。脱敏应该由字段元数据驱动,而不是靠调用方记得传敏感标记。字段是否敏感来自数据字典或 schema,不应该由每个页面各写一遍。

Audit 只为敏感字段生成条目,并且只记录脱敏值。审计表如果存明文,就变成了新的泄露源;如果只存字段名,又无法区分“读了手机号”和“读了手机号但没看到值”。脱敏值加上字段名,是在可追溯和低风险之间的折中。

Audit 把空 actor 标记为 anonymous,而不是直接拒绝。拒绝会让匿名访问直接失败,可能影响内部批处理;标记为匿名至少保留了“发生了一次读取”的事实。更好的做法是在调用方保证 actor 不为空,审计函数只负责兜底。

代码没有实现字段识别。哪些字段是手机号、哪些是身份证号,应该由数据字典或 schema 定义,而不是在脱敏函数里用正则猜。正则识别适合做兜底扫描,不适合做主路径判断,因为误判和漏判都会直接影响展示和导出。

测试:长度边界与审计条目

脱敏代码最怕“看起来遮了,其实还能还原”。下面的测试覆盖了长字段、短字段、中文、空值和非敏感字段,也固定了审计条目的格式。

// file: internal/guard/pii_test.go
// tier: L1
// verify: go test ./internal/guard/

package guard

import (
	"reflect"
	"testing"
)

func TestMask(t *testing.T) {
	tests := []struct {
		name string
		f    Field
		want string
	}{
		{"not sensitive", Field{"email", "a@b.com", false}, "a@b.com"},
		{"phone", Field{"phone", "13812345678", true}, "13*******78"},
		{"id card", Field{"id", "110101199001011234", true}, "11**************34"},
		{"five runes", Field{"name", "欧阳小明啊", true}, "欧阳*明啊"},
		{"exactly four", Field{"code", "abcd", true}, "****"},
		{"empty", Field{"token", "", true}, ""},
	}
	for _, tt := range tests {
		if got := Mask(tt.f); got != tt.want {
			t.Errorf("%s: Mask() = %q, want %q", tt.name, got, tt.want)
		}
	}
}

func TestAudit(t *testing.T) {
	fields := []Field{
		{"email", "a@b.com", false},
		{"phone", "13812345678", true},
		{"id", "110101199001011234", true},
	}
	want := []string{
		"actor=cs-42 field=phone masked=13*******78",
		"actor=cs-42 field=id masked=11**************34",
	}
	if got := Audit(fields, "cs-42"); !reflect.DeepEqual(got, want) {
		t.Fatalf("Audit() = %#v, want %#v", got, want)
	}

	anon := Audit([]Field{{"phone", "13812345678", true}}, "")
	if len(anon) != 1 || anon[0] != "actor=anonymous field=phone masked=13*******78" {
		t.Fatalf("Audit anonymous = %#v", anon)
	}

	if got := Audit([]Field{{"email", "a@b.com", false}}, "cs-42"); len(got) != 0 {
		t.Fatalf("Audit without sensitive fields = %#v, want empty", got)
	}
}

TestMask 覆盖了 11 位手机号和 18 位身份证号。手机号期望 13*******78,身份证号期望 11**************34。这两个期望值把“保留首尾各 2 个字符”的规则固定下来,避免有人改成保留 3 位或 4 位后悄悄改变脱敏强度。

TestMask 还覆盖了 5 个字符的中文姓名和正好 4 个字符的短码。5 个字符时中间只遮 1 位,结果仍然可读;4 个字符时全部遮蔽,因为保留首尾会完整暴露。这个边界是脱敏函数最容易被写错的地方。

TestMask 的空值用例返回空字符串。空值没有可遮蔽的内容,返回空是合理的;但调用方不应该把空值当成“脱敏成功”,而应该在字段校验阶段就拒绝空手机号、空身份证号。

TestAudit 用 reflect.DeepEqual 比对完整条目,而不是只检查包含关系。审计格式是下游系统解析的契约,字段顺序、分隔符和前缀变化都会影响告警和报表。把格式锁进测试,可以避免一次“顺手重构”破坏审计链路。

TestAudit 还覆盖了匿名 actor 和非敏感字段。匿名访问应该留下记录,而不是被静默丢弃;非敏感字段不应该进入审计,否则审计表会被无意义的条目淹没,真正的敏感读取反而更难发现。

测试没有覆盖加密存储和密钥管理。脱敏只解决展示层,存储层是否加密、密钥如何轮换、谁能解密,属于另一套基础设施。单元测试不应该假装覆盖这些内容。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/guard/ -run 'TestMask|TestAudit' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestMask
--- PASS: TestMask (0.00s)
=== RUN   TestAudit
--- PASS: TestAudit (0.00s)
PASS
ok  	webapp/internal/guard	0.322s

两个用例全部通过,退出码为 0。-run 只筛选了脱敏和审计相关的用例,同包的评分与内容分级测试没有执行;需要全量确认时去掉 -run 再跑一次。

小结

个人信息保护不是单个脱敏函数,而是最小化、脱敏、审计三件套。最小化决定“收不收、留不留”,脱敏决定“展示时看不看得见”,审计决定“谁读过、能不能追溯”。三者缺一,数据就会从另外两个口子流出去:只做脱敏,日志和导出仍然可能明文;只做审计,伤害已经发生;只做权限,有权限的人仍然可以批量带走。

导出和测试环境是最容易失控的两条路径,因为它们把数据从受控系统带到了不受控的介质。导出要有行数限制、用途校验、默认脱敏和过期链接;测试环境要用合成数据,不能直接复制生产。脱敏要按字段类型和长度设计,短字段全遮;审计要记录主体、字段、脱敏值和时间,审计表本身不能存明文。

到这里,第 7 章的三件事——反爬、内容安全、个人信息保护——都落到了同一组原则上:用多个维度代替单一开关,用可解释的纯函数代替散落的 if,用审计和申诉把误判变成可修复的过程。下一章转向发布与变更,讨论环境、制品和发布流水线如何支撑规模化的持续交付。

阅读导航:上一节:7.2 内容安全与未成年人保护 · 下一节:8.1 环境、制品与发布流水线 。

常见错误与边界

  • 把个人信息保护等同于一个脱敏函数。脱敏只覆盖展示层,收集、存储、导出、测试和审计都需要单独设计。
  • 日志里直接打印请求参数。导出接口、支付回调和调试接口最容易把手机号、地址写进日志,上报前必须统一脱敏。
  • 导出文件默认明文且不限行数。导出应限制行数、要求用途、默认脱敏、生成过期链接,并记录下载人和批次。
  • 测试环境直接导入生产数据。测试库权限通常更宽,明文一旦进入就会多一个出口,应优先使用合成数据。
  • 短字段也保留首尾。四位验证码、六位邀请码保留首尾后几乎完整暴露,长度不足时应全部遮蔽。
  • 按 byte 截断字符串。中文和 emoji 会被切成半个字符,产生乱码或非法 UTF-8,脱敏必须按 rune 处理。
  • 审计表保存明文。审计记录本身也是数据,存明文会让审计系统变成第二个泄露源,应只存字段名和脱敏值。
  • 删除请求只删主库。日志、备份和第三方系统里的副本不会自动消失,没有数据地图就无法说明删除范围。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查