敏感数据脱敏

2020年某社交平台被曝光:用户昵称、手机号、地址等敏感信息被明文存储在日志中,被爬虫爬取了数百万条。

技术团队排查后发现:日志系统在打印用户信息时,没有做任何脱敏处理,手机号、身份证号等直接明文输出。

更可怕的是:这个日志库被外包团队有权限访问,数十万用户信息被泄露。

这次数据泄露导致平台被罚款约500万元,用户投诉爆增。

【面试官手记】

敏感数据脱敏是数据安全的基础工作。我面试过的候选人里,能说清楚"脱敏规则"的不超过30%,能说出"脱敏实现"的不超过20%。数据脱敏的关键是系统化、自动化

一、敏感数据识别 🔴

1.1 敏感数据类型

敏感数据类型:

个人身份信息(PII):
- 手机号:138****5678
- 身份证:110****1234****5678
- 姓名:张*
- 地址:北京市朝阳区****

金融信息:
- 银行卡号:****1234(只保留后4位)
- 密码:不可逆加密
- 余额:范围展示,如>10000

医疗健康:
- 病历号
- 体检报告
- 处方信息

联系方式:
- 邮箱:a***@example.com
- QQ号
- 微信号

1.2 敏感数据识别

// 敏感字段识别
public class SensitiveFieldDetector {

    private static final Map<String, SensitiveType> FIELD_PATTERNS = Map.of(
        "phone", SensitiveType.PHONE,
        "mobile", SensitiveType.PHONE,
        "idCard", SensitiveType.ID_CARD,
        "id_number", SensitiveType.ID_CARD,
        "name", SensitiveType.NAME,
        "realName", SensitiveType.NAME,
        "address", SensitiveType.ADDRESS,
        "email", SensitiveType.EMAIL,
        "bankCard", SensitiveType.BANK_CARD,
        "password", SensitiveType.PASSWORD,
        "balance", SensitiveType.BALANCE
    );

    public SensitiveType detect(String fieldName) {
        String lowerName = fieldName.toLowerCase();
        for (Map.Entry<String, SensitiveType> entry : FIELD_PATTERNS.entrySet()) {
            if (lowerName.contains(entry.getKey())) {
                return entry.getValue();
            }
        }
        return null;
    }
}

public enum SensitiveType {
    PHONE,           // 手机号
    ID_CARD,         // 身份证
    NAME,            // 姓名
    ADDRESS,         // 地址
    EMAIL,           // 邮箱
    BANK_CARD,       // 银行卡
    PASSWORD,        // 密码
    BALANCE          // 余额
}

二、脱敏规则实现 🔴

2.1 脱敏工具类

public class DataMaskingUtil {

    // 手机号脱敏:13812345678 → 138****5678
    public static String maskPhone(String phone) {
        if (phone == null || phone.length() < 7) {
            return phone;
        }
        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
    }

    // 身份证脱敏:110101199001011234 → 110***********1234
    public static String maskIdCard(String idCard) {
        if (idCard == null || idCard.length() < 10) {
            return idCard;
        }
        return idCard.substring(0, 3) + "***********" + idCard.substring(idCard.length() - 4);
    }

    // 姓名脱敏:张三 → 张*
    public static String maskName(String name) {
        if (name == null || name.isEmpty()) {
            return name;
        }
        if (name.length() == 2) {
            return name.charAt(0) + "*";
        }
        return name.charAt(0) + "*" + name.charAt(name.length() - 1);
    }

    // 邮箱脱敏:zhangsan@example.com → z****n@example.com
    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) {
            return email;
        }
        String[] parts = email.split("@");
        String prefix = parts[0];
        if (prefix.length() <= 2) {
            return prefix.charAt(0) + "***@" + parts[1];
        }
        return prefix.charAt(0) + "***" + prefix.charAt(prefix.length() - 1) + "@" + parts[1];
    }

    // 银行卡脱敏:6222021234567890123 → ************0123
    public static String maskBankCard(String cardNo) {
        if (cardNo == null || cardNo.length() < 8) {
            return cardNo;
        }
        return "****************".substring(0, cardNo.length() - 4) + cardNo.substring(cardNo.length() - 4);
    }

    // 密码:不可逆,返回固定值
    public static String maskPassword(String password) {
        return "******";
    }

    // 余额:范围展示
    public static String maskBalance(BigDecimal balance) {
        if (balance == null) {
            return null;
        }
        if (balance.compareTo(new BigDecimal("10000")) > 0) {
            return ">10000";
        }
        if (balance.compareTo(new BigDecimal("5000")) > 0) {
            return "5000-10000";
        }
        if (balance.compareTo(new BigDecimal("1000")) > 0) {
            return "1000-5000";
        }
        return "<1000";
    }
}

