数据脱敏(Aegis.Security.DataMasking)
接口返回 JSON 时,把 DTO 上标注了 [Mask] 的 string 字段按规则打码成 *。规则放在独立的 maskrules.json 里,改规则文件即时生效,不用重启应用。只在输出侧打码:前端把 138****5678 提交回来,框架会当成真实值照单全收,写入侧的防护要自己做。
组件概览
| 字段 | 说明 |
|---|---|
| 组件名称 | 数据脱敏 |
| 真实类库 | Aegis.Security.DataMasking |
| 最新版本 | 3.1.4 |
| 组件定位 | 接口输出序列化阶段的字段打码 |
| 引入方式 | 安装 NuGet,Component.deps.json 声明 Mask |
| 核心能力 | [Mask] 标注、规则外置配置、四种打码部位(全部/前部/中部/后部)、规则热更新 |
| 主要配置 | MaskEnabled、MaskRules(maskrules.json) |
| 典型配套 | 加密工具、审计日志 |
什么时候要用它
适合场景:
- 接口会把手机号、身份证号、银行卡号、姓名、地址这类敏感字段返回给前端,需要打码后再出网
- 同一种敏感字段在多个 DTO 里出现,想统一维护打码规则,改一处全局生效
- 打码策略会随合规要求调整,希望改配置文件就能换规则,不发版
不适合场景:
- 存储侧或传输链路的加密(用加密工具,打码不可逆,不是加密)
- 防止前端回传伪造值(脱敏是单向的,写入校验自行处理)
- 非 string 字段(
int、DateTime标了[Mask]不处理,也没有任何提示)
最小可运行路径
第一步:安装包
dotnet add package Aegis.Security.DataMasking
第二步:Component.deps.json 声明 Mask
Load 列表加一条 Mask(BaseDemo 演示工程就是这种写法):
{
"Components": {
"Load": [
"Mask"
]
}
}
也可以用双列表写法,效果一样:
{
"Components": {
"Services": [ "Mask" ],
"Middlewares": [ "Mask" ]
}
}
两种写法等价:Load 是双阶段公共清单,Load ∪ Services 决定服务注册,Load ∪ Middlewares 决定中间件挂载。组件名匹配区分大小写,写 mask 装配不上。漏了声明不会有报错,[Mask] 标注直接没有效果,这是接入时最容易踩的坑。
用双列表写法时,只写 Services 脱敏功能本身是正常的(序列化挂载在服务阶段完成),但中间件阶段的终态校验不会执行,ContractResolver 被别的代码替换时你收不到告警。推荐直接用 Load 一条写法,两个阶段都在。
第三步:建规则文件,并在 appsettings.json 声明加载
appsettings.json:
{
"LoadConfigurations": [
"Configs/maskrules.json"
]
}
Configs/maskrules.json(节点名 MaskEnabled、MaskRules 是硬编码的,不可改名):
{
"MaskEnabled": true,
"MaskRules": [
{ "Name": "Phone", "Part": "Middle", "Digits": 4 },
{ "Name": "IdCard", "Part": "Middle", "Digits": 8 },
{ "Name": "BankCard", "Part": "Tail", "Digits": 4 },
{ "Name": "RealName", "Part": "All" }
]
}
确认规则文件会复制到运行目录(csproj 里对应 CopyToOutputDirectory),否则运行时读不到。
第四步:在 DTO 上标注
using Aegis.Security.DataMasking;
public class MaskDemoDto
{
[Mask("Phone")] public string Phone { get; set; } = "13812345678";
[Mask("IdCard")] public string IdCard { get; set; } = "110101199001011234";
[Mask("BankCard")] public string BankCard { get; set; } = "6222020200011234";
[Mask("RealName")] public string RealName { get; set; } = "张三";
public string DeptName { get; set; } = "心内科"; // 未标注,原样输出
public int Age { get; set; } = 32; // 非 string,不处理
}
[Mask("...")] 里的规则名对应配置里的 Name。规则名匹配忽略大小写:配置写 Phone、标注写 [Mask("phone")] 能命中。
第五步:调接口看输出
{
"Phone": "138****5678",
"IdCard": "11010********11234",
"BankCard": "622202020001****",
"RealName": "**",
"DeptName": "心内科",
"Age": 32
}
配置项说明
根节点
| 节点 | 必填 | 说明 |
|---|---|---|
MaskEnabled | 是 | 总开关,只接受 true / false。缺失、留空、null、无法解析为 bool 的值(1、yes)一律按 false 处理,即不脱敏 |
MaskRules | 是 | 规则数组。缺失或空数组时不脱敏 |
规则字段
| 字段 | 必填 | 缺省 | 说明 |
|---|---|---|---|
Name | 是 | "" | 规则名,与 [Mask("...")] 对应(忽略大小写)。为空的规则被丢弃 |
Part | 否 | All | 打码部位:All 全部 / Head 前部 / Middle 中部 / Tail 后部 |
Digits | 否 | 0 | 打码位数。Part 非 All 时必须配正整数,否则整条规则被丢弃;Part 为 All 时忽略此字段 |
打码效果
以 13812345678 为例,占位符固定为 *,输出长度恒等于原文长度:
| Part | Digits | 输出 |
|---|---|---|
All | 任意 | ***********(整串打码,忽略位数) |
Middle | 4 | 138****5678(保头保尾) |
Head | 3 | ***12345678 |
Tail | 4 | 1381234**** |
位数配多少就打多少,没有硬性上限;达到或超过串长时等同 All 整串打码。要全串打码直接用 Part: All。
规则校验与丢弃
启动时逐条校验规则,坏规则只丢自己,不影响其余规则:
| 问题 | 处理 | 后果 |
|---|---|---|
| 节点解析失败(类型不对) | 跳过该条 | 该规则不生效 |
Name 为空 | 跳过该条 | 该规则不生效 |
Part 不在枚举范围内 | 跳过该条 | 该规则不生效 |
Part 非 All 且 Digits ≤ 0 | 跳过该条 | 引用该规则名的字段原样输出真实值 |
Name 与已有规则重复(忽略大小写) | 保留最后一条 | 以最后一次配置为准 |
启动日志会输出「共配置:X 条,最终生效数:Y 条」。Y 小于 X,或者出现黄色告警,按告警里列出的规则名修 maskrules.json。
工作机制
打码发生在 MVC 输出序列化阶段。组件注册时调用 AddControllers().AddNewtonsoftJson(),把 MaskContractResolver 挂到 Newtonsoft 序列化设置上;序列化 DTO 时,凡是 string 类型且带 [Mask] 标注的属性,都会被挂上 MaskJsonConverter,在写 JSON 值的瞬间查规则打码。
规则查找与降级有三条固定路径:
| 情况 | 行为 |
|---|---|
总开关为 false 或未配置 | 规则表为空,字段按未命中处理,原样输出 |
| 规则名未命中(拼错、被校验丢弃) | 原样输出,不报错 |
| 读配置抛异常 | 降级为空规则表,原样输出,异常不上抛,配置没就绪也不会把接口打成 500 |
配置问题只体现为启动日志里的黄色告警和组件行的警告明细,主状态仍是成功,不会阻断启动。也就是说:脱敏失效是静默的,验证手段见下文「接入后怎么确认生效」。
中间件阶段还有一道终态校验:启动时检查 MVC 的 ContractResolver 是不是 MaskContractResolver。如果业务代码在组件注册之后又自定义了 ContractResolver,把脱敏解析器顶掉了,日志会打出「脱敏失效:ContractResolver 已被自定义解析器 X 替换」的告警。
规则热更新的边界:
- 改 maskrules.json 即时生效。规则文件按
reloadOnChange加载,每次 HTTP 输出都会重新读一遍规则(同一次输出内多个字段共用一份规则副本),不需要重启 - 给 DTO 新加
[Mask]标注需要重启。属性契约按类型缓存在序列化器里,运行中改标注不会被发现
自定义扩展
组件公开了两个可以直接调用的类型,供业务在非序列化场景复用打码能力:
| 类型 | 用法 |
|---|---|
Masker.Mask(value, part, digits) | 静态打码算法,按部位与位数返回掩码串,不依赖配置 |
MaskRuleProvider.GetRule(name) | 按规则名查单条规则(走配置与开关判定),未命中返回 null |
using Aegis.Security.DataMasking;
// 直接按部位打码,不经过配置
var masked = Masker.Mask("13812345678", MaskPartEnum.Middle, 4);
// 138****5678
// 按配置规则打码:总开关关闭或规则名未命中时拿到 null,要自己处理
var rule = MaskRuleProvider.GetRule("Phone");
if (rule != null)
{
var v = Masker.Mask("13812345678", rule);
}
MaskContractResolver 继承自 Newtonsoft 的 DefaultContractResolver,属性挂载逻辑在 CreateProperty 里,只处理「string 类型 + 带 [Mask]」的属性。想换挂载策略可以继承它重写。
接入后怎么确认生效
- 启动日志的 Mask 组件行出现「数据脱敏组件已注册:输出序列化已挂载 MaskContractResolver」
- 配置检查行出现「配置检查完毕,共配置:X 条,最终生效数:Y 条」,且 Y 等于你配置的条数、没有黄色告警
- 调一个返回
[Mask]字段的接口,确认输出是掩码串而不是真实值 - 有自定义 ContractResolver 的项目,确认日志里没有「已被自定义解析器替换」的告警
常见问题
装了包,[Mask] 完全没效果
Component.deps.json 没声明 Mask,或者组件名大小写写错。补上声明,注意是 Mask 不是 mask。漏声明不报错,只能从这里查。
接口正常,但字段输出的是真实值
按优先级查四处:
MaskEnabled缺失或为false(缺失按false处理)- 规则名与配置不一致(拼错、大小写之外的字面差异)
- 字段不是
string类型 Part非All但Digits没配正整数,整条规则被丢弃
后三种在启动日志里都有对应告警,先看日志里的规则名清单,再对照「最终生效数」。
数据库里存进了 138****5678
脱敏是单向的:前端把接口返回的掩码串原样提交,反序列化读回来的就是掩码串本身,框架不会还原也不会拦截。回传接口要么换 DTO(不带 [Mask] 的入参模型),要么在写入前校验格式。
同一个规则名,有的字段脱敏了有的没有
没脱敏的字段要么不是 string,要么运行时值是 null 或空串。空值不打码,原样输出,属预期行为。
改了规则文件不生效
两种可能:maskrules.json 没被 LoadConfigurations 声明加载;或者同名规则重复定义,以最后一条为准,改的不是生效那条。删掉重复定义。
新加 [Mask] 标注不生效
属性契约已缓存,重启应用。
配套阅读
- 加密工具(Aegis.Security.Encryption) — 存储与传输场景的可逆加解密,与打码互补
- 审计日志(Aegis.Security.AuditLog) — 业务操作留痕,配合脱敏做敏感数据治理