Java项目日志脱敏解决方案
目录
- 一、通过历史项目所使用技术,以及网上高频方法,大概是4种方案
- 二、日志脱敏的难度描述:
- 三、各方案优缺点
- 四、方案二的代码演示
- 1.不吹不黑
- 2.示例背景
- 3.示例要求
- 4.示例方案设计
应合规要求。。。。巴拉、巴拉、巴拉。故对日志中客户的敏感信息进行脱敏便提上了日程。
一、通过历史项目所使用技术,以及网上高频方法,大概是4种方案
- 单脱敏工具类,对日志输出的地方单独进行脱敏处理
- 脱敏工具类+日志框架的切面方法转换器上统一脱敏
- 脱敏框架-----注解模式
- 脱敏框架-----工具类+配置模式
二、日志脱敏的难度描述:
- 参数命名、数据库字段命名未标准化,或者标准化不高
- 日志输出非单一字段输出
- 无法明确日志输出包含哪些脱敏范围
- 日志输出代码量很大,很难一处一处进行评估
三、各方案优缺点
1.方案一:单脱敏工具类,对日志输出的地方单独进行脱敏处理
优点:效率最高缺点:侵入性最高、且工作量最大总结:可以解决几乎所有项目的问题,但是侵入性太高,耦合性太高,几乎不再使用2.方案二:脱敏工具类+日志框架的切面方法转换器上统一脱敏优点:侵入性最低、且工作量最小缺点:效率最低,需对所有可能的情况进行脱敏判断,且误杀风险最高总结:可以解决几乎所有项目的问题,日志超长,一般会进行截取,使用率最高,但是若日志量巨大可能会使用日志框架进行辅助3.方案三:脱敏框架-----注解模式(如:sensitive)优点:方法简单,使用方便,效率较高,可以解决大部分场景的脱敏缺点:侵入性较高,需对所有可能的情况进行脱敏判断总结:新项目,或者项目重构以及数据标准化程度较高会考虑使用,对于自定义日志输出、请求日志等很不友好(加了注解才脱敏嘛)4.方案四:脱敏框架-----工具类+配置模式(如:desensitize)优点:侵入性低、且工作量最小缺点:易漏杀,需所有日志输出按框架特定格式进行输出总结:可以解决几乎所有项目的问题,对于项目标准化要求较高,新项目或者重构,推荐指数比方案三高。但是针对多为字段(如证件号码字段,不同证件类型,证件号码规范不一样),多释义字段(如:number:号码,可能代表证件号码、还可能代表数量)不太友好。四、方案二的代码演示
(方案一绝对不会用的,方案三、方案四有很多框架的详解,就不赘述
所以这里重点介绍方案二。)1.不吹不黑
市场上大多数项目面对合规时,项目基本成型,特别是较大的项目,一般都是会分成不同的大项目团队,不同团队针对的业务重叠的部分,字段命名也时常不一样,日志输出对不同的开发者有不同的习惯,故想要使用方案三和方案四,很难全面、低耦合、低工作量的完成。那么方案二的缺点可能是最能接受的。
2.示例背景
- 数据有做标准化,但程度相对较低
- 项目相对庞大,且较为完善的基础上进行日志脱敏改造
- 整个项目结构分为 前台门户 + 后台门户 + 多个后端服务 + 多个第三方服务
- 日志输出有{对象名:对象}方式、{对象}方式、字符串拼接对象方式、第三方请求体、XML、sql等
- 日志框架都是logback
3.示例要求
- 合规要求的所有类型数据不管字段名,必须全部脱敏
- 代码侵入最小
- 不影响服务正常功能
- 对纯数字类型脱敏,要求能够解码(本项目特殊要求,合规是不想允许这样的)
4.示例方案设计
logback.xml配置
<conversionRule conversionWord="msg" converterClass="com.test.mask.SensitiveDataConverter"> </conversionRule>
转换器(SensitiveDataConverter)代码
public class SensitiveConverter extends MessageConverter { @Override public String convert(ILoggingEvent event){ // 获取原始日志 String requestLogMsg = event.ge编程客栈tFormattedMessage(); // 获取返回脱敏后的日志 isLogMaskEnabled全局公共配置,是否脱敏开关 return GlobalConfig.isLogMaskEnabled() ? LogSensitiveUtils.filterSensitive(requestLogMsg) : requestLogMsg; } }
实际处理工具类(LogSensitiveUtils)代码-可以提取到公共包中进行依赖管理即可
public class LogSensitiveUtils { /** * [邮箱]@前隐藏<例子:138******1234> * 字段加数字的可逆掩码 以及纯数字采取可逆掩码 * 其他的对脱敏部分采取根据字段长度取中间的进行脱敏 * * @param content * @return */ public static String filterSensitive(String content) { try { if (!StringUtils.isBlank(content)) { for (Map.Entry<String, List<Pattern>> entry : LogSensitiveConstants.SENSITIVE_SEQUENCE.entrySet()) { content = filter(content, entry.getKey(), entry.getValue()); } } return content; } catch (Exception e) { return content; } } /** * 数字的可逆掩码 * * @param content 需脱敏字符串 * @param type 采取的脱敏方式 * @param patterns 该方式下需匹配的正则 * @return * @author hh * @date 2021年10月18日 */ public static String filter(String content, String type, List<Pattern> patterns) { for (Pattern pattern : patterns) { Matcher matcher = pattern.matcher(content); StringBuffer sb = new StringBuffer(); while (matcher.find()) { matcher.appendReplacement(sb, Matcher.quoteReplacement(basesensitive(matcher.group(), type))); } matcher.appendTail(sb); content = sb.toString(); } return content; } /** * 基础纯鏇字脱敏处理 指定起止展示长度 剩余用"KEY"中字符替换 * 非纯数字脱敏处理 * [邮箱] @前隐藏<例亍:******@.163> * * @param str 待脱敏的字符串 * @return * @author hh * @date 2021年10月18日 */ private static String basesensitive(String str, String type) { int startLength, endLength = 0; if (StringUtils.isBlank(str)) { return StringUtils.EMPTY; } // 默认脱敏从第4个字符开始掩码 startLength = 3; endLength = getEndLength(str.length()); if (LogSensitiveConstants.EMAIL.equals(type)) { endLength = str.length() - str.indexOf('@'); } if (LogSensitiveConstants.FIELD_NUM.equals(type)) { Matcher matcher = LogSensitiveConstants.NUMBER_PATTERN.matcher(str); int start = -1; int end = -1; String ss = ""; if (matcher.find()) { ss = matcher.group(); } start = str.indexOf(ss); while (matcher.find()) { ss = matcher.group(); } end = str.lastIndexOf(ss); int length = end - start; endLength = getEndLength(length); startLength = start CrNUB+ startLength; } String replacement = str.substring(startLength, str.length() - endLength); StringBuilder sb = new StringBuilder(); if (LogSensitiveConstants.NUM.equals(type) || LogSensitiveConstants.FIELD_NUM.equals(type)) { for (int i = 0; i < replacement.length(); i++) { char ch; if (replacement.charAt(i) >= '0' && replacement.charAt(i) <= '?') { ch = LogSensitiveConstants.KEY.charAt((int) (replacement.charAt(i) - '0')); } else { ch = replacement.charAt(i); } sb.append(ch); } } else { for (int i = 0; i < replacement.length(); i++) { sb.append("*"); } } return StringUtils.left(str, startLength).concat(StringUtils.leftPad(StringUtils.right(str, endLength), str.length() - startLength, sb.toString())); }
正则表达式常量类(LogSensitiveConstants)-- 可以提取到公共包中进行依赖管理即可
public class LogSensitiveConstants { /** * 数字脱敏掩码字符 */ public static final String KEY = "oiZeAsGTbQ"; public sta编程客栈tic final Pattern NUMBER_PATTERN = Pattern.compile("\\d"); /** * 脱敏掩码类型标识 */ public static final String EMAIL = "email"; public static final String FIELD_NUM = "field_num"; public static final String FIELD = "field"; public static final String NOT_NUM = "not_num"; public static final String NUM = "num"; /** * 过滤先后顺序:邮箱-->字段加数字的可逆掩码-->其他非数字掩码-->字段加非数字掩码-->数字可逆掩码 * 顺序原因: * 1.邮箱@前可能被其他正则先脱敏,但是邮箱有特殊脱敏要求,故优先进行脱敏 * 2.纯数字和非字段前缀校验的非纯数字放最后是因为,纯数字涵盖范围与其他的有重叠,减少误杀的方式,最好就是范围大的放最后 * 3.字段前缀校验的两种情况,其实不分顺序,同理,非字段前缀的两种类型,也不分前后 */ public static final Map<String,List<Pattern>> SENSITIVE_SEQUENCE = new TreeMap<String, List<Pattern>>(); /** * 数字:手机号、身份证号 */ public static final List<Pattern> SENSITIVE_NUM_KEY = new ArrayList<Pattern>(4); /** * 过滤顺序:身份证号-->手机号-->座机号-->QQ-->营业执照-->税务登记号+台湾往来通行证+户口簿+身份证号(纯数字)-->回乡证港澳往来内地通行证 */ public static final List<Pattern> SENSITIVE_NOT_NUM_KEY = new ArrayList<Pattern>(7); /** * 字段过滤(非纯数字): */ public static final List<Pattern> SENSITIVE_FIELD_KEY = new ArrayList<Pattern>(6); /** * 字段过滤(纯数字): */ public static final List<Pattern> SENSITIVE_FIELD_NUM = new ArrayList<Pattern>(3); /** * 邮箱过滤: */ public static final List<Pattern> SENSITIVE_EMAIL_KEY = new ArrayList<Pattern>(1); /** * 手机号正则匹配 */ public static final String TEL_REGEX = "^1[23456789]\\d{9}$"; /** * 电话号码正则匹配 */ public static final String PHONE_REGEX = "^0\\d{2,3}-\\d{7,8}$"; /** * 身份证号正则匹配 */ public static final String IDENTIFY_REGEX = "(^[1-9]\\d{7}((0\\d)|(1[0-2]))(([0|1|2]\\d)|3[0-1])\\d{3}$)|(^[1-9]\\d{5}[1-9]\\d{3}((0\\d)|(1[0-2]))(([0|1|2]\\d)|3[0-1])\\d{3}([0-9]|X)$)"; /** * 邮箱正则匹配 */ public static final String EMAIL_REGEX = "([^a-zA-Z0-9._%-]|^)([a-zA-Z0-9_\\.-]+)@([\\da-zA-Z\\.-]+)\\.([a-zA-Z\\.]{2,6})" + "([^a-zA-Z\\d]|$)|([^a-zA-Z\\d]|^)[a-zA-Z\\d]+(\\.[a-z\\d]+)*@([\\da-zA-Z](-[\\da-zA-Z])?)+(\\.{1,2}[a-zA-Z]+)+$/([^a-zA-Z]|$)"; /** * 护照号 * 护照号根据护照类型,规则不同,其中部分规则纳入到手机号、LICENSE_NO_REGEX */ private static final String PASSPORT_REGEX = "(\\D|^)[P|pS|s]\\d{7}(\\b)"; /** * 统一社会信用代码: ^([0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXV]{10}1[1-9]\d{14})$ */ private static final String USCC_REGEX = "([^0-9A-HJ-NPQRTUWXY]|^)([0-9A-HJ-NPQRTUWXV]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}|[1-9]\\d{14})(\\b)"; /** * 组织机构代码证: [a-zA-Z0-9]{8}-[a-ZA-Z0-9] */ private static final String OCC_REGEX = "([^a-zA-Z0-9]|^)([a-zA-Z0-9]{8})-[a-zA-Z0-9](\\b)"; /** * 警官证: ([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4}) */ private static final String POLICE_REGEX = "([^a-zA-Z0-9_\\.\\-]|^)(([a-zA-Z0-9_\\.\\-])+\\@(([a-zA-Z0-9\\-])+\\.)+([a-zA-Z0-9]{2,4}))(\\b)"; /** * 军人/武警身份证件 ^[\u4E00-\u9FA5](字第)([0-9a-zA-Z]{4,8})(号?)$/ */ private static final String SOLDIER_REGEX = "([^\\u4E00-\\u9FA5]|^)([\\u4E00-\\u9FA5](字第)([0-9a-zA-Z]{4,8})(号?))"; /** * MAC */ private static final String MAC_REGEX = "[A-F0-9]{2}([-:][A-F0-9]{2})([-:.][A-F0-9]{2})([-:][A-F0-9]{2})([-:.][A-F0-9]{2})([-:][A-F-9]{2})(\\b)"; /** * 车牌: ([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}(([0-9]{5}[DF])|([DF]([A-HJ-NP-Z0-9])[0-9]{4})))|([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]{1}) * 普通汽车:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]{1} * 新能源车:[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}(([0-9]{5}[DF])|([DF][A-HJ-NP-Z0-9][0-9]{4})) */ private static final String LICENSEE_CAR_REGEX = "[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}(([0-9]{5}[DF])|([DF][A-HJ-NP-Z0-9][0-9]{4})|([A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]{1}))"; /** * IP * IPV4: ((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9][01]?[0-9][0-9]?) * IPV6: ([0-9a-fA-F]{1,4}::?){1,7}([0-9a-fA-F]{1,4}) */ private static final String IP_REGEX = "(((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9][01]?[0-9][0-9]?))|(([0-9a-fA-F]{1,4}::?){1,7}([0-9a-fA-F]{1,4}))"; /** * 姓名: 用户/客户名称/创建人/报告人 [\u4E00-\u9FA5]{2,12} * cust_name/repr_client_name/USER_NAME/ACCT_NAME/CREATER_NAME */ private static final String USER_REGEX = "(\"?)((cust(_?)name)|(repr(_?)client(_?)name)|(repr(_?)client(_?)name)|(USER(_?)NAME)|(ACCT(_?)NAME)|(CREATER(_?)NAME))(\"?)(:|=)(\"?)[\\u4E00-\\u9FA5]{2,12}(\"?)"; /** * 微信正则匹配 */ private static final String WECHART_REGEX = "(\"?)wechat(\"?)(:|=)(\"?)[a-zA-Z]([-_a-zA-Z0-9]{5,19})(\"?)"; /** * 生日正则匹配 屏蔽有效数据 即1900-01-01~2099-12-31时间段的数据 */ private static final String BIRTH_DATE_REGEX = "(\"?)birth(_?)date(www.devze.com\"?)(:|=)(\"?)(19|20)\\d{2}([.|_|年]?)(1[0-2]|0?[1-9])([.|_|年]?(0?[1-9]|[1-2]|[0-9]|3[0-1])(\"?))"; /** * 毕业院校正则匹配 */ private static final String GRADUATE_INSTITUTIONS_REGEX = "(\"?)gradate(_?)institutions(\"?)(:|=)(\"?)[\\u4E00-\\u9FA5]{4,18}(\"?)"; /** * 籍贯 */ private static final String NATIVE_PLACE_REGEX = "(\"?)native(_?)place(\"?)(:|=)(\"?)[\\u4E00-\\u9FA5]{2,18}(\"?)"; /** * 地址: * ADDR\ADDRESS\ADDRDETAILS\addressLines */ private static final String ADDR_REGEX = "[\\u4E00-\\u9FA5][#()()A-Z0-9\\u4E00-\\u9FA5]{1,20}(省|市|区|镇|县|乡|村|屯|路|街|组|号|小区|室|单元)" + "|[#()()A-Z0-9\\u4E00-\\u9FA5]{1,20}(省|市|区|镇|县|乡|村|屯|路|街|组1号|小区|室|单元)[#()()0-9a-z\\u4E00-\\u9FA5]{0,20}"; /** * 银行账号: ([1-9]{1})(\\d{14,18}) * acctno\accountno */ private static final String ACCTNO_REGEX = "((\"?)((ACCT(_?)NO)|(ACCOUNT(_?)NO))(\"?)(:|=)(\"?)([1-9](\\d{14,18}))(\"?))"; /** * QQ正则匹配: [1-9][8-9]{4,12} * qq */ private static final String QQ_REGwww.devze.comEX = "(\"?)qq(\"?)(:|=)(\"?)[1-9][8-9]{4,12}(\"?)"; /** * 营业执照号和税务登记证 ([A-Z0-9]{15}|[A-Z0-9]{18}|[A-Z0-9]{20}) * licence_no\tax_no */ private static final String LICENSE_NO_REGEX = "(\"?)((licence(_?)no)|(tax(_?)no))(\"?)(:|=)(\"?)([A-Z0-9]{15}|[A-Z0-9]{18}|[A-Z0-9]{20})(\"?)"; static { SENSITIVE_NUM_KEY.add(Pattern.compile(TEL_REGEX)); SENSITIVE_NUM_KEY.add(Pattern.compile(PHONE_REGEX)); SENSITIVE_NUM_KEY.add(Pattern.compile(IDENTIFY_REGEX)); SENSITIVE_NUM_KEY.add(Pattern.compile(PASSPORT_REGEX)); } static { SENSITIVE_NOT_NUM_KEY.add(Pattern.compile(USCC_REGEX)); SENSITIVE_NOT_NUM_KEY.add(Pattern.compile(OCC_REGEX)); SENSITIVE_NOT_NUM_KEY.add(Pattern.compile(POLICE_REGEX)); SENSITIVE_NOT_NUM_KEY.add(Pattern.compile(SOLDIER_REGEX)); SENSITIVE_NOT_NUM_KEY.add(Pattern.compile(MAC_REGEX)); SENSITIVE_NOT_NUM_KEY.add(Pattern.compile(LICENSEE_CAR_REGEX)); SENSITIVE_NOT_NUM_KEY.add(Pattern.compile(IP_REGEX)); } static { SENSITIVE_FIELD_KEY.add(Pattern.compile(USER_REGEX, Pattern.CASE_INSENSITIVE)); SENSITIVE_FIELD_KEY.add(Pattern.compile(WECHART_REGEX, Pattern.CASE_INSENSITIVE)); SENSITIVE_FIELD_KEY.add(Pattern.compile(BIRTH_DATE_REGEX, Pattern.CASE_INSENSITIVE)); SENSITIVE_FIELD_KEY.add(Pattern.compile(GRADUATE_INSTITUTIONS_REGEX, Pattern.CASE_INSENSITIVE)); SENSITIVE_FIELD_KEY.add(Pattern.compile(NATIVE_PLACE_REGEX, Pattern.CASE_INSENSITIVE)); SENSITIVE_FIELD_KEY.add(Pattern.compile(ADDR_REGEX, Pattern.CASE_INSENSITIVE)); } static { SENSITIVE_FIELD_NUM.add(Pattern.compile(QQ_REGEX, Pattern.CASE_INSENSITIVE)); SENSITIVE_FIELD_NUM.add(Pattern.compile(LICENSE_NO_REGEX, Pattern.CASE_INSENSITIVE)); SENSITIVE_FIELD_NUM.add(Pattern.compile(ACCTNO_REGEX, Pattern.CASE_INSENSITIVE)); } static { SENSITIVE_EMAIL_KEY.add(Pattern.compile(EMAIL_REGEX)); } static { SENSITIVE_SEQUENCE.put(EMAIL, SENSITIVE_EMAIL_KEY); SENSITIVE_SEQUENCE.put(FIELD_NUM, SENSITIVE_FIELD_NUM); SENSITIVE_SEQUENCE.put(FIELD, SENSITIVE_FIELD_KEY); SENSITIVE_SEQUENCE.put(NOT_NUM, SENSITIVE_NOT_NUM_KEY); SENSITIVE_SEQUENCE.put(NUM, SENSITIVE_NUM_KEY); } private LogSensitiveConstants() { } }
结束!
如果是日志输出相对规范,绝大部分输出需脱敏的字段的场景,都使用了key:value 或key = value的情况下非常建议方案四
精彩评论