欢迎光临
我们一直在努力

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

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

各位看官,这一篇聊一个平时不太起眼、出事就是大事的东西——敏感数据防泄露。

我们做的多租户 SaaS 系统,业务数据里塞满了手机号、微信号、邮箱等用户个人联系方式。这些在法律上叫 PII(个人敏感信息)。我做一次安全自查的时候发现一个挺吓人的事实:后台列表接口把手机号明文直接返回了。意思是,任何一个能进后台的客服、运营、甚至临时工,只要调一下列表接口,就能把全公司的客户联系方式一次性拖走。这要放在《个人信息保护法》和等保的尺子下量,就是实打规的违规。

更隐蔽的是,我原先以为"列表脱敏了就安全了",结果一查审计日志——好家伙,几个敏感动作的 detail 里又把手机号原样记了一遍。等于脱敏做在了前台,后台日志又把底裤扒了。

在这里插入图片描述

这篇就把我们这套"防泄露"的真实做法拆开讲:一层集中脱敏、一层按角色控制明文边界、一层审计日志自身递归脱敏。全是项目里跑着的代码,不是 PPT 上的方案。


一、第一道防线:敏感字段集中管理,禁止散落

最容易踩的坑,是"脱敏逻辑散落各路由"。A 路由记得脱敏手机号,B 路由忘了,C 路由新人加字段压根不知道要脱敏。最后必然是漏的。

我的做法是把"什么是敏感字段"收口成一个清单,所有脱敏都从它出发:

// 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 列表里的真实处理:

    // 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。

    在这里插入图片描述

    这是个角色相关的明文边界,不是一刀切。一刀切全脱敏,一线业务人员没法干活;一刀切全明文,管理员也能顺手拖走数据。按角色开口子,既保业务又能控风险。

    脱敏本身就这么几行,全是纯函数:

    // 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),那详情接口(本该返回明文)可能拿到的是已经被脱敏过的对象——因为列表查询和详情查询可能共用同一个行对象引用,或者被某个中间件缓存了。

    纯函数返回新对象,原始行永远是原始行。脱敏只是"返回给客户端前的一层投影",不动数据源。这句话值得刻在脑子里:脱敏是输出层的投影,不是存储层的修改。


    四、审计日志:谁在什么时候动了什么数据

    脱敏防的是"看",审计防的是"改和滥用"。光脱敏不够——万一有人把业务数据整库导出了,你连"谁导的、什么时候导的"都查不到,那等于没防。

    审计表结构:

    // 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 一堆字段"。我用一个中间件工厂把这件事收口,写操作零侵入:

    // 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:

    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 做一次递归脱敏:

    // 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)

    // 列表: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)

    // 拦截名单擦除 —— 高风险动作,必须留痕
    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 优化校阅,转发请注明首发地址,谢谢大家!

    赞(0)
    未经允许不得转载:171主机测评 » Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址