PAYATHON 2026

Kotlin / Spring Boot:如何避免构造函数空指针异常日志泄露敏感数据?

排障赵师傅

可以减少逐字段改造,优先从日志输出处控制:使用允许字段清单,只记录已确认可输出的技术性 ID、日志时间和诊断信息。业务值默认省略,需要保留字段结构时,用 *** 代替。Kotlin / Spring Boot 没有能识别所有敏感数据并自动脱敏的通用开关。

重写 toString() 可以辅助控制对象的文本输出。如果继续使用 StringValue、DateValue 等包装类型,JPA 的 @Converter(autoApply = true) 通常能减少逐字段添加 @Convert 的工作。这些措施都无法覆盖全部异常日志。

先确认参数从哪里输出

普通 JVM 异常堆栈包含类名、方法名、文件名和行号,不会自动打印构造函数所有参数的运行时值。日志中出现构造函数签名,也不代表输出了构造函数实参。

仅凭问题描述,还无法确定泄露是否来自 Kotlin 本身。可以用合成数据复现,检查以下来源:

  • 应用记录了整个对象,例如 logger.error("创建失败:{}", input, ex)。
  • 异常消息拼接了输入,例如 require(valid) { "Invalid input: $input" }。
  • JSON 反序列化、参数绑定或校验异常附带了输入片段、被拒绝的值。
  • 数据库异常、SQL 参数日志或请求日志记录了原始内容。
  • 外层异常包装了内层异常,敏感值出现在 Caused by 中。

Kotlin data class 自动生成的 toString() 会包含主构造函数中声明的属性,记录整个对象时可能泄露数据。即使覆盖了这个方法,框架仍可能直接读取属性,或通过 JSON 序列化输出原值。

排查时,分别检查日志正文、异常消息、cause、suppressed 和结构化字段,找到最早将敏感值转成文本的位置。

用允许字段清单约束业务日志

如果要求“除技术性 ID 和时间戳外都隐藏”,让日志只接收安全字段最容易审查。

记录一次操作失败,通常只需要固定事件码、服务端生成的操作 ID 和经过处理的诊断信息。姓名、出生日期、病历内容无需传入日志;如果日志格式要求保留这些字段,就直接写固定的 ***。

应避免这样的调用:

logger.error("创建失败,input={}", input, exception)

这段调用有两个独立风险:input 的字符串表示和 exception 的完整异常链都可能泄露数据。SLF4J 的 {} 只负责参数化格式化,不提供脱敏能力。

允许输出的技术性 ID 和时间戳也需要明确范围。服务端生成的操作 ID、日志发生时间,与患者编号、出生日期、就诊时间的敏感性不同,不能仅凭数据类型决定是否允许输出。

异常需要单独处理

即使业务日志不再记录对象,下面的写法仍可能输出含有敏感值的异常消息:

logger.error("创建失败", exception)

如果异常文本尚未经过安全审查,可以在统一异常处理边界提取允许输出的信息。例如,只记录异常类型和当前异常的堆栈帧,不将原始 Throwable 交给日志框架:

import org.slf4j.LoggerFactory
import java.util.UUID

private val logger = LoggerFactory.getLogger("SafeEvents")

fun logCreationFailure(operationId: UUID, error: Exception) {
    val stack = error.stackTrace.joinToString("\n")

    logger.error(
        "event=PATIENT_CREATE_FAILED operationId={} errorType={} stack={} details=***",
        operationId,
        error.javaClass.name,
        stack
    )
}

这里的事件码、异常类型和代码位置属于预先允许输出的诊断元数据,operationId 应由服务端生成。

这种写法省略了异常消息,也没有展开原因链,可用的诊断信息会减少。如果需要保留 cause 和 suppressed,应限制遍历深度、处理循环引用,并且每一层都只提取允许的信息,不能直接调用异常的 toString() 或 printStackTrace()。

