一次邮件 DTO 日志脱敏的取舍 —— 三条泄露路径、两种方案以及为什么少打印一点比完全不打印更可取背景发邮件是一个独立的中间服务在这套体系里邮件发送不是某个业务系统的内部功能而是一个独立的中间服务。业务系统只负责组装邮件参数再通过 RPC 调用这个服务去完成投递业务系统 A ─┐ 业务系统 B ─┼──▶ 邮件服务(独立中间服务) ──▶ SMTP ──▶ 收件人 业务系统 C ─┘这个架构决定了三件事也是后面所有取舍的前提承载邮件参数的 DTO 是跨系统的传输对象。为了同时满足不同业务方的诉求它必须自洽地带齐全套字段 —— 业务类型、业务线、发件邮箱标识、收件人、抄送、标题、正文、附件、自定义发件人配置。业务系统把参数填完就丢过来邮件服务不再回头找业务方要数据。打印它的地方天然分散。每个业务系统都会在组装完参数、准备调用的位置顺手打一条日志日志点因此遍布各个业务方而不集中在邮件服务内部。想靠挨个改调用点来整改既不现实也一定会漏。正文和发件人配置必须能序列化传输。这条约束会在第三章直接排掉一个看起来很自然的写法(JSONField(serialize false))。于是问题的性质就清楚了这个被所有业务方共享的 DTO一旦把正文全文打进日志泄露面就横跨整个服务集群而不是局限在某一个应用内部 —— 这也正是它值得单独写一篇的原因。一、先说结论邮件正文不应该以全文形式出现在日志里。要改推荐第二种做法方案日志里的样子评价方案一不打印EmailMessageDTO(..., configDTOnull)安全但丢失可观测性方案二打印一部分EmailMessageDTO(..., content尊敬的客户您好附件为与贵司确认的...(291))推荐—— 同等安全且保住排查能力两者的实现都只在 DTO 里改一处所有已存在的日志调用点一个都不用动。二、为什么邮件正文进日志本身就该被去掉2.1 排查邮件问题时正文几乎从来不是关键信息真正用来定位一封邮件是否正常发出、发给了谁、走的哪个发件箱靠的是这些字段bizType 业务类型(哪类邮件) product 业务线 senderTag 发件邮箱标识(哪个发件箱) receivers 收件人 title 标题 attaches 附件(路径/文件名) configDTO 自定义发件人配置(host/port确认用的是哪个 SMTP)正文全文对定位问题的贡献极低但它带来的成本很高。2.2 正文里承载的是用户可见的敏感信息这是问题的核心。邮件正文与其他业务字段不同 —— 它天生就是给人看的所以内容里必然包含凭据类信息场景正文里的典型内容登录异常提醒邮件企业/账号名称、登录账号开通账号 / 重置密码邮件初始密码业务通知、推荐类邮件客户名称、手机号、公司名称、职位审批 / 工单通知邮件申请内容、附件说明而且问题不止于正文 —— 这类 DTO 里往往还挂着一个嵌套对象private SmtpConfigDTO configDTO; // 内含 username pwd(邮箱密码/授权码)configDTO.pwd里装的是真实发件邮箱的授权码而设置它的代码通常就紧挨着打日志的那一行。只处理content是不够的。2.3 体量HTML 邮件正文动辄几千字符。一条log.info({}, dto)就能往日志里灌几 KB在高频发信场景下足以把日志文件撑爆也会显著拖慢日志输出。三、动手之前先搞清日志的三条路径这是整件事里最容易被低估的一步。同一个 DTO可能被三种方式打印而它们对注解的反应完全不同路径写法ToString.Exclude是否有效说明toStringlog.info({}, dto)✅ 有效走的就是 Lombok 生成的toString()JSON 序列化log.info({}, JSON.toJSONString(dto))❌完全无效fastjson 绕过toString()直接序列化字段嵌套对象dto内的configDTO.pwd❌ 无效Data会把嵌套对象整体toString()这两种打印方式在实际代码里往往是混用的一部分调用点直接传对象另一部分套了JSON.toJSONString。只加注解只能覆盖一半。一个必须提前确认的约束content和configDTO都是要经 RPC 传给发信服务的字段所以JSONField(serialize false) // ❌ 绝对不能用 private String content;一旦禁掉序列化邮件正文根本传不到发信服务邮件直接发不出去。这个约束决定了一件事JSON 序列化这条路径注解层面堵不住只能靠日志里不要序列化这个对象来收敛。所以那些JSON.toJSONString(dto)的调用点要改成直接传对象把打印路径统一收敛到受保护的toString()// 改造前 log.info(发送邮件参数:{}, JSON.toJSONString(emailMessage)); // 改造后 —— 参数个数、占位符都不变日志格式从 JSON 变成 keyvalue log.info(发送邮件参数:{}, emailMessage);四、方案一不打印做法我们可以重写toString方法拼接字符串时去掉content字段。这里我们使用lombok的语法糖 —— 在 DTO 的content字段上加 ToString.Exclude 注解/** * 邮件正文可能包含验证码、初始密码等敏感信息。 * 字段真实值不参与 toString(ToString.Exclude)日志里不再输出。 */ ToString.Exclude ApiModelProperty(value 邮件正文) NotBlank(message 邮件正文不能为空) private String content;效果EmailMessageDTO(contentTypeTEXT, bizTypeALERT, productCAR, senderTagSEHENBIANYUN, receivers[opsexample.com], cc[], title预警邮件, attachesnull, configDTOSmtpConfigDTO(usernameopsexample.com, hostsmtp.exmail.qq.com, protocolsmtp, port25))content从toString()输出里彻底消失。字段本身完好getContent()照常返回全文发信与 RPC 均不受影响。代价可观测性归零这是它的问题。屏蔽之后日志里再也看不出正文是否正常。几个真实会遇到的排查场景正文是空串(模板变量没拼上)—— 日志上看不出本来就是空还是字段没传正文长度异常(平时 2000 字符这次只有 200)—— 完全看不出来拼接乱码 / 模板发错—— 完全看不出来想评估一次故障影响的正文规模—— 没有数据结果往往是真出问题的时候得临时把注解摘掉、重新发版才能看。这正是脱敏改造最常见的副作用。五、方案二打印一部分(推荐)思路是字段真实值依然不进toString()但额外提供一个预览方法参与输出。做法我们依然使用 lombok的语法糖来实现。/** * 邮件正文可能包含验证码、初始密码等敏感信息。 * 字段真实值不参与 toString(ToString.Exclude)日志里只输出 contentForLog() 的截断预览。 */ ToString.Exclude ApiModelProperty(value 邮件正文) NotBlank(message 邮件正文不能为空) private String content; /** 日志里正文保留的字符数超出部分省略 */ private static final int LOG_CONTENT_LIMIT 50; /** 省略号之后的长度标注格式形如 ...(180) */ private static final String LOG_CONTENT_LENGTH_FORMAT ...(%d); /** * 邮件正文的日志预览只保留前 50 个字符超长时附上原长度。 * 例尊敬的客户您好附件为与贵司确认的客户信息表请查收...(291)。 * 短内容原样返回null 返回 null。 * log.info({}, dto) 走的就是本方法的返回值因此新增日志无需再手工截断。 * 方法名刻意不带 get 前缀避免被 fastjson / Jackson / Dubbo 识别成属性而写进 RPC 报文。 */ ToString.Include(name content) private String contentForLog() { if (content null || content.length() LOG_CONTENT_LIMIT) { return content; } return content.substring(0, LOG_CONTENT_LIMIT) String.format(LOG_CONTENT_LENGTH_FORMAT, content.length()); }核心机制Lombok 的ToString.Include除了能标注字段也能标注方法—— 标注后toString()里输出的就是这个方法的返回值。效果EmailMessageDTO(contentTypeTEXT, bizTypeALERT, ..., title预警邮件, attachesnull, configDTOnull, content尊敬的客户您好附件为与贵司沟通确认的客户信息表请您确认信息表中...(291))一个 291 字符的正文(末尾还带着邮箱密码和手机号)在日志里变成了「50 字符预览 原长度」。为什么它比方案一更靠谱1. 保住了可观测性 —— 这是它最主要的优势有了预览和长度前面那些看不出来的场景全都可判断现象日志表现判断正文为空content模板变量没拼上正文长度骤降...(200)(平时...(2000))模板渲染异常发错模板开头 50 字符就能认出来一眼看出编码乱码预览开头就是乱码一眼看出正文为空 vs 字段缺失contentvscontentnull能区分2. 安全性基本不降级敏感数据在这类通知邮件里通常出现在中后段(您的验证码是 1623465 分钟内有效、请立即修改密码)50 字符的预览不足以泄露完整凭据。同时它也天然限定了单条日志的体量任何正文最多输出 50 字符 一个长度标注。3. 调用点零改动和方案一一样改的是 DTO 一处所有已存在的日志自动生效以后新写的日志也不会漏。它的边界(必须说清楚)方案二不是万无一失的。如果某个模板把敏感信息放在了正文开头 —— 例如开通账号类邮件的首句就是账号和初始密码 —— 那 50 个字符刚好会把它漏出去。对这类开头即敏感的场景有两个办法// 办法 A把阈值调小到安全范围 private static final int LOG_CONTENT_LIMIT 10; // 办法 B只输出长度不输出内容(介于方案一和二之间的变体) ToString.Include(name content) private String contentForLog() { return content null ? null : (len content.length() ); } // 输出content(len291)判断标准很简单看该模板的正文前 N 个字符是什么。开头是尊敬的客户您好这类模板套话 → 方案二直接用开头就是凭据 → 调小阈值或改用办法 B。两个可选调整想让短内容也带长度(如abc(3))把content.length() LOG_CONTENT_LIMIT分支改成同样拼接长度即可。按邮件类型区分阈值把LOG_CONTENT_LIMIT做成静态可配字段。六、别忘了嵌套对象处理完正文后还有一个更隐蔽的泄露点。发件人配置那个嵌套 DTO 通常也是DataData public class SmtpConfigDTO implements Serializable { private String username; // 发件邮箱账号 private String pwd; // 密码 / 授权码 ← 真实凭据 private String host; private String protocol smtp; private Integer port 465; }只要外层 DTO 被打印嵌套的configDTO就会跟着toString()pwd明文进日志。而设置它的代码往往就在打日志的上一行smtpConfig.setPwd(mailProperties.getPassword()); // ← 真实密码 emailMessage.setConfigDTO(smtpConfig); ... log.info(发送邮件参数dto{}, emailMessage); // ← 密码跟着出去了同样加一个注解即可/** * 密码/授权码含敏感信息禁止进日志(ToString.Exclude 让 log.info({}, dto) 不输出本字段) */ ToString.Exclude private String pwd;这条经验值得记下来排除敏感字段时必须再向外问一层 ——「这个对象里还有没有别的对象」。嵌套 DTO 是脱敏改造最常漏的地方。七、落地时的三个注意点1. 方法名绝不能带get前缀ToString.Include(name content) private String contentForLog() { ... } // ✅ ToString.Include(name content) private String getContentForLog() { ... } // ❌ fastjson/Jackson/Dubbo 会把它当属性带get前缀会被序列化框架识别成属性RPC 报文里会平白多出一个字段还可能影响反序列化。用contentForLog()这种形式。2.name content不能省不加的话日志里会显示成contentForLog...而不是content...可读性变差。3.content在toString()里的位置会变Lombok 的生成顺序是「先所有字段再Include方法」所以content会从原来title后面挪到整行末尾。如果必须保持原位置只能手写整个toString()(字段一多维护成本高不划算)。小结邮件正文不该进日志—— 排查价值低泄露风险高体量还大同一个 DTO 里往往还藏着邮箱授权码。先摸清有几条路径——toString走注解「JSON.toJSONString序列化」和「嵌套对象」两条都堵不住需要分别处置。而且这些字段要经 RPC 传输不能用JSONField(serialize false)。推荐方案二——ToString.Exclude管住真实值再用ToString.Include标注一个返回截断预览的私有方法。安全性不打折却把正文是否为空、长度是否异常、模板是否发错这些排查能力留了下来。配套要做两件事—— 嵌套 DTO 的密码字段一起排除JSON.toJSONString(dto)的日志统一改成直接传对象。唯一要评估的边界—— 正文开头即敏感信息的模板需要调小阈值或改成只输出长度。
