跳到主要内容
版本:3.1

数据脱敏(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 不写 Middlewares 的差别

用双列表写法时,只写 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 为例,占位符固定为 *,输出长度恒等于原文长度:

PartDigits输出
All任意***********(整串打码,忽略位数)
Middle4138****5678(保头保尾)
Head3***12345678
Tail41381234****

位数配多少就打多少,没有硬性上限;达到或超过串长时等同 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。漏声明不报错,只能从这里查。

接口正常,但字段输出的是真实值​

按优先级查四处:

  1. MaskEnabled 缺失或为 false(缺失按 false 处理)
  2. 规则名与配置不一致(拼错、大小写之外的字面差异)
  3. 字段不是 string 类型
  4. Part 非 All 但 Digits 没配正整数,整条规则被丢弃

后三种在启动日志里都有对应告警,先看日志里的规则名清单,再对照「最终生效数」。

数据库里存进了 138****5678​

脱敏是单向的:前端把接口返回的掩码串原样提交,反序列化读回来的就是掩码串本身,框架不会还原也不会拦截。回传接口要么换 DTO(不带 [Mask] 的入参模型),要么在写入前校验格式。

同一个规则名,有的字段脱敏了有的没有​

没脱敏的字段要么不是 string,要么运行时值是 null 或空串。空值不打码,原样输出,属预期行为。

改了规则文件不生效​

两种可能:maskrules.json 没被 LoadConfigurations 声明加载;或者同名规则重复定义,以最后一条为准,改的不是生效那条。删掉重复定义。

新加 [Mask] 标注不生效​

属性契约已缓存,重启应用。

配套阅读​