Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
各位看官,这一篇聊一个平时不太起眼、出事就是大事的东西------敏感数据防泄露。
我们做的多租户 SaaS 系统,业务数据里塞满了手机号、微信号、邮箱等用户个人联系方式。这些在法律上叫 PII(个人敏感信息)。我做一次安全自查的时候发现一个挺吓人的事实:后台列表接口把手机号明文直接返回了。意思是,任何一个能进后台的客服、运营、甚至临时工,只要调一下列表接口,就能把全公司的客户联系方式一次性拖走。这要放在《个人信息保护法》和等保的尺子下量,就是实打规的违规。
更隐蔽的是,我原先以为"列表脱敏了就安全了",结果一查审计日志------好家伙,几个敏感动作的 detail 里又把手机号原样记了一遍。等于脱敏做在了前台,后台日志又把底裤扒了。

这篇就把我们这套"防泄露"的真实做法拆开讲:一层集中脱敏、一层按角色控制明文边界、一层审计日志自身递归脱敏。全是项目里跑着的代码,不是 PPT 上的方案。
一、第一道防线:敏感字段集中管理,禁止散落
最容易踩的坑,是"脱敏逻辑散落各路由"。A 路由记得脱敏手机号,B 路由忘了,C 路由新人加字段压根不知道要脱敏。最后必然是漏的。
我的做法是把"什么是敏感字段"收口成一个清单,所有脱敏都从它出发:
ts
// lib/mask.ts
/** §9.4.E 敏感字段清单 */
export const SENSITIVE_FIELDS = ["phone", "wechat", "contactPhone", "contactEmail"] as const;
/** 判断字段是否敏感 */
export const isSensitive = (field: string): boolean =>
(SENSITIVE_FIELDS as readonly string[]).includes(field);
就这么一个常量数组,是单一真相源 。路由层、审计层、导出层全都引用它,而不是各写各的 "phone" 字面量。以后要加个 idCard(身份证),只改这一处,全站生效。
设计的核心不是"怎么脱敏",而是"在哪定义敏感字段"。定义散了,脱敏就保不住。
二、脱敏时机与明文边界
脱敏有两个层面要分清:
- 列表 / 投影层:任何返回多条数据的接口,敏感字段一律脱敏。
- 明文边界:谁能在什么场景看到明文?
第二条是最容易被忽略的。我定的规矩是------明文只从"详情接口"和"导出(且记审计)"返回,而且导出还得看角色 。看 leads.ts 列表里的真实处理:
ts
// routes/tenant/leads.ts
// raw=1 仅 TA 可见明文;其余一律脱敏
const sensitive = params.raw === "1" && user.role === ROLES.TA ? [] : q.sensitiveFields;
const items = maskRows(rows, sensitive);
return paginate(c, items, total, q.page, q.size);
ROLES.TA 是系统中需要直接联系客户的一线业务角色。逻辑是:只有这个角色(TA)调列表时带 raw=1 才能看到客户手机号明文------因为他要直接联系客户。管理员、运营看列表,手机号永远是 138****1234。

