From 2a3cfa0ac45891ad8be6adf5028e1c89e792d0d1 Mon Sep 17 00:00:00 2001 From: malei <> Date: Sun, 2 Aug 2026 03:17:11 +0800 Subject: [PATCH] fix: prevent sensitive data in failure logs --- README.md | 2 + .../petstore/auth/SessionTokenService.java | 4 +- .../petstore/controller/FileController.java | 4 +- .../MerchantOnboardingController.java | 2 +- .../controller/StaffInvitationController.java | 2 +- .../petstore/controller/UserController.java | 6 +- .../service/WechatMiniProgramService.java | 33 ++++---- .../service/SensitiveLoggingContractTest.java | 83 +++++++++++++++++++ 8 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 src/test/java/com/petstore/service/SensitiveLoggingContractTest.java diff --git a/README.md b/README.md index 2046004..0c859d3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ 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`。 ## 本地开发 diff --git a/src/main/java/com/petstore/auth/SessionTokenService.java b/src/main/java/com/petstore/auth/SessionTokenService.java index a37454e..3c7647e 100644 --- a/src/main/java/com/petstore/auth/SessionTokenService.java +++ b/src/main/java/com/petstore/auth/SessionTokenService.java @@ -62,7 +62,7 @@ public class SessionTokenService { String sig = hmacSha256(payloadB64); return payloadB64 + "." + sig; } catch (Exception e) { - log.warn("issue session token failed: {}", e.getMessage()); + log.warn("issue session token failed: exceptionType={}", e.getClass().getSimpleName()); return null; } } @@ -102,7 +102,7 @@ public class SessionTokenService { } return java.util.Optional.of(new CurrentUser(userId, storeId, role)); } 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(); } } diff --git a/src/main/java/com/petstore/controller/FileController.java b/src/main/java/com/petstore/controller/FileController.java index ef9fbeb..3524eb0 100644 --- a/src/main/java/com/petstore/controller/FileController.java +++ b/src/main/java/com/petstore/controller/FileController.java @@ -221,12 +221,12 @@ public class FileController { return result; } catch (IOException e) { - log.warn("upload failed", e); + log.warn("upload failed: exceptionType={}", e.getClass().getSimpleName()); result.put("code", 500); result.put("message", "上传失败"); return result; } catch (SecurityException e) { - log.warn("upload security violation", e); + log.warn("upload security violation: exceptionType={}", e.getClass().getSimpleName()); result.put("code", 400); result.put("message", "上传路径异常"); return result; diff --git a/src/main/java/com/petstore/controller/MerchantOnboardingController.java b/src/main/java/com/petstore/controller/MerchantOnboardingController.java index aa3685d..fcb6a22 100644 --- a/src/main/java/com/petstore/controller/MerchantOnboardingController.java +++ b/src/main/java/com/petstore/controller/MerchantOnboardingController.java @@ -46,7 +46,7 @@ public class MerchantOnboardingController { openid = identity.openid(); unionid = identity.unionid(); } else { - log.debug("老板注册时 openid 沉淀跳过: {}", identity.errorMessage()); + log.debug("老板注册时 openid 沉淀跳过"); } } try { diff --git a/src/main/java/com/petstore/controller/StaffInvitationController.java b/src/main/java/com/petstore/controller/StaffInvitationController.java index 8bc577d..ef469a6 100644 --- a/src/main/java/com/petstore/controller/StaffInvitationController.java +++ b/src/main/java/com/petstore/controller/StaffInvitationController.java @@ -105,7 +105,7 @@ public class StaffInvitationController { openid = identity.openid(); unionid = identity.unionid(); } else { - log.debug("员工接受邀请时 openid 沉淀跳过: {}", identity.errorMessage()); + log.debug("员工接受邀请时 openid 沉淀跳过"); } } try { diff --git a/src/main/java/com/petstore/controller/UserController.java b/src/main/java/com/petstore/controller/UserController.java index 76bc9be..12f73ea 100644 --- a/src/main/java/com/petstore/controller/UserController.java +++ b/src/main/java/com/petstore/controller/UserController.java @@ -83,15 +83,15 @@ public class UserController { } } } else { - log.debug("openid 沉淀跳过(jscode2session 失败): {}", js.errorMessage()); + log.debug("openid 沉淀跳过(jscode2session 失败)"); } } return result; } catch (Exception e) { - log.error("wx-phone-login 异常", e); + log.error("wx-phone-login 异常: exceptionType={}", e.getClass().getSimpleName()); Map err = new HashMap<>(); err.put("code", 500); - err.put("message", "登录处理失败: " + e.getMessage()); + err.put("message", "登录处理失败,请稍后重试"); return err; } } diff --git a/src/main/java/com/petstore/service/WechatMiniProgramService.java b/src/main/java/com/petstore/service/WechatMiniProgramService.java index 5188f41..b7c1664 100644 --- a/src/main/java/com/petstore/service/WechatMiniProgramService.java +++ b/src/main/java/com/petstore/service/WechatMiniProgramService.java @@ -92,11 +92,11 @@ public class WechatMiniProgramService { } int errcode = root.path("errcode").asInt(-1); String errmsg = root.path("errmsg").asText(""); - log.warn("jscode2session 失败: {}", body); + log.warn("jscode2session 失败: httpStatus={} errcode={}", hr.statusCode(), errcode); return JsCode2SessionResult.fail(formatWxError(errcode, errmsg)); } catch (Exception e) { - log.warn("jscode2session 异常", e); - return JsCode2SessionResult.fail("连接微信服务异常: " + e.getMessage()); + log.warn("jscode2session 请求异常: exceptionType={}", exceptionType(e)); + return JsCode2SessionResult.fail("连接微信服务异常,请稍后重试"); } } @@ -152,14 +152,15 @@ public class WechatMiniProgramService { HttpResult hr = httpPostJson(url, jsonBody); String body = hr.body(); 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())); } JsonNode root; try { root = objectMapper.readTree(body); } catch (Exception parseEx) { - log.warn("解析微信响应失败 body={}", body, parseEx); + log.warn("解析 getuserphonenumber 响应失败: httpStatus={} exceptionType={}", + hr.statusCode(), exceptionType(parseEx)); return WxPhoneExchangeResult.fail("微信返回非 JSON(HTTP " + hr.statusCode() + "),可能被网关替换"); } @@ -171,7 +172,7 @@ public class WechatMiniProgramService { } String phone = normalizeMainlandPhone(raw); if (phone == null) { - log.warn("无法解析手机号,原始响应: {}", body); + log.warn("getuserphonenumber 返回无法解析的手机号: httpStatus={}", hr.statusCode()); return WxPhoneExchangeResult.fail("微信返回的手机号格式异常"); } return WxPhoneExchangeResult.ok(phone); @@ -179,7 +180,8 @@ public class WechatMiniProgramService { int errcode = root.path("errcode").asInt(-1); 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) { invalidateAccessToken(); continue; @@ -189,8 +191,8 @@ public class WechatMiniProgramService { } return WxPhoneExchangeResult.fail(formatWxError(errcode, errmsg)); } catch (Exception e) { - log.warn("换取手机号异常", e); - return WxPhoneExchangeResult.fail("连接微信服务异常: " + e.getMessage()); + log.warn("getuserphonenumber 请求异常: exceptionType={}", exceptionType(e)); + return WxPhoneExchangeResult.fail("连接微信服务异常,请稍后重试"); } } return WxPhoneExchangeResult.fail("获取手机号失败,请重试"); @@ -244,13 +246,13 @@ public class WechatMiniProgramService { try { root = objectMapper.readTree(body); } catch (Exception parseEx) { - log.warn("解析 token 响应失败 body={}", body, parseEx); + log.warn("解析 access_token 响应失败: httpStatus={} exceptionType={}", + hr.statusCode(), exceptionType(parseEx)); return null; } if (root.hasNonNull("errcode") && root.path("errcode").asInt() != 0) { int ec = root.path("errcode").asInt(); - String em = root.path("errmsg").asText(""); - log.warn("获取 access_token 失败: errcode={} errmsg={} body={}", ec, em, body); + log.warn("获取 access_token 失败: httpStatus={} errcode={}", hr.statusCode(), ec); return null; } String token = root.path("access_token").asText(null); @@ -262,7 +264,7 @@ public class WechatMiniProgramService { tokenExpiresAtEpochMs = System.currentTimeMillis() + expiresIn * 1000L; return cachedAccessToken; } catch (Exception e) { - log.warn("获取 access_token 异常", e); + log.warn("获取 access_token 请求异常: exceptionType={}", exceptionType(e)); return null; } } @@ -306,7 +308,10 @@ public class WechatMiniProgramService { if (digits.length() == 11) { return digits; } - log.warn("无法解析为 11 位手机号: {}", raw); return null; } + + private static String exceptionType(Exception exception) { + return exception == null ? "Unknown" : exception.getClass().getSimpleName(); + } } diff --git a/src/test/java/com/petstore/service/SensitiveLoggingContractTest.java b/src/test/java/com/petstore/service/SensitiveLoggingContractTest.java new file mode 100644 index 0000000..05dde8c --- /dev/null +++ b/src/test/java/com/petstore/service/SensitiveLoggingContractTest.java @@ -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)); + } +}