2.2 Jackson脱敏注解

// 自定义脱敏注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface Sensitive {
    SensitiveType value();
}

public class UserVO {
    private Long id;

    @Sensitive(SensitiveType.NAME)
    private String name;

    @Sensitive(SensitiveType.PHONE)
    private String phone;

    @Sensitive(SensitiveType.ID_CARD)
    private String idCard;

    @Sensitive(SensitiveType.BANK_CARD)
    private String bankCard;
}

// Jackson脱敏处理器
@Component
public class SensitiveJsonSerializer extends JsonSerializer<Object> {

    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeStartObject();
        Class<?> clazz = value.getClass();
        for (Field field : clazz.getDeclaredFields()) {
            if (field.isAnnotationPresent(Sensitive.class)) {
                Sensitive annotation = field.getAnnotation(Sensitive.class);
                field.setAccessible(true);
                Object fieldValue = field.get(value);
                String maskedValue = mask(fieldValue, annotation.value());
                gen.writeStringField(field.getName(), maskedValue);
            } else {
                // 其他字段正常序列化
                gen.writeObjectField(field.getName(), field.get(value));
            }
        }
        gen.writeEndObject();
    }

    private String mask(Object value, SensitiveType type) {
        if (value == null) return null;
        switch (type) {
            case PHONE: return DataMaskingUtil.maskPhone(value.toString());
            case NAME: return DataMaskingUtil.maskName(value.toString());
            case ID_CARD: return DataMaskingUtil.maskIdCard(value.toString());
            case BANK_CARD: return DataMaskingUtil.maskBankCard(value.toString());
            default: return value.toString();
        }
    }
}

三、日志脱敏 🟡

3.1 Logback脱敏

<!-- Logback配置 -->
<configuration>
    <conversionRule conversionWord="sensitive"
                    converterClass="com.example.log.SensitiveConverter"/>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %sensitive{phone} %sensitive{name} %msg%n</pattern>
        </encoder>
    </appender>
</configuration>
// Logback脱敏Converter
public class SensitiveConverter extends ClassicConverter {

    @Override
    public String convert(ILoggingEvent event) {
        Map<String, Object> logContext = event.getMDCPropertyMap();
        String phone = (String) logContext.get("phone");
        String name = (String) logContext.get("name");

        String log = event.getFormattedMessage();
        if (phone != null) {
            log = log.replace(phone, DataMaskingUtil.maskPhone(phone));
        }
        if (name != null) {
            log = log.replace(name, DataMaskingUtil.maskName(name));
        }
        return log;
    }
}

3.2 日志打印规范

// 正确的日志打印
public class UserService {

    private static final Logger log = LoggerFactory.getLogger(UserService.class);

    public void createUser(User user) {
        // 正确:打印用户ID,不打印敏感信息
        log.info("创建用户,userId={}", user.getId());

        // 错误:打印完整用户信息
        // log.info("创建用户,user={}", user); // 不要这样

        // 正确:打印脱敏后的信息
        log.info("创建用户,phone={}, name={}",
            DataMaskingUtil.maskPhone(user.getPhone()),
            DataMaskingUtil.maskName(user.getName()));
    }
}

四、数据库脱敏 🟡

4.1 敏感字段加密

-- 敏感字段加密存储
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    phone_encrypted VARCHAR(256),    -- 加密存储
    id_card_encrypted VARCHAR(512),   -- 加密存储
    name_encrypted VARCHAR(256),     -- 加密存储
    create_time DATETIME
);

