diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/convert/account/AccountConvert.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/convert/account/AccountConvert.java index 166d0dd3..f92a7094 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/convert/account/AccountConvert.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/convert/account/AccountConvert.java @@ -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 REVIEW_STATUSES = Set.of("draft", "submitted", "under_review", "needs_supplement", "approved", "listed", "delisted", "appealed"); Set MARKET_STATUSES = Set.of("not_listed", "listed", "delisted", "recalled"); Set ACTION_POLICIES = Set.of("allowed", "blocked"); + Set SECURITY_EVENT_TYPES = Set.of("login_anomaly", "credential_expired", "sensitive_export", + "access_denied_burst", "device_change", "permission_escalation"); + Set 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()); // 摘要只取业务描述,不回传 IP、UA、deviceInfo 原始 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 的自由文本来自外部事实源,可能混入 IP、token、API 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 sanitizedStringList(JsonNode node, String fieldName) { + if (node == null || !node.has(fieldName) || !node.get(fieldName).isArray()) { + return Collections.emptyList(); + } + List 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) { + // 未知风险等级不能透传,也不降为 info;warning 是对未知输入的保守中性兜底。 + return enumOrFallback(severity, SECURITY_EVENT_SEVERITIES, "warning"); + } + default String statusOrFallback(String status, Set allowedStatuses, String fallback) { if (status == null || status.isBlank()) { return fallback; } return allowedStatuses.contains(status) ? status : fallback; } + + default String enumOrFallback(String value, Set allowedValues, String fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + return allowedValues.contains(normalized) ? normalized : fallback; + } } diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/convert/account/AccountConvertTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/convert/account/AccountConvertTest.java index f5e1bc19..06f6b985 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/convert/account/AccountConvertTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/convert/account/AccountConvertTest.java @@ -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); + } }