被安全部门约谈了一次。
原因是日志里明文打印了用户手机号、身份证号,还被导出到了测试环境。这要是出了事,GDPR罚款能让公司破产。
花了两周时间做日志脱敏,整理一下方案。
为什么要日志脱敏
日志里经常会有:
- 手机号、身份证号
- 银行卡号、密码(别笑,真有人打印)
- 地址、邮箱
- Token、API Key
这些数据如果:
- 被运维人员看到 → 内部泄露风险
- 日志被导出 → 外部泄露风险
- 日志被攻击者获取 → 直接完蛋
方案一:代码层脱敏
最可控的方式,在打日志的地方处理。
1.1 自定义toString
publicclassUser{privateStringname;privateStringphone;privateStringidCard;@OverridepublicStringtoString(){return"User{"+"name='"+maskName(name)+'\''+", phone='"+maskPhone(phone)+'\''+", idCard='"+maskIdCard(idCard)+'\''+'}';}privateStringmaskPhone(Stringphone){if(phone==null||phone.length()!=11)returnphone;returnphone.substring(0,3)+"****"+phone.substring(7);}privateStringmaskIdCard(StringidCard){if(idCard==null||idCard.length()<10)returnidCard;returnidCard.substring(0,4)+"**********"+idCard.substring(idCard.length()-4);}privateStringmaskName(Stringname){if(name==null||name.length()<2)returnname;returnname.charAt(0)+"*".repeat(name.length()-1);}}打印出来:
User{name='张*', phone='138****5678', idCard='3201**********1234'}1.2 注解方式
更优雅一点,用注解标记敏感字段:
// 自定义注解@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)public@interfaceSensitive{SensitiveTypetype();}publicenumSensitiveType{PHONE,ID_CARD,NAME,BANK_CARD,EMAIL}// 使用注解publicclassUser{@Sensitive(type=SensitiveType.NAME)privateStringname;@Sensitive(type=SensitiveType.PHONE)privateStringphone;@Sensitive(type=SensitiveType.ID_CARD)privateStringidCard;}// 脱敏工具类publicclassSensitiveUtil{publicstaticStringmask(Objectobj){if(obj==null)return"null";Class<?>clazz=obj.getClass();StringBuildersb=newStringBuilder(clazz.getSimpleName()+"{");Field[]fields=clazz.getDeclaredFields();for(inti=0;i<fields.length;i++){Fieldfield=fields[i];field.setAccessible(true);Stringvalue;try{ObjectfieldValue=field.get(obj);if(field.isAnnotationPresent(Sensitive.class)){Sensitiveannotation=field.getAnnotation(Sensitive.class);value=doMask(String.valueOf(fieldValue),annotation.type());}else{value=String.valueOf(fieldValue);}}catch(IllegalAccessExceptione){value="N/A";}sb.append(field.getName()).append("='").append(value).append("'");if(i<fields.length-1)sb.append(", ");}returnsb.append("}").toString();}privatestaticStringdoMask(Stringvalue,SensitiveTypetype){if(value==null||"null".equals(value))returnvalue;switch(type){casePHONE:returnvalue.length()==11?value.substring(0,3)+"****"+value.substring(7):value;caseID_CARD:returnvalue.length()>=10?value.substring(0,4)+"**********"+value.substring(value.length()-4):value;caseNAME:returnvalue.length()>=2?value.charAt(0)+"*".repeat(value.length()-1):value;caseBANK_CARD:returnvalue.length()>=8?value.substring(0,4)+"****"+value.substring(value.length()-4):value;caseEMAIL:intatIndex=value.indexOf("@");returnatIndex>2?value.substring(0,2)+"***"+value.substring(atIndex):value;default:returnvalue;}}}使用:
log.info("用户信息: {}",SensitiveUtil.mask(user));// 输出:用户信息: User{name='张*', phone='138****5678', idCard='3201**********1234'}方案二:日志框架层脱敏
在Logback/Log4j2层面统一处理。
2.1 Logback自定义Converter
publicclassSensitivePatternConverterextendsClassicConverter{privatestaticfinalPatternPHONE_PATTERN=Pattern.compile("1[3-9]\\d{9}");privatestaticfinalPatternIDCARD_PATTERN=Pattern.compile("\\d{17}[\\dXx]");privatestaticfinalPatternEMAIL_PATTERN=Pattern.compile("[\\w.]+@[\\w.]+");@OverridepublicStringconvert(ILoggingEventevent){Stringmessage=event.getFormattedMessage();// 手机号脱敏message=PHONE_PATTERN.matcher(message).replaceAll(m->m.group().substring(0,3)+"****"+m.group().substring(7));// 身份证脱敏message=IDCARD_PATTERN.matcher(message).replaceAll(m->m.group().substring(0,4)+"**********"+m.group().substring(14));// 邮箱脱敏message=EMAIL_PATTERN.matcher(message).replaceAll(m->{Stringemail=m.group();intatIndex=email.indexOf("@");returnatIndex>2?email.substring(0,2)+"***"+email.substring(atIndex):email;});returnmessage;}}<!-- logback.xml --><configuration><conversionRuleconversionWord="sensMsg"converterClass="com.example.SensitivePatternConverter"/><appendername="CONSOLE"class="ch.qos.logback.core.ConsoleAppender"><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger - %sensMsg%n</pattern></encoder></appender></configuration>这样所有日志自动脱敏,无需改业务代码。
2.2 Log4j2 RewritePolicy
@Plugin(name="SensitiveRewritePolicy",category="Core",elementType="rewritePolicy")publicclassSensitiveRewritePolicyimplementsRewritePolicy{@OverridepublicLogEventrewrite(LogEventevent){Messagemessage=event.getMessage();StringformattedMessage=message.getFormattedMessage();// 脱敏处理StringmaskedMessage=maskSensitiveData(formattedMessage);returnnewLog4jLogEvent.Builder(event).setMessage(newSimpleMessage(maskedMessage)).build();}privateStringmaskSensitiveData(Stringmessage){// 同上,正则替换returnmessage;}}方案三:日志采集层脱敏
在Logstash/Filebeat采集时处理。
3.1 Logstash Filter
filter{# 手机号脱敏mutate{gsub=>["message","1[3-9]\d{9}","1**********"]}# 身份证脱敏mutate{gsub=>["message","\d{17}[\dXx]","****"]}# 银行卡脱敏mutate{gsub=>["message","\d{16,19}","****"]}}3.2 Filebeat Processor
processors:-script:lang:javascriptsource:>function process(event) { var message = event.Get("message"); // 手机号脱敏 message = message.replace(/1[3-9]\d{9}/g, function(match) { return match.substring(0,3) + "****" + match.substring(7); }); event.Put("message", message); }方案四:落盘后处理
如果历史日志已经有敏感信息,需要清洗。
#!/bin/bash# 日志脱敏脚本LOG_DIR="/var/log/app"# 手机号脱敏find$LOG_DIR-name"*.log"-execsed-i -E's/1([3-9])[0-9]{9}/1\1*******/g'{}\;# 身份证脱敏find$LOG_DIR-name"*.log"-execsed-i -E's/[0-9]{6}[0-9]{8}[0-9]{3}[0-9Xx]/******/g'{}\;最佳实践
分层防护
代码层 → 日志框架层 → 采集层 → 存储层 ↓ ↓ ↓ ↓ 不打印敏感 自动脱敏 二次脱敏 加密存储建议至少做两层。
敏感数据分类
| 级别 | 数据类型 | 处理方式 |
|---|---|---|
| L1绝密 | 密码、密钥 | 禁止打印 |
| L2机密 | 身份证、银行卡 | 强脱敏 |
| L3敏感 | 手机号、邮箱 | 弱脱敏 |
| L4普通 | 姓名、地址 | 可脱敏可不脱敏 |
禁止打印的内容
// 密码绝对不能打印log.info("用户登录: {}, 密码: {}",username,password);// 错误// Token不能打印log.info("Token: {}",token);// 错误// API Key不能打印log.info("调用第三方API, key={}",apiKey);// 错误正确做法:
// 只打印是否存在log.info("用户登录: {}, 密码: {}",username,password!=null?"[已设置]":"[未设置]");// Token只打印部分log.info("Token: {}...",token.substring(0,8));验证脱敏效果
写个测试用例:
@TestpublicvoidtestSensitiveMask(){Useruser=newUser();user.setName("张三");user.setPhone("13812345678");user.setIdCard("320123199001011234");Stringmasked=SensitiveUtil.mask(user);// 验证脱敏后不包含原始数据assertFalse(masked.contains("13812345678"));assertFalse(masked.contains("320123199001011234"));// 验证脱敏格式正确assertTrue(masked.contains("138****5678"));assertTrue(masked.contains("3201**********1234"));}日志安全审计
除了脱敏,还要控制日志访问权限。
我们有几台日志服务器分布在不同机房,之前权限管理很乱。现在用星空组网把所有节点连起来后,统一通过跳板机访问,权限管控清晰多了。
总结
日志脱敏方案选择:
| 方案 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| 代码层 | 最可控 | 需要改代码 | 新项目 |
| 日志框架层 | 全局生效 | 性能有损耗 | 老项目改造 |
| 采集层 | 不改代码 | 本地日志仍有敏感信息 | 补充方案 |
| 落盘后处理 | 处理历史数据 | 不实时 | 应急处理 |
核心原则:
- 能不打印的就不打印
- 必须打印的要脱敏
- 脱敏要分级
- 多层防护
日志安全这块有经验的欢迎交流~