#敏感数据脱敏
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要加盐。
【面试官手记】
小张这场面试的亮点:
知道三层脱敏:存储、接口、日志
知道脱敏规则
知道脱敏后搜索的方案
数据脱敏是P6工程师必备技能,能完整回答的候选人,说明有安全意识。
数据脱敏的核心是系统化、自动化。记住三个要点:
- 存储加密:敏感数据加密存储
- 接口脱敏:返回数据自动脱敏
- 日志脱敏:打印日志时脱敏
数据安全无小事,脱敏是第一步。