fix: prevent sensitive data in failure logs

This commit is contained in:
malei 2026-08-02 03:17:11 +08:00
parent 752fcaa38d
commit 2a3cfa0ac4
8 changed files with 113 additions and 23 deletions

View File

@ -36,6 +36,8 @@
CORS 只由全局 `CorsFilter``CORS_ALLOWED_ORIGINS` 控制Controller 不得声明 `@CrossOrigin`。生产只读 smoke 必须同时提供白名单中的 `SMOKE_ALLOWED_ORIGIN`,并验证一个非白名单 Origin 返回 403。 CORS 只由全局 `CorsFilter``CORS_ALLOWED_ORIGINS` 控制Controller 不得声明 `@CrossOrigin`。生产只读 smoke 必须同时提供白名单中的 `SMOKE_ALLOWED_ORIGIN`,并验证一个非白名单 Origin 返回 403。
微信出站调用的日志只允许记录 HTTP 状态码、微信错误码和异常类型禁止记录完整响应体、手机号、access_token、AppSecret、一次性 code 或可能包含带凭据 URI 的异常消息。
> ⚠️ **密钥轮换**`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET` > ⚠️ **密钥轮换**`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`
## 本地开发 ## 本地开发

View File

@ -62,7 +62,7 @@ public class SessionTokenService {
String sig = hmacSha256(payloadB64); String sig = hmacSha256(payloadB64);
return payloadB64 + "." + sig; return payloadB64 + "." + sig;
} catch (Exception e) { } catch (Exception e) {
log.warn("issue session token failed: {}", e.getMessage()); log.warn("issue session token failed: exceptionType={}", e.getClass().getSimpleName());
return null; return null;
} }
} }
@ -102,7 +102,7 @@ public class SessionTokenService {
} }
return java.util.Optional.of(new CurrentUser(userId, storeId, role)); return java.util.Optional.of(new CurrentUser(userId, storeId, role));
} catch (Exception e) { } catch (Exception e) {
log.debug("verify session token parse failed: {}", e.getMessage()); log.debug("verify session token parse failed: exceptionType={}", e.getClass().getSimpleName());
return java.util.Optional.empty(); return java.util.Optional.empty();
} }
} }

View File

@ -221,12 +221,12 @@ public class FileController {
return result; return result;
} catch (IOException e) { } catch (IOException e) {
log.warn("upload failed", e); log.warn("upload failed: exceptionType={}", e.getClass().getSimpleName());
result.put("code", 500); result.put("code", 500);
result.put("message", "上传失败"); result.put("message", "上传失败");
return result; return result;
} catch (SecurityException e) { } catch (SecurityException e) {
log.warn("upload security violation", e); log.warn("upload security violation: exceptionType={}", e.getClass().getSimpleName());
result.put("code", 400); result.put("code", 400);
result.put("message", "上传路径异常"); result.put("message", "上传路径异常");
return result; return result;

View File

@ -46,7 +46,7 @@ public class MerchantOnboardingController {
openid = identity.openid(); openid = identity.openid();
unionid = identity.unionid(); unionid = identity.unionid();
} else { } else {
log.debug("老板注册时 openid 沉淀跳过: {}", identity.errorMessage()); log.debug("老板注册时 openid 沉淀跳过");
} }
} }
try { try {

View File

@ -105,7 +105,7 @@ public class StaffInvitationController {
openid = identity.openid(); openid = identity.openid();
unionid = identity.unionid(); unionid = identity.unionid();
} else { } else {
log.debug("员工接受邀请时 openid 沉淀跳过: {}", identity.errorMessage()); log.debug("员工接受邀请时 openid 沉淀跳过");
} }
} }
try { try {

View File

@ -83,15 +83,15 @@ public class UserController {
} }
} }
} else { } else {
log.debug("openid 沉淀跳过jscode2session 失败): {}", js.errorMessage()); log.debug("openid 沉淀跳过jscode2session 失败)");
} }
} }
return result; return result;
} catch (Exception e) { } catch (Exception e) {
log.error("wx-phone-login 异常", e); log.error("wx-phone-login 异常: exceptionType={}", e.getClass().getSimpleName());
Map<String, Object> err = new HashMap<>(); Map<String, Object> err = new HashMap<>();
err.put("code", 500); err.put("code", 500);
err.put("message", "登录处理失败: " + e.getMessage()); err.put("message", "登录处理失败,请稍后重试");
return err; return err;
} }
} }

View File

