Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战

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(身份证),只改这一处,全站生效。

设计的核心不是"怎么脱敏",而是"在哪定义敏感字段"。定义散了,脱敏就保不住。


二、脱敏时机与明文边界

脱敏有两个层面要分清:

  1. 列表 / 投影层:任何返回多条数据的接口,敏感字段一律脱敏。
  2. 明文边界:谁能在什么场景看到明文?

第二条是最容易被忽略的。我定的规矩是------明文只从"详情接口"和"导出(且记审计)"返回,而且导出还得看角色 。看 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 必须是纯函数

注意 maskRowsrows.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 手写 ipdevice

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),
  });
};

ipcf-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-passwordlead.eraseblocklist.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 经过的每个通道"都过一遍脑子。我们这版做完,自查时再没找到第二个裸奔出口。

各位看官,如果你的后台列表现在还明文返回手机号,建议今晚就排个期------这事儿,真出事比写代码贵得多。


相关阅读

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
八角丶4 小时前
Node.js Cluster 详解
前端·node.js
濮水大叔8 小时前
CabloyJS 强大之处不仅仅是 IoC,而是全栈资源的寻址体系
typescript·node.js·全栈
烂蜻蜓9 小时前
Node.js入门教程(二十六):内置模块
node.js
东方小月9 小时前
从零开发一个 Coding Agent(十一):实现 CLI 的 print 模式
node.js·全栈
__zRainy__10 小时前
Node系列 · Node基础:ES 模块化
node.js
深念Y11 小时前
Opencode Event 表写入优化方案
数据库·人工智能·ai·node.js·bug·优化·opencode
ikun778g12 小时前
DeepSeek Harness 本地部署保姆级教程:从 Node.js 24.0.0 安装到 WorkBuddy 一键运行
ai·node.js
烂蜻蜓1 天前
Node.js入门教程(二十三):全局对象
node.js·编辑器·vim
xywww1681 天前
Node.js Claude API 实战接入:SDK 调用 Opus 5、环境变量配置与报错排查
node.js