一、日志里打印了用户手机号,被安全部门约谈
2021年,安全部门扫描发现我们的日志里明文打印了用户手机号和身份证号。
根据《个人信息保护法》,这属于违规行为。我们被要求限期整改,否则面临罚款。
从那以后,数据脱敏成了我们的必修课。
二、脱敏规则
java
/**
* 脱敏工具类
*/
public class MaskUtils {
/** 手机号脱敏:138****1234 */
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) return phone;
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
/** 身份证脱敏:3301****1234 */
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 8) return idCard;
return idCard.substring(0, 4) + "****" + idCard.substring(idCard.length() - 4);
}
/** 邮箱脱敏:t***@example.com */
public static String maskEmail(String email) {
if (email == null || !email.contains("@")) return email;
String[] parts = email.split("@");
return parts[0].charAt(0) + "***@" + parts[1];
}
/** 姓名脱敏:张** */
public static String maskName(String name) {
if (name == null || name.length() < 2) return name;
return name.charAt(0) + "**";
}
/** 银行卡脱敏:**** **** **** 1234 */
public static String maskBankCard(String cardNo) {
if (cardNo == null || cardNo.length() < 4) return cardNo;
return "**** **** **** " + cardNo.substring(cardNo.length() - 4);
}
/** 地址脱敏:浙江省杭州市**** */
public static String maskAddress(String address) {
if (address == null || address.length() < 6) return address;
return address.substring(0, 6) + "****";
}
}
2.1 MyBatis脱敏拦截器
java
/**
* 查询结果自动脱敏
*/
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
@Component
public class DataMaskInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object result = invocation.proceed();
if (result instanceof List) {
for (Object item : (List<?>) result) {
maskSensitiveFields(item);
}
} else if (result != null) {
maskSensitiveFields(result);
}
return result;
}
private void maskSensitiveFields(Object obj) {
if (obj == null) return;
for (Field field : obj.getClass().getDeclaredFields()) {
Sensitive sensitive = field.getAnnotation(Sensitive.class);
if (sensitive != null && field.getType() == String.class) {
field.setAccessible(true);
try {
String value = (String) field.get(obj);
if (value != null) {
field.set(obj, maskByType(value, sensitive.type()));
}
} catch (IllegalAccessException e) {
// ignore
}
}
}
}
private String maskByType(String value, SensitiveType type) {
switch (type) {
case PHONE: return MaskUtils.maskPhone(value);
case ID_CARD: return MaskUtils.maskIdCard(value);
case EMAIL: return MaskUtils.maskEmail(value);
case NAME: return MaskUtils.maskName(value);
case BANK_CARD: return MaskUtils.maskBankCard(value);
case ADDRESS: return MaskUtils.maskAddress(value);
default: return value;
}
}
}
/**
* 敏感字段注解
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sensitive {
SensitiveType type();
}
// 使用
@Data
public class UserVO {
private Long id;
private String username;
@Sensitive(type = SensitiveType.PHONE)
private String phone;
@Sensitive(type = SensitiveType.ID_CARD)
private String idCard;
@Sensitive(type = SensitiveType.EMAIL)
private String email;
}
三、日志脱敏
java
/**
* 日志脱敏拦截器
*/
@Component
public class LogMaskFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
ContentCachingRequestWrapper wrappedRequest =
new ContentCachingRequestWrapper((HttpServletRequest) request);
chain.doFilter(wrappedRequest, response);
// 日志中记录请求体(脱敏后)
String body = new String(wrappedRequest.getContentAsByteArray());
String masked = maskSensitiveData(body);
log.info("Request: uri={}, body={}",
((HttpServletRequest) request).getRequestURI(), masked);
}
private String maskSensitiveData(String data) {
// 正则匹配手机号
data = data.replaceAll("(1[3-9]\\d)\\d{4}(\\d{4})", "$1****$2");
// 正则匹配身份证号
data = data.replaceAll("(\\d{4})\\d{10}(\\d{4})", "$1**********$2");
// 正则匹配邮箱
data = data.replaceAll("(\\w)\\w+(@\\w+\\.\\w+)", "$1***$2");
return data;
}
}
四、加密存储
java
/**
* 数据库字段加密
*/
@Component
public class FieldEncryptUtil {
@Autowired
private AesEncryptor aesEncryptor;
/**
* 加密
*/
public String encrypt(String plaintext) {
return aesEncryptor.encrypt(plaintext);
}
/**
* 解密
*/
public String decrypt(String ciphertext) {
return aesEncryptor.decrypt(ciphertext);
}
}
/**
* MyBatis加密拦截器
*/
@Intercepts({
@Signature(type = Executor.class, method = "update"),
@Signature(type = Executor.class, method = "query")
})
@Component
public class FieldEncryptInterceptor implements Interceptor {
@Autowired
private FieldEncryptUtil encryptUtil;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 写入时加密
if ("update".equals(invocation.getMethod().getName())) {
encryptParams(invocation.getArgs());
}
Object result = invocation.proceed();
// 查询时解密
if ("query".equals(invocation.getMethod().getName())) {
decryptResult(result);
}
return result;
}
}
五、踩坑实录
坑1:脱敏了但数据库里没加密
API返回脱敏了,但数据库里是明文存储,被SQL注入后泄露。
解决:敏感字段数据库加密存储。
坑2:脱敏规则不统一
不同服务的脱敏方式不同,A服务手机号3位+4位,B服务4位+4位。
解决:统一脱敏工具类,强制使用。
坑3:日志脱敏遗漏
有些日志是第三方库打印的,没有经过我们的脱敏过滤器。
解决:Logback脱敏插件,统一处理所有日志输出。
坑4:加密后无法查询
手机号加密存储后,无法按手机号查询。
解决:存储脱敏值+加密值,用脱敏值查询;或使用可搜索加密。
坑5:密钥管理
加密密钥硬编码在代码里,不安全。
解决:密钥存储在KMS(密钥管理服务),运行时动态获取。
六、总结
数据隐私保护要点:
| 层面 | 方案 |
|---|---|
| 传输 | HTTPS + 字段加密 |
| 存储 | 数据库字段加密 |
| 展示 | 脱敏返回 |
| 日志 | 自动脱敏 |
| 密钥 | KMS管理 |
血的教训:
数据安全不是可选项。一次数据泄露的代价,远超安全建设的成本。
思考题: 你的系统做了哪些数据脱敏措施?
个人观点,仅供参考