@ -92,11 +92,11 @@ public class WechatMiniProgramService {
} }
int errcode = root.path("errcode").asInt(-1); int errcode = root.path("errcode").asInt(-1);
String errmsg = root.path("errmsg").asText(""); String errmsg = root.path("errmsg").asText("");
log.warn("jscode2session 失败: {}", body); log.warn("jscode2session 失败: httpStatus={} errcode={}", hr.statusCode(), errcode);
return JsCode2SessionResult.fail(formatWxError(errcode, errmsg)); return JsCode2SessionResult.fail(formatWxError(errcode, errmsg));
} catch (Exception e) { } catch (Exception e) {
log.warn("jscode2session 异常", e); log.warn("jscode2session 请求异常: exceptionType={}", exceptionType(e));
return JsCode2SessionResult.fail("连接微信服务异常: " + e.getMessage()); return JsCode2SessionResult.fail("连接微信服务异常,请稍后重试");
} }
} }
@ -152,14 +152,15 @@ public class WechatMiniProgramService {
HttpResult hr = httpPostJson(url, jsonBody); HttpResult hr = httpPostJson(url, jsonBody);
String body = hr.body(); String body = hr.body();
if (body == null || body.isBlank()) { if (body == null || body.isBlank()) {
log.warn("getuserphonenumber 响应为空, httpStatus={}, urlLen={}", hr.statusCode(), url.length()); log.warn("getuserphonenumber 响应为空, httpStatus={}", hr.statusCode());
return WxPhoneExchangeResult.fail(emptyBodyHint("getuserphonenumber", hr.statusCode())); return WxPhoneExchangeResult.fail(emptyBodyHint("getuserphonenumber", hr.statusCode()));
} }
JsonNode root; JsonNode root;
try { try {
root = objectMapper.readTree(body); root = objectMapper.readTree(body);
} catch (Exception parseEx) { } catch (Exception parseEx) {
log.warn("解析微信响应失败 body={}", body, parseEx); log.warn("解析 getuserphonenumber 响应失败: httpStatus={} exceptionType={}",
hr.statusCode(), exceptionType(parseEx));
return WxPhoneExchangeResult.fail("微信返回非 JSONHTTP " + hr.statusCode() + "),可能被网关替换"); return WxPhoneExchangeResult.fail("微信返回非 JSONHTTP " + hr.statusCode() + "),可能被网关替换");
} }
@ -171,7 +172,7 @@ public class WechatMiniProgramService {
} }
String phone = normalizeMainlandPhone(raw); String phone = normalizeMainlandPhone(raw);
if (phone == null) { if (phone == null) {
log.warn("无法解析手机号,原始响应: {}", body); log.warn("getuserphonenumber 返回无法解析的手机号: httpStatus={}", hr.statusCode());
return WxPhoneExchangeResult.fail("微信返回的手机号格式异常"); return WxPhoneExchangeResult.fail("微信返回的手机号格式异常");
} }
return WxPhoneExchangeResult.ok(phone); return WxPhoneExchangeResult.ok(phone);
@ -179,7 +180,8 @@ public class WechatMiniProgramService {
int errcode = root.path("errcode").asInt(-1); int errcode = root.path("errcode").asInt(-1);
String errmsg = root.path("errmsg").asText(""); String errmsg = root.path("errmsg").asText("");
log.warn("getuserphonenumber 无 phone_info: {}", body); log.warn("getuserphonenumber 无 phone_info: httpStatus={} errcode={}",
hr.statusCode(), errcode);
if ((errcode == 40001 || errcode == 42001) && attempt == 0) { if ((errcode == 40001 || errcode == 42001) && attempt == 0) {
invalidateAccessToken(); invalidateAccessToken();
continue; continue;
@ -189,8 +191,8 @@ public class WechatMiniProgramService {
} }
return WxPhoneExchangeResult.fail(formatWxError(errcode, errmsg)); return WxPhoneExchangeResult.fail(formatWxError(errcode, errmsg));
} catch (Exception e) { } catch (Exception e) {
log.warn("换取手机号异常", e); log.warn("getuserphonenumber 请求异常: exceptionType={}", exceptionType(e));
return WxPhoneExchangeResult.fail("连接微信服务异常: " + e.getMessage()); return WxPhoneExchangeResult.fail("连接微信服务异常,请稍后重试");
} }
} }
return WxPhoneExchangeResult.fail("获取手机号失败,请重试"); return WxPhoneExchangeResult.fail("获取手机号失败,请重试");
@ -244,13 +246,13 @@ public class WechatMiniProgramService {
try { try {
root = objectMapper.readTree(body); root = objectMapper.readTree(body);
} catch (Exception parseEx) { } catch (Exception parseEx) {
log.warn("解析 token 响应失败 body={}", body, parseEx); log.warn("解析 access_token 响应失败: httpStatus={} exceptionType={}",
hr.statusCode(), exceptionType(parseEx));
return null; return null;
} }
if (root.hasNonNull("errcode") && root.path("errcode").asInt() != 0) { if (root.hasNonNull("errcode") && root.path("errcode").asInt() != 0) {
int ec = root.path("errcode").asInt(); int ec = root.path("errcode").asInt();
String em = root.path("errmsg").asText(""); log.warn("获取 access_token 失败: httpStatus={} errcode={}", hr.statusCode(), ec);
log.warn("获取 access_token 失败: errcode={} errmsg={} body={}", ec, em, body);
return null; return null;
} }
String token = root.path("access_token").asText(null); String token = root.path("access_token").asText(null);
@ -262,7 +264,7 @@ public class WechatMiniProgramService {
tokenExpiresAtEpochMs = System.currentTimeMillis() + expiresIn * 1000L; tokenExpiresAtEpochMs = System.currentTimeMillis() + expiresIn * 1000L;
return cachedAccessToken; return cachedAccessToken;
} catch (Exception e) { } catch (Exception e) {
log.warn("获取 access_token 异常", e); log.warn("获取 access_token 请求异常: exceptionType={}", exceptionType(e));
return null; return null;
} }
} }
@ -306,7 +308,10 @@ public class WechatMiniProgramService {
if (digits.length() == 11) { if (digits.length() == 11) {
return digits; return digits;
} }
log.warn("无法解析为 11 位手机号: {}", raw);
return null; return null;
} }
private static String exceptionType(Exception exception) {
return exception == null ? "Unknown" : exception.getClass().getSimpleName();
}
} }

