Просмотр исходного кода

feat(utils): UtilHttp 加 §3.5 审计日志 + UtilHttpAudit 脱敏工具

BACKLOG B2.1 实施。UtilHttp.doRequest 单点改造,所有 doPost/doGet/doPut/doDelete/doPatch/doUpload 重载经它代理,无需重复埋点:

- 进入时记 startMs + 推断 vendor / endpoint
- debug 出入参日志的 header/param/body/form 走 UtilHttpAudit.sanitize
- 成功路径 INFO [http] vendor=X method=Y endpoint=Z latencyMs=N status=success respSize=M
- 异常路径 WARN status=error msg=<exception>,rethrow 不吞

新增 UtilHttpAudit 静态工具(独立 logger name=audit):
- sanitize: 递归脱敏 Map(token/secret/password/aesKey/privateKey/authorization/access_token/client_secret/client_id/appSecret/appKey),不污染源 Map,case-insensitive
- endpoint: URL 提取 path(去 scheme+host+query)
- vendor: 从 host 识别 dingtalk/fxiaoke/teambition/feishu/etc,未知→other
- logSuccess/logError: 统一 [http] 格式

新增 UtilHttpAuditTest 18 用例全过(sanitize 7 + isSensitiveKey 2 + endpoint 5 + vendor 3 + 边界 1)。

OpenSpec change extend-http-audit-log 已立项(proposal/spec/tasks 三件套),等首次生产部署冒烟后 archive。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
malk 1 неделя назад
Родитель
Сommit
79f0146435

+ 51 - 32
mjava/src/main/java/com/malk/utils/UtilHttp.java

