From b468750d001592051f6174391c5910dcbf374156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 4 Sep 2026 23:53:23 +0800 Subject: [PATCH] =?UTF-8?q?task-190:=20=E6=97=A5=E5=BF=97=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E5=A5=91=E7=BA=A6=EF=BC=88StructuredLog.maskToken=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20Token=20=E9=83=A8=E5=88=86=E8=84=B1?= =?UTF-8?q?=E6=95=8F=EF=BC=8CImageVideo=20maskForLog=20=E5=A7=94=E6=89=98?= =?UTF-8?q?=EF=BC=9BURL=20query/=E5=BC=82=E5=B8=B8=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=BD=93=E4=B8=8D=E5=85=A5=E6=97=A5=E5=BF=97=E3=80=81=E9=98=B2?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=EF=BC=89+=208=20=E6=9D=A1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nanri/aiimage/config/StructuredLog.java | 12 ++ .../service/ImageVideoCozeService.java | 10 +- .../aiimage/config/LogRedactionTest.java | 124 ++++++++++++++++++ 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/LogRedactionTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java b/backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java index 5b93c997..54711a82 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/StructuredLog.java @@ -54,6 +54,18 @@ public final class StructuredLog { return result.isEmpty() ? MISSING : result; } + /** 统一 Token 部分脱敏:≤10 位全打码,长 token 保留前6+后4,中间 ***(与既有 maskForLog 一致)。 */ + public static String maskToken(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String text = value.trim(); + if (text.length() <= 10) { + return "***"; + } + return text.substring(0, 6) + "***" + text.substring(text.length() - 4); + } + /** * 按规范字段集渲染 key=value 串:仅输出规范字段、顺序固定,缺失字段用占位符, * 避免不同日志字段漂移。 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java index efc62bd7..2b4df6ae 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java @@ -787,13 +787,7 @@ public class ImageVideoCozeService { } private String maskForLog(String value) { - if (!hasText(value)) { - return ""; - } - String text = value.trim(); - if (text.length() <= 10) { - return "***"; - } - return text.substring(0, 6) + "***" + text.substring(text.length() - 4); + // 统一走 StructuredLog.maskToken(task-190:Token 部分脱敏规则单一来源) + return com.nanri.aiimage.config.StructuredLog.maskToken(value); } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/LogRedactionTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/LogRedactionTest.java new file mode 100644 index 00000000..25db7abb --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/LogRedactionTest.java @@ -0,0 +1,124 @@ +package com.nanri.aiimage.config; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-190:日志脱敏契约(spec 11 §2)。 + * Token 部分脱敏规则单一来源(StructuredLog.maskToken,ImageVideo 已委托);结构化字段值 + * 消毒防日志注入;重试守卫的 URL(去 query)/异常消息体不入日志,保证敏感原文不落盘。 + */ +class LogRedactionTest { + + @Test + void shortTokenFullyMasked() { + assertEquals("***", StructuredLog.maskToken("abc123")); + } + + @Test + void longTokenPartiallyMaskedNoOriginal() { + String original = "abcdefghij-ABCDEFGHIJ-secret-token-000"; + String masked = StructuredLog.maskToken(original); + assertEquals(original.substring(0, 6) + "***" + original.substring(original.length() - 4), masked); + assertFalse(masked.contains(original), "脱敏结果不应包含原始 token"); + } + + @Test + void nullBlankTokenMaskedEmpty() { + assertEquals("", StructuredLog.maskToken(null)); + assertEquals("", StructuredLog.maskToken(" ")); + } + + @Test + void maskingDeterministic() { + String token = "abcdefghijk-1234567890"; + assertEquals(StructuredLog.maskToken(token), StructuredLog.maskToken(token)); + } + + @Test + void structuredFieldStripsNewlineNoLogInjection() { + String value = StructuredLog.field("正常前缀\r\nInject: stolen; more"); + assertFalse(value.contains("\r"), "字段值不应含回车"); + assertFalse(value.contains("\n"), "字段值不应含换行,防止日志注入"); + } + + @Test + void guardStripsTokenFromUrlQueryInLog() throws Exception { + IdempotentRetryGuard guard = new IdempotentRetryGuard(fastProps()); + ListAppender captured = attach(); + try { + AtomicInteger n = new AtomicInteger(); + guard.execute("POST", true, "brand", "http://example/x?token=SECRET-QUERY-999", () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("连接中断"); + } + return "ok"; + }); + String text = join(captured); + assertFalse(text.contains("SECRET-QUERY-999"), "URL query 中的 token 不应入日志: " + text); + } finally { + detach(captured); + } + } + + @Test + void guardExceptionMessageNotLogged() throws Exception { + IdempotentRetryGuard guard = new IdempotentRetryGuard(fastProps()); + ListAppender captured = attach(); + try { + AtomicInteger n = new AtomicInteger(); + guard.execute("POST", true, "brand", "http://example/x", () -> { + if (n.incrementAndGet() < 2) { + throw new IOException("响应: Bearer secret-token-abc-9999"); + } + return "ok"; + }); + String text = join(captured); + assertFalse(text.contains("secret-token-abc-9999"), "异常消息体不应入日志: " + text); + assertFalse(text.contains("Bearer"), "鉴权字样不应入日志"); + } finally { + detach(captured); + } + } + + @Test + void normalFieldsKeptWhileSensitiveMasked() { + // 普通字段原样保留;敏感字段走 maskToken 后不再含原文 + assertTrue(StructuredLog.format(Map.of("taskId", 7L)).contains("taskId=7"), "普通字段应保留"); + String token = "normal-token-value-1234567890"; + assertFalse(StructuredLog.maskToken(token).equals(token), "maskToken 不应原样返回 token"); + } + + private static HttpClientProperties fastProps() { + HttpClientProperties p = new HttpClientProperties(); + p.setBaseRetryDelayMillis(1); + return p; + } + + private static ListAppender attach() { + Logger guardLogger = (Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + guardLogger.addAppender(appender); + return appender; + } + + private static void detach(ListAppender appender) { + ((Logger) LoggerFactory.getLogger(IdempotentRetryGuard.class)).detachAppender(appender); + } + + private static String join(ListAppender appender) { + return String.join("\n", appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList()); + } +}