fix(p1r): 收敛安全事件响应合同

This commit is contained in:
zizi 2026-05-29 23:31:51 +08:00
parent 0d37d37c76
commit e27138ef06
2 changed files with 111 additions and 11 deletions

View File

@ -23,6 +23,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
@ -43,8 +44,19 @@ public interface AccountConvert {
Set<String> REVIEW_STATUSES = Set.of("draft", "submitted", "under_review", "needs_supplement", "approved", "listed", "delisted", "appealed");
Set<String> MARKET_STATUSES = Set.of("not_listed", "listed", "delisted", "recalled");
Set<String> ACTION_POLICIES = Set.of("allowed", "blocked");
Set<String> SECURITY_EVENT_TYPES = Set.of("login_anomaly", "credential_expired", "sensitive_export",
"access_denied_burst", "device_change", "permission_escalation");
Set<String> SECURITY_EVENT_SEVERITIES = Set.of("info", "warning", "critical");
Pattern IPV4_TEXT_PATTERN = Pattern.compile(
"\\b((25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b");
Pattern SECRET_ASSIGNMENT_PATTERN = Pattern.compile(
"(?i)\\b(secret|token|api[-_ ]?key|authorization|access[-_ ]?token|refresh[-_ ]?token|id[-_ ]?token)\\b\\s*[:=]\\s*(?:Bearer\\s+)?[^\\s,;\\]}\\)]+");
Pattern AUTHORIZATION_BEARER_PATTERN = Pattern.compile(
"(?i)\\b(authorization)\\s+Bearer\\s+[^\\s,;\\]}\\)]+");
Pattern SECRET_WORD_VALUE_PATTERN = Pattern.compile(
"(?i)\\b(secret|token|api[-_ ]?key)\\s+[^\\s,;\\]}\\)]+");
Pattern SECRET_LIKE_TOKEN_PATTERN = Pattern.compile(
"(?i)\\b(?=[A-Za-z0-9._-]{12,}\\b)(?:sk-[A-Za-z0-9._-]*|[A-Za-z0-9._-]*(?:secret|token|api[-_]?key)[A-Za-z0-9._-]*)\\b");
default AppProfileRespVO convertProfile(AccountProfileDO profile, String email, String phone,
Boolean emailVerified, Boolean phoneVerified, String status,
@ -165,8 +177,8 @@ public interface AccountConvert {
JsonNode deviceNode = parseSnapshot(event.getDeviceInfo());
SecurityEventSummaryRespVO vo = new SecurityEventSummaryRespVO();
vo.setEventId(stringId(event.getId()));
vo.setEventType(event.getEventType());
vo.setSeverity(event.getSeverity());
vo.setEventType(toSecurityEventType(event.getEventType()));
vo.setSeverity(toSecurityEventSeverity(event.getSeverity()));
vo.setOccurredAt(event.getCreateTime());
// 摘要只取业务描述不回传 IPUAdeviceInfo 原始 JSON
vo.setDescription(maskSensitiveText(text(deviceNode, "description")));
@ -182,14 +194,14 @@ public interface AccountConvert {
JsonNode deviceNode = parseSnapshot(event.getDeviceInfo());
SecurityEventDetailRespVO vo = new SecurityEventDetailRespVO();
vo.setEventId(stringId(event.getId()));
vo.setEventType(event.getEventType());
vo.setSeverity(event.getSeverity());
vo.setEventType(toSecurityEventType(event.getEventType()));
vo.setSeverity(toSecurityEventSeverity(event.getSeverity()));
vo.setOccurredAt(event.getCreateTime());
vo.setSourceIp(maskIp(event.getIpAddress()));
vo.setDeviceInfo(convertDeviceInfo(deviceNode));
vo.setAffectedScope(maskSensitiveText(text(deviceNode, "affectedScope")));
vo.setDescription(maskSensitiveText(text(deviceNode, "description")));
vo.setSuggestedActions(stringList(deviceNode, "suggestedActions"));
vo.setSuggestedActions(sanitizedStringList(deviceNode, "suggestedActions"));
if (ack != null) {
vo.setAcknowledgedAt(ack.getCreateTime());
vo.setAcknowledgedAction(ack.getAction());
@ -207,8 +219,8 @@ public interface AccountConvert {
default SecurityEventDetailRespVO.DeviceInfoVO convertDeviceInfo(JsonNode deviceNode) {
SecurityEventDetailRespVO.DeviceInfoVO vo = new SecurityEventDetailRespVO.DeviceInfoVO();
vo.setUserAgent(maskUserAgent(text(deviceNode, "userAgent")));
vo.setPlatform(text(deviceNode, "platform"));
vo.setLocation(text(deviceNode, "location"));
vo.setPlatform(maskSensitiveText(text(deviceNode, "platform")));
vo.setLocation(maskSensitiveText(text(deviceNode, "location")));
return vo;
}
@ -259,17 +271,31 @@ public interface AccountConvert {
}
int blankIndex = userAgent.indexOf(' ');
if (blankIndex < 0) {
return userAgent;
return "***";
}
return userAgent.substring(0, blankIndex) + " ***";
String productToken = userAgent.substring(0, blankIndex);
String sanitizedProductToken = maskSensitiveText(productToken);
if (!productToken.equals(sanitizedProductToken) || "***".equals(sanitizedProductToken)) {
return "***";
}
return sanitizedProductToken + " ***";
}
default String maskSensitiveText(String text) {
if (text == null || text.isBlank()) {
return text;
}
// deviceInfo JSON description/affectedScope 属于外部事实文本可能嵌入 IP输出前统一做 IPv4 摘要化
return IPV4_TEXT_PATTERN.matcher(text).replaceAll(match -> maskIp(match.group()));
// deviceInfo JSON 的自由文本来自外部事实源可能混入 IPtokenAPI Key Authorization 响应前统一收敛为摘要
String masked = IPV4_TEXT_PATTERN.matcher(text).replaceAll(match -> maskIp(match.group()));
masked = SECRET_ASSIGNMENT_PATTERN.matcher(masked).replaceAll(match -> {
String keyword = match.group(1);
String separator = match.group().contains(":") ? ":" : "=";
return keyword + separator + "***";
});
masked = AUTHORIZATION_BEARER_PATTERN.matcher(masked)
.replaceAll(match -> match.group(1) + " Bearer ***");
masked = SECRET_WORD_VALUE_PATTERN.matcher(masked).replaceAll(match -> match.group(1) + " ***");
return SECRET_LIKE_TOKEN_PATTERN.matcher(masked).replaceAll("***");
}
default JsonNode parseSnapshot(String json) {
@ -320,6 +346,19 @@ public interface AccountConvert {
return values;
}
default List<String> sanitizedStringList(JsonNode node, String fieldName) {
if (node == null || !node.has(fieldName) || !node.get(fieldName).isArray()) {
return Collections.emptyList();
}
List<String> values = new java.util.ArrayList<>();
node.get(fieldName).forEach(item -> {
if (item != null && !item.isNull()) {
values.add(maskSensitiveText(item.asText()));
}
});
return values;
}
default LocalDateTime dateTime(JsonNode node, String fieldName) {
String value = text(node, fieldName);
if (value == null || value.isBlank()) {
@ -361,10 +400,28 @@ public interface AccountConvert {
return statusOrFallback(status, REVIEW_STATUSES, "needs_supplement");
}
default String toSecurityEventType(String eventType) {
// 存量安全事件没有数据库枚举约束响应层必须兜底到 OpenAPI 已声明枚举避免内部事件名泄露给 App
return enumOrFallback(eventType, SECURITY_EVENT_TYPES, "login_anomaly");
}
default String toSecurityEventSeverity(String severity) {
// 未知风险等级不能透传也不降为 infowarning 是对未知输入的保守中性兜底
return enumOrFallback(severity, SECURITY_EVENT_SEVERITIES, "warning");
}
default String statusOrFallback(String status, Set<String> allowedStatuses, String fallback) {
if (status == null || status.isBlank()) {
return fallback;
}
return allowedStatuses.contains(status) ? status : fallback;
}
default String enumOrFallback(String value, Set<String> allowedValues, String fallback) {
if (value == null || value.isBlank()) {
return fallback;
}
String normalized = value.trim().toLowerCase(Locale.ROOT);
return allowedValues.contains(normalized) ? normalized : fallback;
}
}

View File

@ -5,6 +5,7 @@ import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminPurchaseRe
import cn.iocoder.muse.module.member.controller.app.account.vo.AppPublishRecordRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppProfileRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountProfileDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountSecurityEventAckDO;
@ -155,6 +156,42 @@ class AccountConvertTest {
assertEquals("password_changed", vo.getAcknowledgedAction());
}
@Test
void should_normalizeSecurityEnumsAndSanitizeAllDeviceInfoText() {
MemberSecurityEventDO event = MemberSecurityEventDO.builder()
.id(89L)
.accountUserId(1001L)
.eventType("internal_password_reset_token")
.severity("urgent")
.ipAddress("198.51.100.23")
.deviceInfo("""
{"userAgent":"sk-user-agent-secret-123456",
"platform":"macOS apiKey=sk-platform-secret-123456",
"location":"authorization Bearer loc-secret-123456",
"affectedScope":"scope token=scope-secret-123456 apiKey sk-scope-secret-123456",
"description":"desc authorization=Bearer desc-secret-123456 from 198.51.100.23",
"suggestedActions":["rotate token=action-secret-123456","remove apiKey sk-action-secret-123456"]}
""")
.acknowledged(false)
.build();
event.setCreateTime(LocalDateTime.of(2026, 5, 29, 11, 0));
SecurityEventSummaryRespVO summary = convert.convertSecurityEventSummary(event, null);
SecurityEventDetailRespVO detail = convert.convertSecurityEventDetail(event, null);
assertEquals("login_anomaly", summary.getEventType());
assertEquals("warning", summary.getSeverity());
assertEquals("login_anomaly", detail.getEventType());
assertEquals("warning", detail.getSeverity());
assertEquals("***", detail.getDeviceInfo().getUserAgent());
assertDoesNotContainRawSecrets(summary.getDescription());
assertDoesNotContainRawSecrets(detail.getDeviceInfo().getPlatform());
assertDoesNotContainRawSecrets(detail.getDeviceInfo().getLocation());
assertDoesNotContainRawSecrets(detail.getAffectedScope());
assertDoesNotContainRawSecrets(detail.getDescription());
detail.getSuggestedActions().forEach(AccountConvertTest::assertDoesNotContainRawSecrets);
}
private AccountRecordProjectionDO purchaseProjection(String recordId, String externalOrderRef,
String authorizationResult) {
AccountRecordProjectionDO projection = AccountRecordProjectionDO.builder()
@ -171,4 +208,10 @@ class AccountConvertTest {
projection.setCreateTime(LocalDateTime.of(2026, 5, 28, 10, 30));
return projection;
}
private static void assertDoesNotContainRawSecrets(String value) {
assertFalse(value.contains("sk-"), value);
assertFalse(value.contains("secret-123456"), value);
assertFalse(value.contains("198.51.100.23"), value);
}
}