@@ -48,39 +48,58 @@ public abstract class UtilHttp {
 
 
     // todo: 认证格式 - Authorization:Basic base64(“admin:密码”)
     // todo: 认证格式 - Authorization:Basic base64(“admin:密码”)
     public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Object body, Map form, String usr, String pwd) {
     public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Object body, Map form, String usr, String pwd) {
-        log.debug("请求入参, url = {}, header = {}, param = {}, body = {}, form = {}", url, header, param, body, form);
-        String path = HttpUtil.urlWithForm(url, param, CharsetUtil.CHARSET_UTF_8, true);
-        HttpRequest request;
-        switch (method) {
-            case GET:
-                request = HttpRequest.get(path);
-                break;
-            case PUT:
-                request = HttpRequest.put(path);
-                break;
-            case PATCH:
-                request = HttpRequest.patch(path);
-                break;
-            case DELETE:
-                request = HttpRequest.delete(path);
-                break;
-            default:
-                request = HttpRequest.post(path);
-                break;
+        // ppExt: §3.5 审计起点 - 记录时间戳 + 推断 vendor / endpoint 供 success/error 路径共用
+        long startMs = System.currentTimeMillis();
+        String vendor = UtilHttpAudit.vendor(url);
+        String endpoint = UtilHttpAudit.endpoint(url);
+        log.debug("请求入参, url = {}, header = {}, param = {}, body = {}, form = {}",
+                url,
+                UtilHttpAudit.sanitize(header),
+                UtilHttpAudit.sanitize(param),
+                body instanceof Map ? UtilHttpAudit.sanitize((Map<?, ?>) body) : body,
+                UtilHttpAudit.sanitize(form));
+        try {
+            String path = HttpUtil.urlWithForm(url, param, CharsetUtil.CHARSET_UTF_8, true);
+            HttpRequest request;
+            switch (method) {
+                case GET:
+                    request = HttpRequest.get(path);
+                    break;
+                case PUT:
+                    request = HttpRequest.put(path);
+                    break;
+                case PATCH:
+                    request = HttpRequest.patch(path);
+                    break;
+                case DELETE:
+                    request = HttpRequest.delete(path);
+                    break;
+                default:
+                    request = HttpRequest.post(path);
+                    break;
+            }
+            request.addHeaders(header).form(form); // form允许为空
+            if (ObjectUtil.isNotNull(body)) {
+                // ppExt: 序列号保留null字段, 不做过滤
+                request.body(JSON.toJSONString(body, SerializerFeature.WriteMapNullValue));
+            }
+            if (StringUtils.isNotBlank(usr) && StringUtils.isNotBlank(pwd)) {
+                request.basicAuth(usr, pwd);
+            }
+            HttpResponse out = request.execute();
+            log.debug("请求响应, {}, {}", out.getStatus(), out.body()); // http 状态判定
+            // ppExt: 外部接口http状态异常, 不直接阻断, 通过 r.assertSuccess(); 校验
+            //McException.assertException(out.getStatus() != 200, String.valueOf(out.getStatus()), "ERROR HTTP STATUS EXCEPTION");
+            String respBody = out.body();
+            UtilHttpAudit.logSuccess(vendor, method.name(), endpoint,
+                    System.currentTimeMillis() - startMs,
+                    respBody == null ? 0 : respBody.length());
+            return respBody;
+        } catch (RuntimeException e) {
+            UtilHttpAudit.logError(vendor, method.name(), endpoint,
+                    System.currentTimeMillis() - startMs, e.getMessage());
+            throw e;
         }
         }
-        request.addHeaders(header).form(form); // form允许为空
-        if (ObjectUtil.isNotNull(body)) {
-            // ppExt: 序列号保留null字段, 不做过滤
-            request.body(JSON.toJSONString(body, SerializerFeature.WriteMapNullValue));
-        }
-        if (StringUtils.isNotBlank(usr) && StringUtils.isNotBlank(pwd)) {
-            request.basicAuth(usr, pwd);
-        }
-        HttpResponse out = request.execute();
-        log.debug("请求响应, {}, {}", out.getStatus(), out.body()); // http 状态判定
-        // ppExt: 外部接口http状态异常, 不直接阻断, 通过 r.assertSuccess(); 校验
-        //McException.assertException(out.getStatus() != 200, String.valueOf(out.getStatus()), "ERROR HTTP STATUS EXCEPTION");
-        return out.body();
     }
     }
 
 
     public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Map body, Map form) {
     public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Map body, Map form) {

+ 132 - 0
mjava/src/main/java/com/malk/utils/UtilHttpAudit.java

@@ -0,0 +1,132 @@
+package com.malk.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 第三方 HTTP 调用审计日志工具
+ *
+ * <p>实施 {@code mjava-baseline §3.5} 请求审计要求:vendor / endpoint / latencyMs / success / errorMsg,
+ * 敏感字段(token / secret / password / aesKey / privateKey 等)在日志中替换为 {@code ***}。</p>
+ *
+ * <p>由 {@link UtilHttp#doRequest} 单点调用;其他 do* 重载经它代理,无需重复埋点。</p>
+ *
+ * <p>日志格式(logger name = {@code audit}):</p>
+ * <pre>
+ * INFO  [http] vendor=dingtalk method=POST endpoint=/topapi/v2/user/get latencyMs=87 status=success respSize=412
+ * WARN  [http] vendor=aliwork method=PUT endpoint=/v1.0/yida/forms/instances latencyMs=2103 status=error msg=Connect timed out
+ * </pre>
+ */
+public abstract class UtilHttpAudit {
+
+    private static final Logger log = LoggerFactory.getLogger("audit");
+
+    /**
+     * 敏感 key 关键字(子串匹配,case-insensitive)。
+     * <p>覆盖 mjava-baseline §3.5 列出的 token/appSecret/password/aesKey/privateKey 及常见变体。</p>
+     */
+    private static final String[] MASK_KEYS = {
+            "token", "secret", "password", "passwd",
+            "aeskey", "privatekey", "publickey",
+            "authorization", "auth_token", "accesstoken",
+            "client_secret", "client_id", "appsecret", "appkey"
+    };
+
+    /**
+     * 递归脱敏 Map:key 名包含敏感关键字(不分大小写)的 value 替换为 {@code ***}。
+     *
+     * <p>规则:</p>
+     * <ul>
+     *   <li>嵌套 Map 递归处理</li>
+     *   <li>非敏感 value 原样保留(包括 List / 数字 / 字符串)</li>
+     *   <li>List 内嵌 Map 不展开递归(性能考虑;调用方需要时自行预处理)</li>
+     *   <li>null 输入返回 null,不抛异常</li>
+     * </ul>
+     */
+    public static Map<String, Object> sanitize(Map<?, ?> source) {
+        if (source == null) return null;
+        Map<String, Object> out = new HashMap<>(source.size());
+        for (Map.Entry<?, ?> e : source.entrySet()) {
+            String key = String.valueOf(e.getKey());
+            Object val = e.getValue();
+            if (isSensitiveKey(key)) {
+                out.put(key, "***");
+            } else if (val instanceof Map) {
+                out.put(key, sanitize((Map<?, ?>) val));
+            } else {
+                out.put(key, val);
+            }
+        }
+        return out;
+    }
+
+    /**
+     * 判断 key 是否敏感
+     */
+    public static boolean isSensitiveKey(String key) {
+        if (key == null || key.isEmpty()) return false;
+        String lower = key.toLowerCase();
+        for (String mk : MASK_KEYS) {
+            if (lower.contains(mk)) return true;
+        }
+        return false;
+    }
+
+    /**
+     * 从 URL 提取 path 部分(去 scheme + host + query)
+     *
+     * <p>典型场景:</p>
+     * <pre>
+     * "https://api.dingtalk.com/v1.0/yida/forms/instances?x=1" → "/v1.0/yida/forms/instances"
+     * "https://oapi.dingtalk.com/gettoken"                     → "/gettoken"
+     * "/relative/path"                                          → "/relative/path"
+     * null / 空                                                 → ""
+     * </pre>
+     */
+    public static String endpoint(String url) {
+        if (url == null || url.isEmpty()) return "";
+        int hostStart = url.indexOf("://");
+        int pathStart = url.indexOf('/', hostStart < 0 ? 0 : hostStart + 3);
+        if (pathStart < 0) return "/";
+        int queryStart = url.indexOf('?', pathStart);
+        return queryStart < 0 ? url.substring(pathStart) : url.substring(pathStart, queryStart);
+    }
+
+    /**
+     * 从 URL host 推断 vendor 名(用于审计聚合)
+     *
+     * <p>识别清单与基座现有 vendor 模块对应。未识别返回 {@code "other"}。</p>
+     */
+    public static String vendor(String url) {
+        if (url == null) return "other";
+        if (url.contains("dingtalk.com")) return "dingtalk";
+        if (url.contains("fxiaoke.com")) return "fxiaoke";
+        if (url.contains("teambition.com")) return "teambition";
+        if (url.contains("ekuaibao.com")) return "ekuaibao";
+        if (url.contains("beisen.com")) return "beisen";
+        if (url.contains("vika.cn")) return "vika";
+        if (url.contains("xbongbong.com")) return "xbongbong";
+        if (url.contains("feishu.cn") || url.contains("larksuite.com")) return "feishu";
+        if (url.contains("h3yun.com")) return "h3yun";
+        return "other";
+    }
+
+    /**
+     * 记录成功调用
+     */
+    public static void logSuccess(String vendor, String method, String endpoint, long latencyMs, int responseSize) {
+        log.info("[http] vendor={} method={} endpoint={} latencyMs={} status=success respSize={}",
+                vendor, method, endpoint, latencyMs, responseSize);
+    }
+
+    /**
+     * 记录失败调用(异常 / 解析失败)
+     */
+    public static void logError(String vendor, String method, String endpoint, long latencyMs, String errorMsg) {
+        log.warn("[http] vendor={} method={} endpoint={} latencyMs={} status=error msg={}",
+                vendor, method, endpoint, latencyMs, errorMsg);
+    }
+}

+ 176 - 0
mjava/src/test/java/com/malk/utils/UtilHttpAuditTest.java

@@ -0,0 +1,176 @@
+package com.malk.utils;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * 单元测试:{@link UtilHttpAudit}
+ *
+ * <p>覆盖 sanitize 脱敏规则 / endpoint URL 解析 / vendor 识别。
+ * BACKLOG B2.1 + mjava-baseline §3.5 审计日志能力。</p>
+ */
+public class UtilHttpAuditTest {
+
+    // ---------- sanitize ----------
+
+    @Test
+    public void sanitize_null_returns_null() {
+        assertNull(UtilHttpAudit.sanitize(null));
+    }
+
+    @Test
+    public void sanitize_empty_returns_empty() {
+        assertTrue(UtilHttpAudit.sanitize(new HashMap<>()).isEmpty());
+    }
+
+    @Test
+    public void sanitize_token_field_masked() {
+        Map<String, Object> in = new HashMap<>();
+        in.put("access_token", "abc123");
+        in.put("name", "alice");
+        Map<String, Object> out = UtilHttpAudit.sanitize(in);
+        assertEquals("***", out.get("access_token"));
+        assertEquals("alice", out.get("name"));
+    }
+
+    @Test
+    public void sanitize_appSecret_password_aesKey_privateKey_all_masked() {
+        Map<String, Object> in = new HashMap<>();
+        in.put("appSecret", "s1");
+        in.put("password", "p1");
+        in.put("aesKey", "k1");
+        in.put("privateKey", "k2");
+        in.put("clientSecret", "s2");
+        in.put("safeField", "ok");
+        Map<String, Object> out = UtilHttpAudit.sanitize(in);
+        assertEquals("***", out.get("appSecret"));
+        assertEquals("***", out.get("password"));
+        assertEquals("***", out.get("aesKey"));
+        assertEquals("***", out.get("privateKey"));
+        assertEquals("***", out.get("clientSecret"));
+        assertEquals("ok", out.get("safeField"));
+    }
+
+    @Test
+    public void sanitize_case_insensitive() {
+        Map<String, Object> in = new HashMap<>();
+        in.put("ACCESS_TOKEN", "x");
+        in.put("Password", "y");
+        in.put("appSECRET", "z");
+        Map<String, Object> out = UtilHttpAudit.sanitize(in);
+        assertEquals("***", out.get("ACCESS_TOKEN"));
+        assertEquals("***", out.get("Password"));
+        assertEquals("***", out.get("appSECRET"));
+    }
+
+    @Test
+    public void sanitize_recurses_into_nested_map() {
+        Map<String, Object> inner = new LinkedHashMap<>();
+        inner.put("token", "secret-val");
+        inner.put("desc", "ok");
+        Map<String, Object> in = new HashMap<>();
+        in.put("config", inner);
+        in.put("name", "alice");
+
+        Map<String, Object> out = UtilHttpAudit.sanitize(in);
+        Map<String, Object> outInner = (Map<String, Object>) out.get("config");
+        assertEquals("***", outInner.get("token"));
+        assertEquals("ok", outInner.get("desc"));
+        assertEquals("alice", out.get("name"));
+    }
+
+    @Test
+    public void sanitize_preserves_non_sensitive_values() {
+        Map<String, Object> in = new HashMap<>();
+        in.put("count", 42);
+        in.put("active", true);
+        in.put("tags", Arrays.asList("a", "b"));
+        Map<String, Object> out = UtilHttpAudit.sanitize(in);
+        assertEquals(42, out.get("count"));
+        assertEquals(true, out.get("active"));
+        assertEquals(Arrays.asList("a", "b"), out.get("tags"));
+    }
+
+    @Test
+    public void sanitize_does_not_mutate_input() {
+        Map<String, Object> in = new HashMap<>();
+        in.put("token", "raw");
+        UtilHttpAudit.sanitize(in);
+        assertEquals("源 Map 不应被改写", "raw", in.get("token"));
+    }
+
+    // ---------- isSensitiveKey ----------
+
+    @Test
+    public void isSensitiveKey_null_or_empty_returns_false() {
+        assertFalse(UtilHttpAudit.isSensitiveKey(null));
+        assertFalse(UtilHttpAudit.isSensitiveKey(""));
+    }
+
+    @Test
+    public void isSensitiveKey_safe_keys_return_false() {
+        assertFalse(UtilHttpAudit.isSensitiveKey("name"));
+        assertFalse(UtilHttpAudit.isSensitiveKey("user_id"));
+        assertFalse(UtilHttpAudit.isSensitiveKey("dept_id_list"));
+    }
+
+    // ---------- endpoint ----------
+
+    @Test
+    public void endpoint_full_url_extracts_path() {
+        assertEquals("/v1.0/yida/forms/instances",
+                UtilHttpAudit.endpoint("https://api.dingtalk.com/v1.0/yida/forms/instances"));
+    }
+
+    @Test
+    public void endpoint_strips_query_string() {
+        assertEquals("/topapi/v2/user/get",
+                UtilHttpAudit.endpoint("https://oapi.dingtalk.com/topapi/v2/user/get?access_token=xxx"));
+    }
+
+    @Test
+    public void endpoint_no_path_returns_slash() {
+        assertEquals("/", UtilHttpAudit.endpoint("https://api.example.com"));
+    }
+
+    @Test
+    public void endpoint_relative_url_returns_as_is() {
+        assertEquals("/iam/api/users", UtilHttpAudit.endpoint("/iam/api/users"));
+    }
+
+    @Test
+    public void endpoint_null_or_empty_returns_empty() {
+        assertEquals("", UtilHttpAudit.endpoint(null));
+        assertEquals("", UtilHttpAudit.endpoint(""));
+    }
+
+    // ---------- vendor ----------
+
+    @Test
+    public void vendor_dingtalk_recognized() {
+        assertEquals("dingtalk", UtilHttpAudit.vendor("https://oapi.dingtalk.com/gettoken"));
+        assertEquals("dingtalk", UtilHttpAudit.vendor("https://api.dingtalk.com/v1.0/yida/forms/instances"));
+    }
+
+    @Test
+    public void vendor_other_vendors_recognized() {
+        assertEquals("fxiaoke", UtilHttpAudit.vendor("https://open.fxiaoke.com/cgi/user/getByMobile"));
+        assertEquals("teambition", UtilHttpAudit.vendor("https://open.teambition.com/api/v3/project/query"));
+        assertEquals("feishu", UtilHttpAudit.vendor("https://open.feishu.cn/open-apis/auth"));
+    }
+
+    @Test
+    public void vendor_unknown_returns_other() {
+        assertEquals("other", UtilHttpAudit.vendor("https://example.com/api"));
+        assertEquals("other", UtilHttpAudit.vendor(null));
+    }
+}