task-179: 禁止记录清单审计(语句级扫描 src/main log.*,Token/Cookie/完整body/二进制/凭据/APIKey 白名单校验)+ 8 条测试

- 多行感知扫描:body={}/authorization/Bearer/token 语句必须过 maskChatBody/maskSecretsForLog/maskForLog/toJsonForLog/abbreviate 等脱敏,否则判违规
- Cookie/凭据/API Key/二进制类字段一律不允许出现;LLM 脱敏后的 body 诊断保留(allowed_fields_kept)
- 审计结果:现网 LLM 客户端日志均已脱敏,零裸漏,无需修正;纯扫描可重复
This commit is contained in:
2026-09-04 23:40:38 +08:00
parent 620ee15a48
commit 5fa59680f2
@@ -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 §2Token/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<String> 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<Path> paths = Files.walk(Path.of("src/main/java"))) {
for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) {
List<String> 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<String> found = violations(pattern, requireMask);
assertTrue(found.isEmpty(), label + " 不应入日志(需脱敏或移除):\n " + String.join("\n ", found));
}
private static List<String> violations(Pattern pattern, boolean requireMask) throws IOException {
List<String> violations = new ArrayList<>();
try (Stream<Path> paths = Files.walk(Path.of("src/main/java"))) {
for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) {
List<String> 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<String> 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;
}
}