View File

@ -0,0 +1,83 @@
package com.petstore.service;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
class SensitiveLoggingContractTest {
private static final Pattern LOG_CALL = Pattern.compile(
"log\\.(?:trace|debug|info|warn|error)\\((.*?)\\);",
Pattern.DOTALL
);
private static final Pattern STRING_LITERAL = Pattern.compile("\\\"(?:\\\\.|[^\\\"\\\\])*\\\"");
private static final Pattern WECHAT_SENSITIVE_ARGUMENT = Pattern.compile(
"\\b(?:body|raw|accessToken|phoneCode|jsCode|secret|url)\\b"
);
private static final Pattern FULL_THROWABLE_ARGUMENT = Pattern.compile(
",\\s*(?:e|ex|exception|parseEx)\\s*$"
);
@Test
void outboundWechatLogsCannotIncludeSensitiveRequestOrResponseValues() throws Exception {
String source = read("src/main/java/com/petstore/service/WechatMiniProgramService.java");
var matcher = LOG_CALL.matcher(source);
while (matcher.find()) {
String argumentsWithoutMessageLiterals = STRING_LITERAL.matcher(matcher.group(1)).replaceAll("");
assertFalse(
WECHAT_SENSITIVE_ARGUMENT.matcher(argumentsWithoutMessageLiterals).find(),
"微信日志不得传入完整请求/响应、手机号、token、AppSecret 或一次性 code: " + matcher.group()
);
}
assertNoExceptionMessageOrFullThrowable(source, "WechatMiniProgramService");
}
@Test
void identitySessionAndUploadBoundariesCannotLogExceptionPayloads() throws Exception {
for (String path : List.of(
"src/main/java/com/petstore/auth/SessionTokenService.java",
"src/main/java/com/petstore/controller/UserController.java",
"src/main/java/com/petstore/controller/MerchantOnboardingController.java",
"src/main/java/com/petstore/controller/StaffInvitationController.java",
"src/main/java/com/petstore/controller/FileController.java"
)) {
String source = read(path);
boolean businessFlowMessagesAllowed = path.endsWith("MerchantOnboardingController.java")
|| path.endsWith("StaffInvitationController.java");
assertNoExceptionMessageOrFullThrowable(source, path, !businessFlowMessagesAllowed);
assertFalse(source.matches("(?s).*log\\.(?:trace|debug|info|warn|error)\\([^;]*\\.errorMessage\\(\\)[^;]*\\);.*"),
path + " 不得把外部服务错误文案直接写入日志");
}
}
private static void assertNoExceptionMessageOrFullThrowable(String source, String label) {
assertNoExceptionMessageOrFullThrowable(source, label, true);
}
private static void assertNoExceptionMessageOrFullThrowable(
String source,
String label,
boolean prohibitAnyExceptionMessage) {
if (prohibitAnyExceptionMessage) {
assertFalse(source.contains(".getMessage()"),
label + " 不得记录或回传可能包含敏感 URI/载荷的异常消息");
}
var matcher = LOG_CALL.matcher(source);
while (matcher.find()) {
String argumentsWithoutMessageLiterals = STRING_LITERAL.matcher(matcher.group(1)).replaceAll("");
assertFalse(FULL_THROWABLE_ARGUMENT.matcher(argumentsWithoutMessageLiterals).find(),
label + " 不得记录可能包含敏感 URI/载荷的完整异常: " + matcher.group());
}
}
private static String read(String path) throws Exception {
return Files.readString(Path.of(path));
}
}