-- 查询时解密
SELECT
    id,
    AES_DECRYPT(UNHEX(phone_encrypted), 'key') as phone,
    AES_DECRYPT(UNHEX(name_encrypted), 'key') as name
FROM users WHERE id = 1;

4.2 敏感数据访问控制

// 敏感数据访问控制
@Service
public class SensitiveDataService {

    @Value("${sensitive.data.enabled:false}")
    private boolean sensitiveDataEnabled;

    public String getPhone(Long userId) {
        if (!sensitiveDataEnabled) {
            throw new SecurityException("无权访问敏感数据");
        }
        // 查询解密
        return userDAO.selectPhoneById(userId);
    }
}

五、常见问题处理 🟡

5.1 脱敏后无法搜索

问题:手机号脱敏后无法按手机号搜索
场景:用户输入手机号登录
解决方案:
- 密文搜索:用加密后的手机号搜索
- 哈希搜索:手机号Hash后存储,搜索时Hash匹配
- 分词索引:保留前3位用于展示,存储完整加密

5.2 脱敏影响业务

问题:脱敏后业务流程出错
场景:导出报表中手机号全是星号
解决方案:
- 按角色脱敏:管理员看到完整数据,普通用户看到脱敏数据
- 按场景脱敏:内部展示完整,外部展示脱敏
- 按用途脱敏:统计用脱敏,操作用完整

5.3 日志脱敏遗漏

问题:新增接口忘记脱敏
场景:新人写了日志没注意
解决方案:
- Code Review检查日志
- 扫描工具自动检测
- 敏感字段注解强制使用

六、生产避坑 🟡

6.1 数据脱敏的五大坑

坑1:只脱敏不加密

问题:脱敏数据仍然可逆
场景:手机号只脱敏没加密
解决方案:
- 敏感数据加密存储
- 密钥分开管理

坑2:日志漏脱敏

问题:某个接口忘记脱敏
场景:新增接口没检查
解决方案:
- Code Review强制检查
- 定期扫描日志

坑3:脱敏规则不一致

问题:不同系统脱敏规则不同
场景:订单系统用张三,支付系统用张*
解决方案:
- 统一脱敏工具
- 统一脱敏规则

坑4:测试环境用真实数据

问题:测试环境用生产脱敏数据
场景:测试数据质量差
解决方案:
- 使用脱敏后的生产数据
- 或使用合成测试数据

坑5:脱敏后数据丢失

问题:脱敏导致数据不可用
场景:ID_card只保留前后4位
解决方案:
- 设计合理的脱敏规则
- 保留必要信息用于业务

6.2 脱敏检查清单

开发规范:
- [ ] 敏感字段使用@Sensitive注解
- [ ] 日志不打印敏感信息
- [ ] 接口返回脱敏数据
- [ ] 数据库敏感数据加密

定期检查:
- [ ] 日志扫描脱敏情况
- [ ] 代码Review检查
- [ ] 安全扫描

七、真实面试回放 🟡

面试官:敏感数据怎么脱敏?

候选人(小张):三个层面:

一是存储层。手机号、身份证等加密存储,用AES加密。

二是接口返回层。用Jackson脱敏注解,接口返回时自动脱敏。

三是日志层。日志打印时用Converter脱敏。

面试官:手机号脱敏规则是什么?

小张:显示前三后四,中间四位星号。

比如13812345678,脱敏后是138****5678。

面试官:脱敏后还能搜索吗?

小张:两个方案:

一是密文搜索。存储时加密,搜索时用加密后的值搜索。

二是哈希搜索。存储时Hash,搜索时Hash匹配。Hash要加盐。

【面试官手记】

小张这场面试的亮点:

  1. 知道三层脱敏:存储、接口、日志

  2. 知道脱敏规则

  3. 知道脱敏后搜索的方案

数据脱敏是P6工程师必备技能,能完整回答的候选人,说明有安全意识。

数据脱敏的核心是系统化、自动化。记住三个要点:

  1. 存储加密:敏感数据加密存储
  2. 接口脱敏:返回数据自动脱敏
  3. 日志脱敏:打印日志时脱敏

数据安全无小事,脱敏是第一步。