这是个角色相关的明文边界,不是一刀切。一刀切全脱敏,一线业务人员没法干活;一刀切全明文,管理员也能顺手拖走数据。按角色开口子,既保业务又能控风险。
脱敏本身就这么几行,全是纯函数:
ts
// lib/mask.ts
/** 手机号脱敏:国内11位 → 138****1234;其它号码保留前3后2、中间打码 */
export const maskPhone = (phone: string | null | undefined): string => {
if (!phone) return "";
if (/^1\d{10}$/.test(phone)) return `${phone.slice(0, 3)}****${phone.slice(7)}`;
if (phone.length > 6) return `${phone.slice(0, 3)}${"*".repeat(phone.length - 5)}${phone.slice(-2)}`;
if (phone.length > 2) return `${phone.slice(0, 1)}${"*".repeat(phone.length - 1)}`;
return "*";
};
/** 通用文本脱敏(wechat / 邮箱等):保留首尾各1,中间打码 */
export const maskGeneric = (value: string | null | undefined): string => {
if (!value) return "";
if (value.length <= 1) return "*";
if (value.length === 2) return `${value[0]}*`;
return `${value.slice(0, 1)}${"*".repeat(value.length - 2)}${value.slice(-1)}`;
};
/** 按字段名脱敏单值 */
export const maskField = (field: string, value: unknown): unknown => {
if (value === null || value === undefined) return value;
const s = String(value);
if (field === "phone" || field === "contactPhone") return maskPhone(s);
if (field === "wechat" || field === "contactEmail") return maskGeneric(s);
return value;
};
/** 对行集合批量脱敏敏感字段(列表/投影统一调用) */
export const maskRows = <T extends Record<string, unknown>>(
rows: readonly T[],
sensitiveFields: readonly string[],
): T[] =>
rows.map((row) => {
const out: Record<string, unknown> = { ...row };
for (const f of sensitiveFields) {
if (f in out) out[f] = maskField(f, out[f]);
}
return out as T;
});
maskField 按字段名分派策略:手机号走 maskPhone(保留前 3 后 4,中间 4 个星),微信/邮箱走 maskGeneric(保留首尾各 1)。之所以分开,是因为手机号国人习惯看前三位运营商 + 后四位,脱成 138****1234 既保护又方便人眼核对是不是自己;而微信号、邮箱没这个习惯,首尾各留一个够辨认就行。
脱敏规则我直接贴单元测试的断言,比文字描述靠谱:
| 输入字段 | 输入值 | 脱敏结果 | 说明 |
|---|---|---|---|
phone |
13812341234 |
138****1234 |
国内 11 位标准脱敏 |
contactPhone |
13812341234 |
138****1234 |
同手机号策略 |
wechat |
wxid_abc |
w******c |
首尾各 1,中间 6 星 |
contactEmail |
a@b.com |
a*****m |
邮箱首尾保留 |
name |
张三 |
张三 |
非敏感字段原样返回 |
最后一行是关键:非敏感字段(如 name)一律原样返回 。maskRows 只动清单里的字段,不会误伤业务数据。
三、为什么 maskRows 必须是纯函数
注意 maskRows 是 rows.map(...) 出新对象,从不原地修改入参。这点我特意做成铁律,原因很实际:
从 D1 查出来的原始行,往往会进缓存、或被同一请求里的多个环节共用。如果你原地 row.phone = maskPhone(row.phone),那详情接口(本该返回明文)可能拿到的是已经被脱敏过的对象------因为列表查询和详情查询可能共用同一个行对象引用,或者被某个中间件缓存了。
纯函数返回新对象,原始行永远是原始行。脱敏只是"返回给客户端前的一层投影",不动数据源。这句话值得刻在脑子里:脱敏是输出层的投影,不是存储层的修改。
四、审计日志:谁在什么时候动了什么数据
脱敏防的是"看",审计防的是"改和滥用"。光脱敏不够------万一有人把业务数据整库导出了,你连"谁导的、什么时候导的"都查不到,那等于没防。
审计表结构:
ts
// db/schema.ts
export const auditLogs = sqliteTable(
"audit_logs",
{
id: text("id").primaryKey(), // 审计记录主键(UUID)
tenantId: text("tenant_id"), // 所属租户;平台级操作为 NULL
actorId: text("actor_id"), // 操作人 ID
actorRole: text("actor_role"), // 操作人角色(审计用)
action: text("action").notNull(), // 动作标识(如 user.create / lead.erase)
entityType: text("entity_type"), // 实体类型(如 user / lead / tenant)
entityId: text("entity_id"), // 实体 ID
detail: text("detail"), // 操作详情(JSON);敏感字段已脱敏
ip: text("ip"), // 操作来源 IP
actorDevice: text("actor_device"), // 操作设备标识(UA 或设备号)
createdAt: integer("created_at").notNull(), // 创建时间(unix秒)
},
(t) => ({
idxTenant: index("idx_audit_tenant").on(t.tenantId),
idxEntity: index("idx_audit_entity").on(t.entityType, t.entityId),
idxCreatedAt: index("idx_audit_created_at").on(t.createdAt),
}),
);
三个索引不是随便建的,对应三种真实查询姿势:
idx_audit_tenant:租户管理员只想看自己租户 的审计,按tenantId过滤。idx_audit_entity:追溯某条具体数据(比如某条lead记录)被谁动过,按entityType + entityId查。idx_audit_created_at:安全排查按时间范围捞("上周三半夜谁导的数据")。
tenantId 允许为 NULL,对应平台超级管理员(PSA)的跨租户操作------平台级动作不归属任何租户,但照样要记,只是查的时候走另一条路径。

