返回博客

PII 脱敏上线 13 天,拨打按钮静默失效——下游消费者审计的血泪教训

某次安全审计提了一个 P0 需求:接口返回的手机号必须脱敏,展示为 138****1234。我在 Presenter 层加了一行 maskPhone(),自测接口返回正常、代码审查通过、上线。

然后,什么都没发生——直到 13 天后,用户反馈「拨打按钮点了没反应」。

现象

接口字段 telephone 从原始号码变成了 138****1234。前端小程序的拨打按钮绑定的是同一个字段:

<button data-phone="{{item.telephone}}" bind:tap="onPhoneCall">拨打</button>
onPhoneCall(e) {
  wx.makePhoneCall({ phoneNumber: e.currentTarget.dataset.phone });
}

wx.makePhoneCall 内部会校验号码格式,遇到 138****1234 直接进 fail 回调——而这个回调在绝大多数存量代码里根本没有接线。用户点按钮,手机无反应,日志里一条错误都没有。

根因

脱敏操作发生在 API 层(Presenter / Resource / DTO),它只管「这个字段对外展示什么」,并不知道下游有哪些消费者在依赖原始值。以下场景全部会静默失效:

| 消费者 | 失效方式 | |---|---| | wx.makePhoneCall | fail 回调无声,按钮死掉 | | wx.setClipboardData | 成功写入剪贴板,但内容是脱敏串,粘贴出去毫无意义 | | <a href="tel:138****1234"> | 拨号器收到非法号码,iOS 困惑、Android 报"无效号码" | | 企微客服跳转 deeplink | 把脱敏串当字面量传入,客服侧收到的是乱码联系人 | | 第三方 SDK 绑定(短信、推送) | 以脱敏串作为用户标识写入 SDK,静默存储错误值 |

没有任何一条会抛出 console.error5xx,只有用户抱怨。

坑:Presenter 脱敏不等于全链路脱敏

maskPhone() 只改了接口出参,所有下游消费者——前端拨打、剪贴板复制、第三方 SDK 调用、数据导出——如果还在读同一个字段,就会静默拿到脱敏串并「成功」执行,产生死按钮、无效复制、错误标识等零报错的功能回归。

正解

上线脱敏前,先跑下游消费者 grep

# 小程序 / H5 / Vue / React 前端
grep -rn "wx.makePhoneCall" miniprogram/
grep -rn 'href="tel:' web/ h5/
grep -rn "wx.setClipboardData" miniprogram/
grep -rn "wx.openCustomerServiceChat" miniprogram/
 
# 后端 PHP / Java / Node
grep -rn "Sms::send\|SmsClient" application/
grep -rn "Aliyun\|JPush" application/
grep -rn "fputcsv\|exportExcel" application/

每一处命中都要判断:它需要原始值吗?是 → 走双字段或角色门控,不是 → 脱敏安全。

三种正确模式

Pattern A:字段本就是公开联系方式,直接不脱敏。 在 PR 里写明决策理由,不要为了看起来「安全」而盲目遮码。

Pattern B:双字段,展示用脱敏,行为用原始值。

// Presenter
$out['mobilePhone']    = self::maskPhone($user['mobile']); // 展示
$out['rawMobilePhone'] = $user['mobile'];                  // 行为绑定
<!-- 前端 -->
<text>{{item.mobilePhone}}</text>
<button data-phone="{{item.rawMobilePhone}}" bind:tap="onPhoneCall">拨打</button>

命名约定全项目统一:xxxDisplay / xxxRaw,选一种,用 lint 锁死。

Pattern C:按查看者角色分级返回。 匿名 → 隐藏,同级 → 脱敏,管理员 → 原始。但有一个致命前提:resolveViewerRole() 必须真的能区分角色。本案例里该方法有一个 TODO,始终返回 peer,"管理员看原始"层级从未生效。角色解析器有 TODO,就别上 Pattern C,退回 A 或 B。

正解:脱敏之前先 grep 所有下游消费者

把每个待脱敏字段在前后端全文搜索一遍,按消费者类型填决策矩阵(展示/行为/SDK/导出),逐一确认是否需要原始值,再决定用哪个模式。这一步只需 10 分钟,省去的是 13 天的静默故障。

顺带一提:脱敏撤销后记得清理孤儿样式类

本案例最终决定撤销脱敏(Pattern A),但此前为了在 UI 上传达「这是只读脱敏字段」,前端加了 class="form-text form-text--locked"。脱敏撤销后这个视觉降级类还挂在那,让用户误以为字段不可编辑。脱敏撤销后要 grep text-locked / text-masked / .pii- 把联动样式一并清掉。

一句话外卖

Presenter 层脱敏是局部操作,PII 字段的下游消费者是全局的——不 grep、不审计,静默功能回归只是时间问题。