Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
Node 后端实战 · 后端敏感数据怎么防泄露PII 自动脱敏与审计日志实战各位看官这一篇聊一个平时不太起眼、出事就是大事的东西——敏感数据防泄露。我们做的多租户 SaaS 系统业务数据里塞满了手机号、微信号、邮箱等用户个人联系方式。这些在法律上叫 PII个人敏感信息。我做一次安全自查的时候发现一个挺吓人的事实后台列表接口把手机号明文直接返回了。意思是任何一个能进后台的客服、运营、甚至临时工只要调一下列表接口就能把全公司的客户联系方式一次性拖走。这要放在《个人信息保护法》和等保的尺子下量就是实打规的违规。更隐蔽的是我原先以为列表脱敏了就安全了结果一查审计日志——好家伙几个敏感动作的detail里又把手机号原样记了一遍。等于脱敏做在了前台后台日志又把底裤扒了。这篇就把我们这套防泄露的真实做法拆开讲一层集中脱敏、一层按角色控制明文边界、一层审计日志自身递归脱敏。全是项目里跑着的代码不是 PPT 上的方案。一、第一道防线敏感字段集中管理禁止散落最容易踩的坑是脱敏逻辑散落各路由。A 路由记得脱敏手机号B 路由忘了C 路由新人加字段压根不知道要脱敏。最后必然是漏的。我的做法是把什么是敏感字段收口成一个清单所有脱敏都从它出发// lib/mask.ts/** §9.4.E 敏感字段清单 */exportconstSENSITIVE_FIELDS[phone,wechat,contactPhone,contactEmail]asconst;/** 判断字段是否敏感 */exportconstisSensitive(field:string):boolean(SENSITIVE_FIELDSasreadonlystring[]).includes(field);就这么一个常量数组是单一真相源。路由层、审计层、导出层全都引用它而不是各写各的phone字面量。以后要加个idCard身份证只改这一处全站生效。设计的核心不是怎么脱敏而是在哪定义敏感字段。定义散了脱敏就保不住。二、脱敏时机与明文边界脱敏有两个层面要分清列表 / 投影层任何返回多条数据的接口敏感字段一律脱敏。明文边界谁能在什么场景看到明文第二条是最容易被忽略的。我定的规矩是——明文只从详情接口和导出且记审计返回而且导出还得看角色。看leads.ts列表里的真实处理// routes/tenant/leads.ts// raw1 仅 TA 可见明文其余一律脱敏constsensitiveparams.raw1user.roleROLES.TA?[]:q.sensitiveFields;constitemsmaskRows(rows,sensitive);returnpaginate(c,items,total,q.page,q.size);ROLES.TA是系统中需要直接联系客户的一线业务角色。逻辑是只有这个角色TA调列表时带raw1才能看到客户手机号明文——因为他要直接联系客户。管理员、运营看列表手机号永远是138****1234。这是个角色相关的明文边界不是一刀切。一刀切全脱敏一线业务人员没法干活一刀切全明文管理员也能顺手拖走数据。按角色开口子既保业务又能控风险。脱敏本身就这么几行全是纯函数// lib/mask.ts/** 手机号脱敏国内11位 → 138****1234其它号码保留前3后2、中间打码 */exportconstmaskPhone(phone:string|null|undefined):string{if(!phone)return;if(/^1\d{10}$/.test(phone))return${phone.slice(0,3)}****${phone.slice(7)};if(phone.length6)return${phone.slice(0,3)}${*.repeat(phone.length-5)}${phone.slice(-2)};if(phone.length2)return${phone.slice(0,1)}${*.repeat(phone.length-1)};return*;};/** 通用文本脱敏wechat / 邮箱等保留首尾各1中间打码 */exportconstmaskGeneric(value:string|null|undefined):string{if(!value)return;if(value.length1)return*;if(value.length2)return${value[0]}*;return${value.slice(0,1)}${*.repeat(value.length-2)}${value.slice(-1)};};/** 按字段名脱敏单值 */exportconstmaskField(field:string,value:unknown):unknown{if(valuenull||valueundefined)returnvalue;constsString(value);if(fieldphone||fieldcontactPhone)returnmaskPhone(s);if(fieldwechat||fieldcontactEmail)returnmaskGeneric(s);returnvalue;};/** 对行集合批量脱敏敏感字段列表/投影统一调用 */exportconstmaskRowsTextendsRecordstring,unknown(rows:readonlyT[],sensitiveFields:readonlystring[],):T[]rows.map((row){constout:Recordstring,unknown{...row};for(constfofsensitiveFields){if(finout)out[f]maskField(f,out[f]);}returnoutasT;});maskField按字段名分派策略手机号走maskPhone保留前 3 后 4中间 4 个星微信/邮箱走maskGeneric保留首尾各 1。之所以分开是因为手机号国人习惯看前三位运营商 后四位脱成138****1234既保护又方便人眼核对是不是自己而微信号、邮箱没这个习惯首尾各留一个够辨认就行。脱敏规则我直接贴单元测试的断言比文字描述靠谱输入字段输入值脱敏结果说明phone13812341234138****1234国内 11 位标准脱敏contactPhone13812341234138****1234同手机号策略wechatwxid_abcw******c首尾各 1中间 6 星contactEmailab.coma*****m邮箱首尾保留name张三张三非敏感字段原样返回最后一行是关键非敏感字段如name一律原样返回。maskRows只动清单里的字段不会误伤业务数据。三、为什么 maskRows 必须是纯函数注意maskRows是rows.map(...)出新对象从不原地修改入参。这点我特意做成铁律原因很实际从 D1 查出来的原始行往往会进缓存、或被同一请求里的多个环节共用。如果你原地row.phone maskPhone(row.phone)那详情接口本该返回明文可能拿到的是已经被脱敏过的对象——因为列表查询和详情查询可能共用同一个行对象引用或者被某个中间件缓存了。纯函数返回新对象原始行永远是原始行。脱敏只是返回给客户端前的一层投影不动数据源。这句话值得刻在脑子里脱敏是输出层的投影不是存储层的修改。四、审计日志谁在什么时候动了什么数据脱敏防的是看审计防的是改和滥用。光脱敏不够——万一有人把业务数据整库导出了你连谁导的、什么时候导的都查不到那等于没防。审计表结构// db/schema.tsexportconstauditLogssqliteTable(audit_logs,{id:text(id).primaryKey(),// 审计记录主键(UUID)tenantId:text(tenant_id),// 所属租户平台级操作为 NULLactorId:text(actor_id),// 操作人 IDactorRole:text(actor_role),// 操作人角色(审计用)action:text(action).notNull(),// 动作标识(如 user.create / lead.erase)entityType:text(entity_type),// 实体类型(如 user / lead / tenant)entityId:text(entity_id),// 实体 IDdetail:text(detail),// 操作详情(JSON)敏感字段已脱敏ip:text(ip),// 操作来源 IPactorDevice: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.tsexportconstauditMiddleware(action:string|((c:ContextAppBindings)string),getEntity?:(c:ContextAppBindings){entityType?:string;entityId?:string;detail?:unknown;},):MiddlewareHandlerAppBindingsasync(c,next){awaitnext();if(c.res.status200c.res.status300){constatypeofactionfunction?action(c):action;constegetEntity?.(c)??{};awaitrecordAudit(c,{action:a,...e});}};两个设计点值得说第一只记 2xx 成功路径。异常由全局错误处理器拦截不会走到这里所以不会记。为什么要这样假设你改密码的请求因为校验失败返回 400如果无脑记一条change-password审计里就会出现一条他改了密码但其实没改成的假记录排查时误导人。只有真正成功2xx才记审计才可信。第二recordAudit自动补全上下文不用每个 handler 手写ip、deviceexportconstrecordAuditasync(c:ContextAppBindings,meta:AuditMeta):Promisevoid{constdbgetDb(c.env);constuserc.get(user);awaitdb.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-ipCloudflare 边缘真实客户端 IPdevice取 UA。这些在溯源时比谁操作的还重要——同一账号半夜从陌生 IP 导出数据IP 能直接暴露异常。六、审计日志本身也会泄露 PIIBIZ-13这一节是全篇最该划重点的。我前面说列表脱敏了就安全但审计日志的detail是个 JSON 字符串里面可能藏着手机号。比如导出数据这个动作detail 里可能记了导出条件而条件里带了个手机号。如果你不处理等于脱敏做在列表PII 又从审计日志的缝里溜出去了。所以recordAudit在落库前对detail做一次递归脱敏// middleware/audit.ts/** 已知敏感字段清单审计 detail 内出现时自动脱敏 */constSENSITIVE_AUDIT_KEYSnewSet([phone,contactPhone,wechat,contactEmail]);/** 脱敏审计 detail 中的已知敏感字段BIZ-13防止 PII 泄露到审计日志 */constsanitizeDetail(detail:unknown):unknown{if(typeofdetail!object||detailnull)returndetail;if(Array.isArray(detail))returndetail.map(sanitizeDetail);constout:Recordstring,unknown{...(detailasRecordstring,unknown)};for(const[k,v]ofObject.entries(out)){if(SENSITIVE_AUDIT_KEYS.has(k)typeofvstring){out[k]maskPhone(v);}elseif(typeofvobjectv!null){out[k]sanitizeDetail(v);// 递归处理嵌套对象}}returnout;};两个细节递归detail可能是{ target: { phone: 138... } }这种嵌套结构只处理第一层会漏。递归把每一层的敏感 key 都扒出来脱敏。复用maskPhone审计层和列表层用的是同一套脱敏函数保证列表里看到的138****1234和审计里记的138****1234完全一致不会出现两套规则对不上的尴尬。这一步就是 BIZ-13 那个设计点——它提醒我凡是 PII 可能经过的通道都要过一遍脱敏审计日志是被最容易忘的那条通道。七、调用点实战脱敏与审计怎么挂到业务上光有库函数不够得看它怎么嵌进路由。挑两个典型业务数据列表 / 详情leads.ts// 列表raw1 仅 TA 明文其余脱敏constsensitiveparams.raw1user.roleROLES.TA?[]:q.sensitiveFields;constitemsmaskRows(rows,sensitive);// 详情同样脱敏后返回const[maskedLead]maskRows([lead],sensitive);列表和详情走同一套sensitive判定保证列表脱敏、详情按角色给明文的规则一致。敏感动作记审计blocklist.ts / auth.ts// 拦截名单擦除 —— 高风险动作必须留痕awaitrecordAudit(c,{action:blocklist.erase,entityType:blocklist,entityId:id});// 改密码 —— 单独 action可追溯awaitrecordAudit(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 星详情接口raw1且 TA 角色、导出记审计wechat首尾各 1中间打码同上contactEmail首尾各 1中间打码同上name等非敏感不脱敏所有接口审计动作是否记审计记录内容登录 / 登出是actor、ip、device改密码是forceReset 标记全设备登出是目标用户导出数据是导出条件PII 已脱敏擦除 / 删除是实体类型与 ID只读查询否量大无意义靠访问日志层级职责防什么集中清单SENSITIVE_FIELDS定义什么是敏感散落遗漏投影层maskRows列表/返回脱敏后台裸奔角色明文边界raw1 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 优化校阅转发请注明首发地址谢谢大家