五、审计中间件:成功路径才记
记审计最容易写成"在 handler 里手动 insert 一堆字段"。我用一个中间件工厂把这件事收口,写操作零侵入:
ts
// middleware/audit.ts
export const auditMiddleware =
(
action: string | ((c: Context<AppBindings>) => string),
getEntity?: (c: Context<AppBindings>) => {
entityType?: string;
entityId?: string;
detail?: unknown;
},
): MiddlewareHandler<AppBindings> =>
async (c, next) => {
await next();
if (c.res.status >= 200 && c.res.status < 300) {
const a = typeof action === "function" ? action(c) : action;
const e = getEntity?.(c) ?? {};
await recordAudit(c, { action: a, ...e });
}
};
两个设计点值得说:
第一,只记 2xx 成功路径。 异常由全局错误处理器拦截,不会走到这里,所以不会记。为什么要这样?假设你改密码的请求因为校验失败返回 400,如果无脑记一条"change-password",审计里就会出现一条"他改了密码"但其实没改成的假记录,排查时误导人。只有真正成功(2xx)才记,审计才可信。
第二,recordAudit 自动补全上下文 ,不用每个 handler 手写 ip、device:
ts
export const recordAudit = async (c: Context<AppBindings>, meta: AuditMeta): Promise<void> => {
const db = getDb(c.env);
const user = c.get("user");
await db.insert(auditLogs).values({
id: crypto.randomUUID(),
tenantId: user?.tenantId ?? null,
actorId: user?.id ?? null,
actorRole: user?.role ?? null,
action: meta.action,
entityType: meta.entityType ?? null,
entityId: meta.entityId ?? null,
detail: meta.detail != null ? JSON.stringify(sanitizeDetail(meta.detail)) : null,
ip: clientIp(c) || null,
actorDevice: clientDevice(c) || null,
createdAt: Math.floor(Date.now() / 1000),
});
};
ip 取 cf-connecting-ip(Cloudflare 边缘真实客户端 IP),device 取 UA。这些在溯源时比"谁操作的"还重要------同一账号半夜从陌生 IP 导出数据,IP 能直接暴露异常。
六、审计日志本身也会泄露 PII(BIZ-13)
这一节是全篇最该划重点的。我前面说"列表脱敏了就安全",但审计日志的 detail 是个 JSON 字符串,里面可能藏着手机号。
比如"导出数据"这个动作,detail 里可能记了导出条件,而条件里带了个手机号。如果你不处理,等于脱敏做在列表,PII 又从审计日志的缝里溜出去了。
所以 recordAudit 在落库前对 detail 做一次递归脱敏:
ts
// middleware/audit.ts
/** 已知敏感字段清单(审计 detail 内出现时自动脱敏) */
const SENSITIVE_AUDIT_KEYS = new Set(["phone", "contactPhone", "wechat", "contactEmail"]);
/** 脱敏审计 detail 中的已知敏感字段(BIZ-13:防止 PII 泄露到审计日志) */
const sanitizeDetail = (detail: unknown): unknown => {
if (typeof detail !== "object" || detail === null) return detail;
if (Array.isArray(detail)) return detail.map(sanitizeDetail);
const out: Record<string, unknown> = { ...(detail as Record<string, unknown>) };
for (const [k, v] of Object.entries(out)) {
if (SENSITIVE_AUDIT_KEYS.has(k) && typeof v === "string") {
out[k] = maskPhone(v);
} else if (typeof v === "object" && v !== null) {
out[k] = sanitizeDetail(v); // 递归处理嵌套对象
}
}
return out;
};
两个细节:
- 递归 :
detail可能是{ target: { phone: "138..." } }这种嵌套结构,只处理第一层会漏。递归把每一层的敏感 key 都扒出来脱敏。 - 复用
maskPhone:审计层和列表层用的是同一套脱敏函数,保证"列表里看到的138****1234"和"审计里记的138****1234"完全一致,不会出现两套规则对不上的尴尬。
这一步就是 BIZ-13 那个设计点------它提醒我:凡是 PII 可能经过的通道,都要过一遍脱敏,审计日志是被最容易忘的那条通道。
七、调用点实战:脱敏与审计怎么挂到业务上
光有库函数不够,得看它怎么嵌进路由。挑两个典型:
业务数据列表 / 详情(leads.ts)
ts
// 列表:raw=1 仅 TA 明文,其余脱敏
const sensitive = params.raw === "1" && user.role === ROLES.TA ? [] : q.sensitiveFields;
const items = maskRows(rows, sensitive);
// 详情:同样脱敏后返回
const [maskedLead] = maskRows([lead], sensitive);
列表和详情走同一套 sensitive 判定,保证"列表脱敏、详情按角色给明文"的规则一致。
敏感动作记审计(blocklist.ts / auth.ts)
ts
// 拦截名单擦除 ------ 高风险动作,必须留痕
await recordAudit(c, { action: "blocklist.erase", entityType: "blocklist", entityId: id });
// 改密码 ------ 单独 action,可追溯
await recordAudit(c, { action: "user.change-password", entityType: "user", entityId: user.id, detail: { forceReset } });
// 全设备登出 ------ 用中间件工厂,零侵入
auditMiddleware("logout-all", (c) => ({ entityType: "user", entityId: c.get("user")?.id })),
注意动作命名是 实体.动作 的层级(user.change-password、lead.erase、blocklist.erase)。这样审计查询时能按前缀聚合------"这个用户所有 *.erase 动作"一眼就能捞出来。
我整理了三张边界表,方便对照:
| 敏感字段 | 脱敏规则 | 明文出现位置 |
|---|---|---|
phone / contactPhone |
保留前 3 后 4,中间 4 星 | 详情接口(raw=1 且 TA 角色)、导出(记审计) |
wechat |
首尾各 1,中间打码 | 同上 |
contactEmail |
首尾各 1,中间打码 | 同上 |
name 等非敏感 |
不脱敏 | 所有接口 |
| 审计动作 | 是否记审计 | 记录内容 |
|---|---|---|
| 登录 / 登出 | 是 | actor、ip、device |
| 改密码 | 是 | forceReset 标记 |
| 全设备登出 | 是 | 目标用户 |
| 导出数据 | 是 | 导出条件(PII 已脱敏) |
| 擦除 / 删除 | 是 | 实体类型与 ID |
| 只读查询 | 否 | 量大无意义,靠访问日志 |
| 层级 | 职责 | 防什么 |
|---|---|---|
集中清单 SENSITIVE_FIELDS |
定义什么是敏感 | 散落遗漏 |
投影层 maskRows |
列表/返回脱敏 | 后台裸奔 |
角色明文边界 raw=1 & TA |
按角色放开明文 | 一刀切误伤业务 |
审计 recordAudit |
记录谁动了数据 | 滥用无痕 |
审计递归脱敏 sanitizeDetail |
日志自身不泄露 PII | 日志二传泄露 |
八、收个尾
这套东西写出来不复杂,但每一层都是踩过(或差点踩过)坑才定下来的:
- 集中清单解决"漏脱敏"------单一真相源,加字段只改一处;
- 投影层脱敏 + 按角色明文边界解决"后台裸奔"------列表永远脱敏,明文只给需要直接联系客户的角色;
- 审计日志 + 递归脱敏解决"滥用无痕"和"日志二传泄露"------既记得住谁动的,又保证日志自己不变成新的泄露点。
数据合规不是上线前补一张表的事,是把"PII 经过的每个通道"都过一遍脑子。我们这版做完,自查时再没找到第二个裸奔出口。
各位看官,如果你的后台列表现在还明文返回手机号,建议今晚就排个期------这事儿,真出事比写代码贵得多。
相关阅读
- Node 后端实战 · Serverless 导出 CSV 总超时?用 Queue + R2 异步任务彻底解决
- Node 后端实战 · 多租户 SaaS 的数据隔离
- Node 后端实战 · JWT 双密钥轮转与 token 版本号
- Node 后端实战 · D1 那些坑
- Node 后端实战 · Cloudflare Workers 踩坑实录
- Node 后端实战 · 架构决策全景
- NodeJS Koa 后端用户会话管理,JWT, Session,长短Token,本文一次性讲明白
- node 后端和浏览器前端,有关 RSA 非对称加密的完整实践
- Nodejs 实现 Mysql 数据库的全量备份的代码演示
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!