diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/ForbiddenLogAuditTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/ForbiddenLogAuditTest.java new file mode 100644 index 00000000..6ce98753 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/ForbiddenLogAuditTest.java @@ -0,0 +1,162 @@ +package com.nanri.aiimage.config; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-179:禁止记录清单审计(spec 11 §2:Token/Cookie/完整请求体/文件二进制/凭据/敏感 API Key + * 不入日志;允许脱敏/截断后的字段保留)。 + * + * 语句级扫描 src/main 的全部 log.* 调用(含跨行参数):凡 format 会出现完整敏感值的高风险 + * 模式做白名单校验——body={} 或 authorization/Bearer/token 的语句必须经脱敏/截断 helper + * (maskChatBody/maskSecretsForLog/maskForLog/toJsonForLog/abbreviate 等);Cookie/凭据/ + * API Key/二进制类字段一律不允许出现。纯扫描可重复,违规即列出 file:line。 + */ +class ForbiddenLogAuditTest { + + private static final List MASK_HELPERS = List.of( + "maskChatBody", "maskSecretsForLog", "maskForLog", "toJsonForLog", + "abbreviate", "mask(", "redact"); + + private static final Pattern LOG_CALL = Pattern.compile("log\\.(info|warn|error|debug|trace)\\s*\\("); + + private static final Pattern TOKEN_AUTH = Pattern.compile( + "\"[^\"]*(authorization|Bearer|token=)[^\"]*\""); + private static final Pattern COOKIE = Pattern.compile("cookie="); + private static final Pattern FULL_BODY = Pattern.compile("body=\\{\\}"); + private static final Pattern BINARY = Pattern.compile( + "(fileBinary|binary=|base64|fileContent=|contentBytes=)"); + private static final Pattern CREDENTIAL = Pattern.compile("(password=|secret=|credential=)"); + private static final Pattern API_KEY = Pattern.compile("(apiKey=|api_secret=|clientSecret=)"); + + @Test + void noTokenLogged() throws IOException { + assertNoViolations("Token/鉴权头", TOKEN_AUTH, true); + } + + @Test + void noCookieLogged() throws IOException { + assertNoViolations("Cookie", COOKIE, false); + } + + @Test + void noFullRequestBodyLogged() throws IOException { + assertNoViolations("完整请求体 body={}", FULL_BODY, true); + } + + @Test + void noBinaryLogged() throws IOException { + assertNoViolations("文件二进制", BINARY, false); + } + + @Test + void noCredentialLogged() throws IOException { + assertNoViolations("用户凭据", CREDENTIAL, false); + } + + @Test + void noApiKeyLogged() throws IOException { + assertNoViolations("敏感 API Key", API_KEY, false); + } + + @Test + void allowedMaskedFieldsKept() throws IOException { + // 允许项保留:LLM 请求/响应的 body 经 maskChatBody/toJsonForLog/abbreviate 脱敏后仍记录, + // 审计不得把这些"已脱敏的可用诊断"当违规删掉(否则白名单空转)。 + int maskedBodyStatements = 0; + try (Stream paths = Files.walk(Path.of("src/main/java"))) { + for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + List lines = Files.readAllLines(path); + for (int i = 0; i < lines.size(); i++) { + if (!LOG_CALL.matcher(lines.get(i)).find()) { + continue; + } + String stmt = joinStatement(lines, i); + if (FULL_BODY.matcher(stmt).find() && hasMaskHelper(stmt)) { + maskedBodyStatements++; + } + } + } + } + assertTrue(maskedBodyStatements >= 4, + "应保留至少 4 处已脱敏的 body 诊断日志,实际 " + maskedBodyStatements); + } + + @Test + void auditScriptRepeatable() throws IOException { + assertEquals(violations(TOKEN_AUTH, true), violations(TOKEN_AUTH, true), + "审计可重复执行且结果稳定"); + assertEquals(0, violations(FULL_BODY, true).size(), "重复扫描 body 违规应为 0"); + } + + private static void assertNoViolations(String label, Pattern pattern, boolean requireMask) throws IOException { + List found = violations(pattern, requireMask); + assertTrue(found.isEmpty(), label + " 不应入日志(需脱敏或移除):\n " + String.join("\n ", found)); + } + + private static List violations(Pattern pattern, boolean requireMask) throws IOException { + List violations = new ArrayList<>(); + try (Stream paths = Files.walk(Path.of("src/main/java"))) { + for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + List lines = Files.readAllLines(path); + for (int i = 0; i < lines.size(); i++) { + if (!LOG_CALL.matcher(lines.get(i)).find()) { + continue; + } + String statement = joinStatement(lines, i); + if (pattern.matcher(statement).find()) { + boolean ok = !requireMask || hasMaskHelper(statement); + if (!ok) { + violations.add(path + ":" + (i + 1) + " " + trim(lines.get(i))); + } + } + } + } + } + return violations; + } + + private static String joinStatement(List lines, int start) { + StringBuilder sb = new StringBuilder(lines.get(start)); + int depth = 0; + for (int i = start; i < lines.size() && i < start + 8; i++) { + if (i > start) { + sb.append(' ').append(lines.get(i)); + } + depth += count(lines.get(i), '(') - count(lines.get(i), ')'); + if (depth <= 0 && lines.get(i).contains(");")) { + break; + } + } + return sb.toString(); + } + + private static int count(String text, char c) { + int n = 0; + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == c) { + n++; + } + } + return n; + } + + private static boolean hasMaskHelper(String statement) { + return MASK_HELPERS.stream().anyMatch(statement::contains); + } + + private static String trim(String line) { + String t = line.trim(); + return t.length() > 160 ? t.substring(0, 160) : t; + } +}