技术创想102 |日志脱敏在实际项目中使用

文摘   科技   2024-03-07 17:36   北京  

背景
在开展多国家线上信用卡业务时,往往面临着大量用户敏感信息的处理,包括联系手机、身份证号、邮箱和地址等私密数据。为了保障用户隐私,防范信息泄漏风险,Atome卡管团队采用了脱敏技术对这些敏感信息进行处理。本文将介绍一些具体的脱敏措施,确保用户数据安全的同时实现业务的顺利进行。通过技术手段,致力于提供更安全、可靠的服务,维护用户的隐私权益。

解决问题思路

起初是朝着寻找一个能基于当前日志输出情况,实现对现有日志进行自动日志脱敏,因为这种方式需要对所有输入日志进行过滤脱敏处理,会出现如果仅有极少数日志需要进行脱敏,采用这种方式就会让脱敏逻辑遍历所有日志,故又朝着基于注解方式,实现对特定类进行信息脱敏。

效果

基于上述思路,输出了两套日志脱敏方案,具体如下

第一种方案(基于输出日志脱敏):控制台(通过日志文件配置)和日志文件(基于项目中使用的特定日志存储转换器,扩展底层日志处理逻辑),分开处理进行脱敏,实现对现有日志逻辑零侵入式脱敏

第二种方案(基于注解脱敏):通过注解,做到精准对特定类进行日志脱敏

实现

3.1 第一种方案:基于输出日志脱敏

3.1.1 核心实现:

控制台脱敏:通过新增对输出日志的转换,实现对输出日志脱敏

输出日志文件脱敏:通过增强底层日志输出逻辑,实现对日志文件的脱敏

其他:控制台和日志输出底层使用同一套脱敏逻辑

 3.1.2 实现过程:

  1. 控制台脱敏配置及类图:

通过对logback_spring.xml配置文件添加日志信息转换,核心配置:添加<conversionRule conversionWork="msg" converterClass="xxx"></conversionRule>详细配置,相关配置和类图如下:

<conversionRule conversionWord="msg" converterClass="com.card.util.SensitiveDataConverter"> </conversionRule>
<property name="LOG_PATTERN" value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%property{projectName}] [%property{applicationName}] [%mdc{TRACE_ID}] [%mdc{X-B3-SpanId}] [%mdc{stringParam1}] [%mdc{stringParam2}] [%mdc{floatParam}] [%thread] [%level] [%logger{36}] - %msg%n"/> <property name="LOG_PATTERN_WITH_COLOR" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%tid] [%thread] %highlight(%-5level) %cyan(%logger{36}) - [%X{traceId} %X{spanId}] %msg%n"/>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout"> <pattern>${LOG_PATTERN}</pattern> </layout> </encoder> </appender>


  1. 输出文件脱敏(基于项目中使用了统一的日志文件输出实现)配置及类图:

通过增强<appender>下<encoder>能力实现脱敏,相关配置和类图如下:

<appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">        <file>            ${LOG_PATH}/app.log        </file>        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">            <fileNamePattern>${LOG_PATH}/app.log.%d{yyyy-MM-dd}.%i</fileNamePattern>            <maxFileSize>200MB</maxFileSize>            <maxHistory>3</maxHistory>            <totalSizeCap>800MB</totalSizeCap>        </rollingPolicy>        <encoder class="com.card.encoder.DesensitizationLoggerEncoder">            <projectName>${projectName}</projectName>            <applicationName>${applicationName}</applicationName>        </encoder>    </appender>

  1. 控制台和输出文件脱敏相关实现类如下
    // 读取替换配置@Data@ConfigurationProperties(prefix = "desensitization")public class MessageReplacesProperties {    /**     * 脱敏正则列表     */    private List<RegexReplacement> replaces = new ArrayList<>();}

    // 正则替换实现@Datapublic class RegexReplacement { // 脱敏匹配正则 private Pattern regex; // 替换正则 private String replacement; public String format(final String msg) { return regex.matcher(msg).replaceAll(replacement); } public void setRegex(String regex) { this.regex = Pattern.compile(regex); }}// 脱敏具体实现public class SensitiveDataConverter extends MessageConverter { private MessageReplacesProperties messageReplacesProperties; @Override public String convert(ILoggingEvent event){ // 获取原始日志 String requestLogMsg = event.getFormattedMessage(); // 获取脱敏后的日志并返回 return convertMsg(requestLogMsg); }
    /** * 处理日志字符串,返回脱敏后的字符串 * @param msg * @return */ public String convertMsg(String msg){ if (messageReplacesProperties == null) { messageReplacesProperties = ContextService.getBean(MessageReplacesProperties.class); } // msg 为空直接返回 if (StringUtils.isBlank(msg)) { return msg; } // 配置为null直接返回 if (messageReplacesProperties == null || CollectionUtils.isEmpty(messageReplacesProperties.getReplaces())) { return msg; } // 消息转换 for (RegexReplacement replace : messageReplacesProperties.getReplaces()) { // 遍历脱敏正则 & 替换敏感数据 msg = replace.format(msg); } return msg; }}// 日志信息脱敏public class DesensitizationJsonProvider extends MessageJsonProvider { private SensitiveDataConverter sensitiveDataConverter = new SensitiveDataConverter(); public DesensitizationJsonProvider() { super(); } @Override public void writeTo(JsonGenerator generator, ILoggingEvent event) throws IOException { if (messageSplitPattern != null) { String[] multiLineMessage = messageSplitPattern.split(event.getFormattedMessage()); JsonWritingUtils.writeStringArrayField(generator, getFieldName(), multiLineMessage); } else { JsonWritingUtils.writeStringField(generator, getFieldName(), sensitiveDataConverter.convertMsg(event.getFormattedMessage())); } }}// 更换文件message输出实现public class DesensitizationLoggerEncoder extends AtomeLoggerEncoder { public DesensitizationLoggerEncoder(){ super(); // 移除原有日志输出provider JsonProvider<ILoggingEvent> messageJsonProvider = null; for (JsonProvider<ILoggingEvent> provider : getProviders().getProviders()) { if (provider instanceof MessageJsonProvider) { messageJsonProvider = provider; break; } } if (messageJsonProvider != null) { getProviders().removeProvider(messageJsonProvider); } // 新增脱敏provider addProvider(new DesensitizationJsonProvider()); }}
    1. 其他:

    控制台脱敏实现方式可作为通用方式实现日志信息脱敏。

    日志文件脱敏实现方式,使用到了AtomeLoggerEncoder,其为项目内部定义日志存储转换器,这种方式适用于基于net.logstash.logback:logstash-logback-encoder.xxx 包中LogstashEncoder类的子类实现日志文件输出方式。

    因为日志文件脱敏使用到了项目内部的日志存储转换器,第一种方案就不提供【验证】模块了,大家可以基于上面实现自行验证

    3.2 第二种方案:基于注解脱敏

    3.2.1 核心实现:

    通过增强com.fasterxml.jackson.core:jackson-databind:xxx(项目中使用的jar包版本)包中的 ObjectMapper类的序列化能力,提供针对特定注解的类的序列化脱敏

    3.2.2 实现过程:

    1. 扩充ObjectMapper对注解类处理

      public class DataMaskObjectMapper {    private static ObjectMapper objectMapper;    static {        objectMapper = new ObjectMapper();        AnnotationIntrospector rawAi = objectMapper.getSerializationConfig().getAnnotationIntrospector();        AnnotationIntrospector newAi = AnnotationIntrospector.pair(rawAi, new DataMaskAnnotationIntrospector());        objectMapper.setAnnotationIntrospector(newAi);    }    public static ObjectMapper getObjectMapper() {        return objectMapper;    }}
      备注:内部涉及DataMaskAnnotationIntrospector类为自定义类
      1. DataMaskAnnotationIntrospector为NopAnnotationIntrospector子类,重写了findSerializer方法,增强序列化功能,内部实现如下

        public class DataMaskAnnotationIntrospector extends NopAnnotationIntrospector {    @Override    public Object findSerializer(Annotated annotated) {        DataMask annotation = annotated.getAnnotation(DataMask.class);        if (annotation != null) {            return new DataMaskSerializer(annotation.value().operation());        }        return null;    }}

        备注:DataMask注解类和DataMaskSerializer类为自定义类

        1. DataMask注解:该注解配置在成员变量上,实现如下所示

          @Target({ElementType.FIELD, ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface DataMask {    DataMaskEnum value() default DataMaskEnum.PART_MASK;}

          备注:DataMaskEnum为自定义枚举

          1. DataMaskEnum为需要脱敏的种类,如对邮箱、手机、名字、id等不同类型进行脱敏,如下所示

            public enum DataMaskEnum {    EMAIL_MASK(DataMaskEnum::emailMask),    PHONE_MASK(DataMaskEnum::phoneMask),    NAME_MASK(DataMaskEnum::commonMask),    ID_MASK(DataMaskEnum::commonMask),    ALL_MASK(DataMaskEnum::allMask),    PART_MASK(DataMaskEnum::commonMask),    ;
            private final DataMaskOperation operation;
            DataMaskEnum(DataMaskOperation operation) { this.operation = operation; }
            public DataMaskOperation operation() { return this.operation; } /** * 对邮件内容脱敏 * @param data * @return */ private static String emailMask(String data) { // 长度<=6不脱敏 if (StringUtils.isBlank(data)) { return data; } // @符号前面字符小于4,不脱敏 int emailFlagLocation = data.lastIndexOf('@'); if (emailFlagLocation < DataMaskOperation.INT_4) { return data; } // @符号前展示3位+@符号及以后字符 return data.substring(DataMaskOperation.INT_0, DataMaskOperation.INT_3) + DataMaskOperation.MIDDLE_MASK + data.substring(emailFlagLocation); } /** * 对手机号内容脱敏 * @param data * @return */ private static String phoneMask(String data) { if (StringUtils.isBlank(data) || data.length() <= DataMaskOperation.INT_7) { return data; } if (data.startsWith("+") && data.length() <= DataMaskOperation.INT_10) { return data; } StringBuilder sb = new StringBuilder(); if (data.startsWith("+")) { return sb.append(data, DataMaskOperation.INT_0, DataMaskOperation.INT_6) .append(DataMaskOperation.MIDDLE_MASK) .append(data.substring(data.length() - DataMaskOperation.INT_4)) .toString(); } return sb.append(data, DataMaskOperation.INT_0, DataMaskOperation.INT_3) .append(DataMaskOperation.MIDDLE_MASK) .append(data.substring(data.length() - DataMaskOperation.INT_4)) .toString(); } /** * 通用掩码 * @param data * @return */ private static String commonMask(String data) { if (StringUtils.isBlank(data)) { return data; } int strLen = data.length(); // 长度不足不脱敏 if (strLen <= DataMaskOperation.INT_6) { return data; } // 首位各取三个,其余部分用"****"代替 return data.substring(DataMaskOperation.INT_0, DataMaskOperation.INT_3) + DataMaskOperation.MIDDLE_MASK + data.substring(strLen - DataMaskOperation.INT_3); } /** * 对所有内容脱敏 * @param data * @return */ private static String allMask(String data) { if (StringUtils.isNotBlank(data)) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < data.length(); i++) { sb.append(DataMaskOperation.MASK_CHAR); } return sb.toString(); } else { return data; } }}

            备注:DataMaskOperation为自定义函数式式接口

            1. DataMaskOperation为函数式接口,具体实现式放在DataMaskEnum枚举类定义的类型上

            public interface DataMaskOperation {    int INT_0 = 0;    int INT_1 = 1;    int INT_2 = 2;    int INT_3 = 3;    int INT_4 = 4;    int INT_6 = 6;    int INT_7 = 7;    int INT_10 = 10;    String MIDDLE_MASK = "****";    String MASK_CHAR = "*";        String mask(String content);}

            b. DataMaskSerializer类:为StdScalarSerializer子类,重写了serialize和serializeWithType方法,增强了序列化能力

              public class DataMaskSerializer extends StdScalarSerializer<Object> {    private final DataMaskOperation operation;    public DataMaskSerializer(DataMaskOperation operation) {        super(String.class, false);        this.operation = operation;    }    @Override    public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException {        if (Objects.isNull(operation)) {            String content = DataMaskEnum.ALL_MASK.operation().mask((String) value);            gen.writeString(content);        } else {            String content = operation.mask((String) value);            gen.writeString(content);        }    }    @Override    public final void serializeWithType(Object value, JsonGenerator gen, SerializerProvider provider, TypeSerializer typeSer) throws IOException {        this.serialize(value, gen, provider);    }}
                1. 通过上述对ObjectMapper对象能力的扩充即可完成对象序列化时,对敏感数据进行脱敏,具体使用通过下面类实现

                  public class DataMaskUtil {    private static final ObjectMapper objectMapper = new ObjectMapper();    private DataMaskUtil(){    }
                  public static String toString(Object object) { try { return DataMaskObjectMapper.getObjectMapper().writeValueAsString(object); } catch (Exception e) { log.warn("DataMaskUtil#toString data mask exception", e); return JsonUtil.toString(object); } } public static String toStringNoDataMask(Object object) { try { return objectMapper.writeValueAsString(object); } catch (Exception e) { log.warn("DataMaskUtil#toStringNoDataMask data mask exception", e); return JsonUtil.toString(object); } }}

                  3.2.3 验证

                  1. 验证类:

                    public class UserInfo {    @DataMask(DataMaskEnum.EMAIL_MASK)    private String email;    @DataMask(DataMaskEnum.PHONE_MASK)    private String phone;    @DataMask(DataMaskEnum.ID_MASK)    private String idNum;    @DataMask(DataMaskEnum.NAME_MASK)    private String name;    @DataMask(DataMaskEnum.ALL_MASK)    private String allMask;    @DataMask(DataMaskEnum.PART_MASK)    private String partMask;}
                    1. 测试类
                    public class DataMaskUtilTest{
                    @Test public void testToString() { UserInfo userInfo = getNoInfoUser(); log.info("-------------email start--------------"); log.info("无值脱敏前:{}", DataMaskUtil.toStringNoDataMask(userInfo)); log.info("无值脱敏后:{}", DataMaskUtil.toString(userInfo)); log.info("----------------------------------------"); userInfo = getShortInfo(); log.info("较短长度脱敏前:{}", DataMaskUtil.toStringNoDataMask(userInfo)); log.info("较短长度脱敏后:{}", DataMaskUtil.toString(userInfo)); log.info("----------------------------------------"); userInfo = getOrdinaryEmailInfo(); log.info("正常长度脱敏前:{}", DataMaskUtil.toStringNoDataMask(userInfo)); log.info("正常长度脱敏后:{}", DataMaskUtil.toString(userInfo)); log.info("-------------email end----------------"); }
                    private UserInfo getNoInfoUser() { return new UserInfo(); }
                    private UserInfo getShortInfo() { UserInfo userInfo = new UserInfo(); userInfo.setEmail("1@q.cn"); userInfo.setPhone("1234567"); userInfo.setIdNum("ic"); userInfo.setName("na"); userInfo.setAllMask("abcdef"); userInfo.setPartMask("pa"); return userInfo; }
                    private UserInfo getOrdinaryEmailInfo() { UserInfo userInfo = new UserInfo(); userInfo.setEmail("1adfwfa1@qq.com"); userInfo.setPhone("+86123456789"); userInfo.setIdNum("ic123456789"); userInfo.setName("naqiu aff ad"); userInfo.setPartMask("panamoabcdef"); return userInfo; }}
                      1. 测试结果

                        -------------email start--------------: 无值脱敏前:{}: 无值脱敏后:{}: ----------------------------------------: 较短长度脱敏前:{"email":"1@q.cn","phone":"1234567","idNum":"ic","name":"na","allMask":"abcdef","partMask":"pa"}: 较短长度脱敏后:{"email":"1@q.cn","phone":"1234567","idNum":"ic","name":"na","allMask":"******","partMask":"pa"}: ----------------------------------------: 正常长度脱敏前:{"email":"1adfwfa1@qq.com","phone":"+86123456789","idNum":"ic123456789","name":"naqiu aff ad","partMask":"panamoabcdef"}: 正常长度脱敏后:{"email":"1ad****@qq.com","phone":"+86123****6789","idNum":"ic1****789","name":"naq**** ad","partMask":"pan****def"}: -------------email end----------------

                        不同方式脱敏比较


                        基于输出日志脱敏

                        基于注解脱敏

                        优点

                        • 简单:通过交少量代码即可实现

                        • 零侵入:不需要对原有代码做任何修改

                        • 彻底:基于输入日志,可以保证所有信息被脱敏

                        • 安全:仅对通过DataMaskUtil工具类且类属性上添加注解进行序列化时生效

                        • 效率高:仅处理带有脱敏注解的类

                        • 灵活:可以对指定字段脱敏,可以脱敏成任意形式

                        • 通用:可以做成jar包,直接应用到别的项目中

                        缺点

                        • 效率低:实现逻辑会对所有日志进行正则匹配进行脱敏

                        • 误脱敏:用户的某些不需要脱敏字段信息命中脱敏正则

                        • 局限性:日志文件方式脱敏,基于项目中特定的日志存储转换器

                        • 成本高:需要改动代码,为需要脱敏的类添加注解

                        其他

                        基于上述特性,在实际项目中使用了基于注解方式脱敏,在使用这类方式脱敏时建议:

                        1、可以将上述逻辑放在单独模块中,生成jar包,除了可以在该项目中使用,还可以通过引入jar包快速应用到其他项目中

                        2、很多项目,都会通过AOP方式拦截请求,并输出对应参数和返回信息,通过加入该脱敏工具,可以较高效对对这部分进行脱敏


                        关于领创集团

                        (Advance Intelligence Group)
                        领创集团成立于 2016年,致力于通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈。集团旗下包含企业业务和消费者业务两大板块,企业业务包含 ADVANCE.AI 和 Ginee,分别为银行、金融、金融科技、零售和电商行业客户提供基于 AI 技术的数字身份验证、风险管理产品和全渠道电商服务解决方案;消费者业务 Atome Financial 包括亚洲领先的数字金融服务平台 Atome 等。2021年 9月,领创集团宣布完成超4亿美元 D 轮融资,融资完成后领创集团估值已超 20亿美元,成为新加坡最大的独立科技创业公司之一。




                        领创集团Advance Group
                        领创集团是亚太地区AI技术驱动的科技集团。
                         最新文章