fix: prevent sensitive data in failure logs
This commit is contained in:
parent
752fcaa38d
commit
2a3cfa0ac4
@ -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`。
|
||||
|
||||
## 本地开发
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -46,7 +46,7 @@ public class MerchantOnboardingController {
|
||||
openid = identity.openid();
|
||||
unionid = identity.unionid();
|
||||
} else {
|
||||
log.debug("老板注册时 openid 沉淀跳过: {}", identity.errorMessage());
|
||||
log.debug("老板注册时 openid 沉淀跳过");
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
@ -105,7 +105,7 @@ public class StaffInvitationController {
|
||||
openid = identity.openid();
|
||||
unionid = identity.unionid();
|
||||
} else {
|
||||
log.debug("员工接受邀请时 openid 沉淀跳过: {}", identity.errorMessage());
|
||||
log.debug("员工接受邀请时 openid 沉淀跳过");
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
@ -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<String, Object> err = new HashMap<>();
|
||||
err.put("code", 500);
|
||||
err.put("message", "登录处理失败: " + e.getMessage());
|
||||
err.put("message", "登录处理失败,请稍后重试");
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user