在 Spring MVC 中,可以通过 @RestControllerAdvice 统一处理控制器异常。过滤器、后台任务、消息消费和启动失败等路径仍需分别检查,统一处理器无法撤回其他组件已经写出的日志。

返回给客户端的错误响应也应使用固定错误码和关联 ID,避免直接返回 exception.message。

保留包装类型时,用自动应用转换器减少注解

如果包装类型有业务价值,例如需要在类型层面区分普通字符串和敏感字符串,可以继续保留,并为各类型配置自动应用的 JPA 转换器:

import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter

data class StringValue(val value: String) {
    override fun toString(): String = "***"
}

@Converter(autoApply = true)
class StringValueConverter : AttributeConverter<StringValue, String> {

    override fun convertToDatabaseColumn(
        attribute: StringValue?
    ): String? = attribute?.value

    override fun convertToEntityAttribute(
        dbData: String?
    ): StringValue? = dbData?.let { StringValue(it) }
}

持久化单元发现转换器后,适用的 StringValue 持久化属性通常无需逐一声明 @Convert。DateValue 也可以采用这种方式,数据库侧类型应与实际日期语义和映射保持一致。使用 javax.persistence 的项目需要相应调整导入。

自动应用仍受 JPA 转换规则约束。ID、版本属性、关联属性,以及显式使用 @Enumerated、@Temporal 等映射的属性,不能一概按普通基本属性处理,需要检查现有映射是否适用。

使用时还要注意:

  • toString() 只影响使用它的文本输出。
  • 直接访问或序列化 value,仍可能暴露原值。
  • 转换器会把原值交给数据库,无法阻止 JDBC 参数日志泄露。
  • AttributeConverter 不提供数据库加密。

不要为了脱敏,给所有 String 属性自动套用同一个转换器。持久化转换器负责数据库映射,无法判断字符串在日志中是否敏感。

如果包装类型只是为了改变日志显示,优先修改日志调用和对象在日志中的表示,通常能减少对实体模型、查询及映射的影响。

集中脱敏必须覆盖异常和结构化字段

可以在日志框架的布局、编码器或事件处理层增加集中脱敏,作为补充措施。仅对最终消息字符串做正则替换,往往无法覆盖所有内容。

一条日志可能同时包含:

  • 格式化后的消息和参数;
  • 异常消息及完整异常链;
  • MDC、结构化键值和 JSON 字段;
  • 独立输出的请求或数据库信息。

例如,Logback 中针对 %msg 的替换规则不会自动处理另行输出的异常堆栈。JSON 日志编码器也可能分别生成消息、异常和 MDC 字段。

如果无法确认异常消息是否安全,可以统一将其显示为 ***,保留允许输出的类型和堆栈位置。结构化业务数据则按字段清单输出。一条正则表达式无法识别所有姓名、诊断内容和自由文本。

控制台、文件、远程采集等输出目的地都应采用相同规则。只在日志平台入库后脱敏,无法防止原始内容先进入标准输出或本地文件。

检查框架日志,并用合成数据验证

上线前应检查这些常见输出位置:

  • 请求体、响应体、查询参数、请求头和 HTTP 客户端日志;
  • 参数校验结果中的 rejectedValue,以及包含输入值的校验消息;
  • Hibernate SQL、JDBC 绑定参数、数据库异常和代理数据源日志;
  • APM、错误上报平台收集的请求内容、异常上下文;
  • 临时调试日志、println 和 printStackTrace()。

关闭 SQL 参数日志只能减少其中一条泄露路径。数据库返回的错误文本仍可能包含冲突值或其他业务内容,也需要按异常输出策略处理。

验证时,为不同字段设置容易识别的合成标记,触发构造失败、反序列化失败、校验失败和数据库约束失败,再检查控制台、日志文件、采集平台与客户端响应。既要断言原始标记没有出现,也要确认事件码、操作 ID 和允许的堆栈信息仍然可用。更换序列化组件、日志编码器或数据库驱动后,应重新检查相关路径。

备注:内容仅供参考。