fix(p1r): 收敛安全事件响应合同
This commit is contained in:
parent
0d37d37c76
commit
e27138ef06
@ -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());
|
||||
// 摘要只取业务描述,不回传 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<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) {
|
||||
// 未知风险等级不能透传,也不降为 info;warning 是对未知输入的保守中性兜底。
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user