通讯录接口返回四个字段,前端只该拿到一个
某招聘产品做「邀请同事」,客户端读取通讯录后生成邀请列表。上线两周后,安全团队在第三方 SDK 的流量里看到整表上传:姓名、手机号、邮箱,一个不少。
复盘时三方都觉得委屈。产品说只用了姓名;客户端说接口就返回这些字段;SDK 说调用方传了整表。翻出数据资产表,只有一行:
| 资产 | 级别 |
|---|---|
| 通讯录 | 敏感 |
这一行回答不了三个问题:哪些字段算敏感、这份数据允许给谁、能留多久。分级如果只停在资产名上,它就无法阻止任何一次具体的传输。
分级要回答四个问题
把数据分级当成「贴一个标签」,最后一定会得到一张没人用的 Excel。可执行的分级至少要回答四件事:
| 维度 | 取值 | 回答的问题 |
|---|---|---|
| 级别 | 公开 / 内部 / 敏感 / 极敏感 | 泄露后影响多大 |
| 字段 | 资产内的每个字段各自定级 | 这次请求到底碰了什么 |
| 用途 | 核心服务 / 分析 / 个性化 / 广告 / 安全审计 | 为什么现在要用 |
| 流向 | 本机 / 自有服务 / 第三方 / 导出 | 数据要去哪里 |
四个维度不是并列的标签,而是同一次判定里的四个输入。缺任何一个,拒绝理由就说不清楚。
| 级别 | 含义 | 示例字段 | 允许的最高流向 |
|---|---|---|---|
public | 泄露无影响 | 系统版本、屏幕尺寸 | 导出 |
internal | 仅内部使用 | 订单号、设备 ID | 第三方(需声明用途) |
sensitive | 泄露造成困扰 | 城市、邮箱、点击 ID | 导出 |
very-sensitive | 泄露造成实质伤害 | 精确坐标、手机号、广告标识 | 自有服务 |
这张表的关键不是级别名字,而是最后一列:级别直接决定流向上限。敏感数据不是「小心一点就能给第三方」,而是从规则上就不允许。
用途绑定不是一句声明
很多团队把「用途」写成隐私政策里的一段话,代码里没有任何约束。结果是同一个埋点 SDK 既做崩溃分析,又做广告归因,因为「数据已经采了,不用白不用」。
用途绑定要求每个资产显式列出允许的用途,请求必须命中其中之一。广告标识可以用于 ads 与 analytics,但不能因为「安全审计」听起来正当就顺手放行。用途是白名单,不是解释权。
把判定写成纯函数
下面的模块把级别、字段、用途、流向和保留期收进同一份资产清单,判定函数只做纯计算,方便在客户端、服务端和审计脚本里复用:
// file: src/security/data-classification.ts
// tier: L1
// verify: node --test test/security/data-classification.test.ts
export type DataLevel = "public" | "internal" | "sensitive" | "very-sensitive";
export type Purpose =
| "core-service"
| "analytics"
| "personalization"
| "ads"
| "security-audit";
export type Channel = "local" | "own-service" | "third-party" | "export";
export type FieldSpec = { name: string; level: DataLevel };
export type DataAsset = {
id: string;
title: string;
fields: FieldSpec[];
purposes: Purpose[];
channels: Channel[];
retentionDays: number | null;
};
export type AccessRequest = {
assetId: string;
fields: string[];
purpose: Purpose;
channel: Channel;
};
export type AccessDecision = {
allowed: boolean;
level: DataLevel | "unknown";
reasons: string[];
};
export const DATA_LEVELS: DataLevel[] = [
"public",
"internal",
"sensitive",
"very-sensitive",
];
const LEVEL_RANK: Record<DataLevel, number> = {
public: 1,
internal: 2,
sensitive: 3,
"very-sensitive": 4,
};
const CHANNEL_CEILING: Record<Channel, number> = {
local: 4,
"own-service": 4,
"third-party": 2,
export: 3,
};
const RETENTION_LIMIT: Partial<Record<DataLevel, number>> = {
sensitive: 365,
"very-sensitive": 30,
};
export const ASSETS: DataAsset[] = [
{
id: "order-basic", title: "订单基础信息",
fields: [
{ name: "orderId", level: "internal" },
{ name: "amount", level: "internal" },
{ name: "status", level: "internal" },
],
purposes: ["core-service", "analytics", "security-audit"],
channels: ["local", "own-service"], retentionDays: null,
},
{
id: "device-profile", title: "设备画像",
fields: [
{ name: "osVersion", level: "public" },
{ name: "screenSize", level: "public" },
{ name: "deviceId", level: "internal" },
],
purposes: ["core-service", "analytics", "personalization"],
channels: ["local", "own-service", "third-party", "export"], retentionDays: null,
},
{
id: "precise-location", title: "精确位置",
fields: [
{ name: "city", level: "sensitive" },
{ name: "latitude", level: "very-sensitive" },
{ name: "longitude", level: "very-sensitive" },
],
purposes: ["core-service", "personalization", "ads"],
channels: ["local", "own-service"], retentionDays: 30,
},
{
id: "contact-book", title: "通讯录",
fields: [
{ name: "displayName", level: "sensitive" },
{ name: "phone", level: "very-sensitive" },
{ name: "email", level: "sensitive" },
],
purposes: ["core-service"],
channels: ["local", "own-service"], retentionDays: 30,
},
{
id: "ad-identifier", title: "广告标识",
fields: [
{ name: "clickId", level: "sensitive" },
{ name: "idfa", level: "very-sensitive" },
],
purposes: ["ads", "analytics"],
channels: ["local", "own-service"], retentionDays: 30,
},
];
export function levelRank(level: DataLevel): number {
return LEVEL_RANK[level];
}
export function maxLevel(levels: DataLevel[]): DataLevel {
return levels.reduce<DataLevel>(
(strongest, level) => (LEVEL_RANK[level] > LEVEL_RANK[strongest] ? level : strongest),
"public",
);
}
export function assetById(id: string): DataAsset | undefined {
return ASSETS.find((asset) => asset.id === id);
}
export function resolveFields(
assetId: string,
fields: string[],
): { level: DataLevel; unknown: string[] } | undefined {
const asset = assetById(assetId);
if (!asset) {
return undefined;
}
const known = new Map(asset.fields.map((field) => [field.name, field.level]));
const unknown = fields.filter((name) => !known.has(name));
const levels = fields
.filter((name) => known.has(name))
.map((name) => known.get(name) as DataLevel);
return { level: maxLevel(levels), unknown };
}
export function channelAllowed(level: DataLevel, channel: Channel): boolean {
return LEVEL_RANK[level] <= CHANNEL_CEILING[channel];
}
export function accessDecision(request: AccessRequest): AccessDecision {
const asset = assetById(request.assetId);
if (!asset) {
return { allowed: false, level: "unknown", reasons: ["unknown-asset"] };
}
if (request.fields.length === 0) {
return { allowed: false, level: "unknown", reasons: ["no-fields"] };
}
const resolved = resolveFields(request.assetId, request.fields);
if (!resolved) {
return { allowed: false, level: "unknown", reasons: ["unknown-asset"] };
}
if (resolved.unknown.length > 0) {
return {
allowed: false,
level: "unknown",
reasons: resolved.unknown.map((name) => `unknown-field:${name}`),
};
}
const reasons: string[] = [];
if (!asset.purposes.includes(request.purpose)) {
reasons.push("purpose-not-declared");
}
if (!asset.channels.includes(request.channel)) {
reasons.push("channel-not-declared");
}
if (!channelAllowed(resolved.level, request.channel)) {
reasons.push(`channel-ceiling:${resolved.level}`);
}
return { allowed: reasons.length === 0, level: resolved.level, reasons };
}
export function auditAssets(assets: DataAsset[] = ASSETS): string[] {
const issues: string[] = [];
for (const asset of assets) {
const level = maxLevel(asset.fields.map((field) => field.level));
for (const channel of asset.channels) {
if (!channelAllowed(level, channel)) {
issues.push(`${asset.id}:channel-ceiling:${channel}`);
}
}
const limit = RETENTION_LIMIT[level];
if (limit !== undefined && asset.retentionDays === null) {
issues.push(`${asset.id}:retention-missing`);
}
if (limit !== undefined && asset.retentionDays !== null && asset.retentionDays > limit) {
issues.push(`${asset.id}:retention-too-long:${asset.retentionDays}`);
}
}
return issues;
}
判定函数返回的不是一个布尔值,而是 allowed 加 reasons。原因码让拒绝可解释,也让日志可以聚合:一周内 channel-ceiling:very-sensitive 出现了多少次,比「有一些违规」有用得多。
测试与真实输出
✔ 四个数据级别按由低到高排列 (0.916375ms)
✔ 最大级别在字段集合中胜出 (0.098292ms)
✔ 资产可以从 id 找回 (0.071291ms)
✔ 字段解析返回最大级别与未知字段 (0.100833ms)
✔ 字段级最小权限:只申请公开字段时级别为公开 (0.110791ms)
✔ 用途未声明时拒绝访问 (0.060125ms)
✔ 流向未声明时拒绝访问 (0.072541ms)
✔ 敏感字段流向第三方同时触发未声明与上限 (0.063917ms)
✔ 极敏感字段不允许导出 (0.065ms)
✔ 内部字段可以在声明过的第三方流向中使用 (0.103459ms)
✔ 极敏感字段在本机自有服务内允许使用 (0.06075ms)
✔ 未声明资产返回 unknown 级别 (0.056834ms)
✔ 空字段请求被拒绝而不是默认放行 (0.042667ms)
✔ 未知字段逐个列出后拒绝 (0.052583ms)
✔ 流向上限按级别判定 (0.052167ms)
✔ 默认资产清单通过分级审计 (0.086375ms)
✔ 审计能发现极敏感资产流向第三方 (0.053584ms)
✔ 审计能发现缺失与过长的保留期 (0.080875ms)
ℹ tests 18
ℹ suites 0
ℹ pass 18
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 130.146625
判据与反例
判据一:先解析字段,再谈授权
请求进来先解析字段集合,取出其中最高的级别。只申请 osVersion 时级别是 public,可以走第三方;一旦带上 deviceId,级别抬到 internal,规则随之变化。字段集合是判定的起点,不是附属信息。
反例一:用资产级标签代替字段级判定
「通讯录 = 敏感」看起来省事,实际会把 displayName 和 phone 绑在一起。更糟的是资产升级时所有字段一起升,业务只能整表申请或整表放弃,最后往往选择整表申请。
判据二:拒绝必须给出原因码
accessDecision 对未知资产、空字段、未知字段、用途未声明、流向未声明、流向上限分别给出原因。原因码不是给用户看的,是给开发和审计看的;没有它,排查只能靠猜。
反例二:空字段当成「全部字段」
空数组在不少接口里意味着「不筛选」,在权限判定里必须反过来:空字段意味着没有声明任何必要字段,直接拒绝。最小权限的第一条就是不允许隐式全量。
反例三:把「脱敏」当成降级通行证
手机号中间四位打码,仍然是手机号。脱敏是展示层控制,不改变数据级别;如果脱敏后的值仍可关联到个人,它依旧是 very-sensitive。要降级,得证明不可逆且不可关联,而不是改个显示格式。
判据三:审计与线上共用同一套规则
auditAssets 和 accessDecision 共用 channelAllowed 与保留期上限。审计脚本如果另写一套判断,迟早会出现「审计通过、线上拒绝」或更危险的反向偏差。
把判定放进请求链路
一次采集请求建议按固定顺序走:
- 业务声明资产 id、字段、用途、流向;
- 判定函数解析字段并取最大级别;
- 检查用途与流向是否在资产白名单内;
- 检查级别是否超过流向上限;
- 通过后写入访问日志,失败时记录原因码。
客户端判定只负责「不发起明显违规的请求」,它不是安全边界。服务端必须用同一份资产清单再判一次,因为客户端可以被改包、被 Hook,也可以被旧版本绕过。
保留期也是分级的一部分
敏感与极敏感数据必须声明保留期:敏感不超过一年,极敏感不超过三十天。没有保留期的敏感资产在审计里等同违规,因为「永久保存」不是一种策略,而是没人负责。
保留期到期后的删除要和清理任务绑定:本地缓存、埋点缓冲、画像存储分别清理,不能只删数据库里的一行。
一次「全部公开」的误判
某团队为了做分享排行,把用户昵称、头像、城市放进了公开接口。上线后发现城市加昵称足以定位到具体小区,被用户投诉。复盘时他们的理由是「这些信息本来就能看到」。
「本来能看到」不等于「公开级别」。公开级别要求任何人拿到都无法推断出更多信息;多个公开字段组合出敏感结论,是分级里最常见的漏洞。修正方式不是把接口下线,而是把城市降为 internal,排行榜只展示昵称与头像。
小结
数据分级不是文档工作,而是一组可执行的判定:
- 级别决定流向上限,字段决定这次请求的真实级别;
- 用途是白名单,流向是白名单,两者都必须命中;
- 空字段、未知字段一律拒绝,拒绝要带原因码;
- 客户端判定不是安全边界,服务端必须复核。
把「能不能用、给谁用、留多久」写进同一个函数,分级才真正开始工作。
阅读导航:上一节:7.3 从告警到定位的闭环 · 下一节:8.2 第三方 SDK 治理 。
常见错误与边界
- 把公开理解成「无主」:公开数据仍然有来源与用途,批量导出依旧要限流与审计。
- 只在客户端判定:改包与旧版本会绕过,服务端必须用同一份清单复核。
- 资产级一刀切:字段级判定才能支撑最小权限,否则业务只能整表申请。
- 用途写成布尔:
ads: true无法回答「为什么现在要用」,必须枚举具体目的。 - 忘记保留期:敏感资产没有保留期就是永久保存,审计应直接判违规。
- 脱敏即降级:可逆或可关联的脱敏不改变级别。
- 审计规则分叉:审计与线上必须调用同一个
channelAllowed。 - 忽略导出通道:导出到文件、剪贴板、分享卡片同样是数据流向。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。