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

fix(安全): 出站日志遮罩键层扩到进件腿的 6 个 PII 键名(R42)

进件腿的出站 trace 有一行「签名原文」日志,它用的是 maskByKey —— 不能用值层,
因为值层不看键名、会把 out_trade_no / pay_trace_no 这些我们自己的对账号一起遮掉,
那等于把这条日志删了。而值层 LONG_DIGITS 是 \d{12,}:18 位证件号接得住,
11 位手机号接不住。⇒ 手机号这一类在出站路径上只有键层能接住,没有第二条路。

名单从 1 个(auth_code)扩到 7 个,新增 6 个逐字:
legal_cert_no / account_idcard_num / principal_cert_no /
account_card_num / contact_phone / account_card_phone。

收不收的判据:值的形态本身就是标识符(证件号 / 卡号 / 手机号)⇒ 遮;
值是业务语义(名称 / 地址 / 邮箱)⇒ 不遮。按这条判据明确不遮的名单
(merchant_name / merchant_name_short / merchant_name_business / contact_name /
legal_name / account_card_name / contact_email / address_* / actual_addr)
连同代价一起写进 javadoc:名称与地址对小微商户仍是个人数据,开 TRACE 会明文进日志,
这是写下来的已知取舍。明确不得加的四个我们自己的对账号
(mch_no / out_trade_no / trade_no / pay_trace_no)也写进 javadoc。

只扩名单,不动结构:不加值层、不在服务层另写一份、不加词边界 \b
(\b 会改变匹配语义,是独立的改动,要单开一件事)。

常量一并改名 AUTH_CODE_KEY → MASKED_KEYS:它已不只认 auth_code,旧名字在说
一件不再成立的事。实测全仓代码引用只在本文件 2 处(声明 + 使用),测试文件零引用。

副作用写进 javadoc,不写成「对入站无影响」:mask() 第一件事就是调 maskByKey
⇒ 放宽键层顺带把入站路径上原本漏掉的 11 位手机号也遮住了(值层阈值 ≥12)。
方向是安全的那一侧,但不是无影响 —— LsyNotifyService 落库的 payload 是
mask(rawBody) 的产物,含这批键名的回调报文其入库文本会改变。

测试:LeshouyingSensitiveTest 新增「6 个键名逐个 + 键名保留」(JSON 与
Map.toString 两种形态)与一条正对照(名单外的键含业务语义 PII 与 18 位对账号
必须原样存活,且 11 位手机号挂到名单外的键上时连 mask 都接不住它);
:268 / :278 两条既有守卫用例仍绿 —— 理由是本轮名单里没有任何键名会与
out_trade_no / auth_code 撞上,且键层仍然是「按键名」、没有引入位数规则。
alphaH 3 недель назад
Родитель
Сommit
adda0324cc

+ 112 - 5
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitive.java

@@ -3,7 +3,28 @@ package com.payment.platform.module.payment.leshouying.client;
 import java.util.regex.Pattern;
 
 /**
- * 乐收赢报文脱敏 —— Task 2 / Task 4 / Task 12 共用同一份实现,不要各写一份正则。
+ * 乐收赢报文脱敏 —— Task 2 / Task 4 / Task 5 / Task 12 共用同一份实现,不要各写一份正则。
+ *
+ * 【★ 覆盖哪些秘密 —— 2026-09-23 扩过一次,它**不再只是付款码**】
+ * 两层各管一摊,只读其中一层就下结论会错:
+ *   - **值层** {@link #LONG_DIGITS}:不看键名,只认「连续 ≥12 位数字」。
+ *   - **键层** {@link #MASKED_KEYS}(原名 `AUTH_CODE_KEY`):按**键名**认,名单现在 7 个 ——
+ *     付款码 `auth_code` 之外,另有 6 个**进件腿的 PII 键名**:`legal_cert_no`(法人证件号)/
+ *     `account_idcard_num`(结算人证件号)/ `principal_cert_no`(身份证)/ `account_card_num`(结算卡号)/
+ *     `contact_phone`(联系电话)/ `account_card_phone`(结算卡手机号)。
+ *     **为什么这一类只能落在键层**:出站那一行 trace 用的是 {@link #maskByKey},而值层是 `\d{12,}`
+ *     —— 18 位证件号接得住,**11 位手机号接不住**。理由与收/不收的判据见 {@link #MASKED_KEYS} 的 javadoc。
+ *
+ * 【★★ 键层**同时**管入站 ——「改键层只影响出站」是**错的**,别照那句写】
+ * 实测(看代码即知,不是推论):{@link #mask} 的第一件事就是调 {@link #maskByKey}
+ * ⇒ **每一次入站遮罩都会跑键层**。放宽名单**顺带**把入站路径上原本漏掉的 11 位手机号
+ * 也遮住了(值层阈值 ≥12)。**方向是安全的那一侧,但它不是「无影响」**:
+ * {@code LsyNotifyService} 落库的 `payload` 是 `LeshouyingSensitive.mask(rawBody)` 的产物
+ * ⇒ 含这批键名的回调报文,**入库文本会因此改变**(多遮几个值)。拿旧报文比对会看到差异。
+ *
+ * 【2026-09-23 那次改动**只动了名单**,没动结构】判据、明确**不**遮的名单(含代价)、
+ * 明确**不得**加的名字、前缀约束、「必须是按键名」这条硬边界 —— 全在 {@link #MASKED_KEYS}
+ * 的 javadoc 里。**改名单前先读那一段。**
  *
  * 【这不是安全边界】它是对**任意畸形文本**的尽力而为,**不保证完整**。
  * 任何人都不得据此认为「这里打日志 / 落库是安全的」。真正的边界是:
@@ -67,11 +88,91 @@ public final class LeshouyingSensitive {
      * 冒号两侧空白。挡住「非数字」的将来形态(值层只认数字)。保留键名本身,
      * 便于排障时仍能看懂报文结构。
      *
-     * 【三条要知道的边界,别按直觉推广】
+     * <p>【★ 名字从 {@code AUTH_CODE_KEY} 改成 {@code MASKED_KEYS}(2026-09-23)】它早已不只认
+     * {@code auth_code}:名单现在有 7 个键名,管两类凭据 —— 付款码({@code auth_code})与
+     * **进件腿的 PII**。旧名字在说一件不再成立的事,而「PII 键管不管」正是读这个文件的人
+     * 要问的第一个问题;留着一个失实的名字,等于用一行代码把答案说反。
+     * 改名成本实测为零:全仓引用只有本文件的声明处与 {@link #maskByKey} 里那一处,
+     * 测试文件里零引用。
+     *
+     * <p>【★★ 一个键名收不收,判据只有一条 —— 改名单前先读这段】
+     * <blockquote>
+     *   <b>值的形态<u>本身就是标识符</u>(证件号 / 卡号 / 手机号)⇒ 收;
+     *   值是<u>业务语义</u>(名称 / 地址 / 邮箱)⇒ 不收。</b>
+     * </blockquote>
+     * 名单里那 7 个就是按这条判据逐条核过的,不是「看着像敏感就加」:
+     * <table border="1">
+     *   <caption>键层名单</caption>
+     *   <tr><th>键名</th><th>是什么</th><th>为什么收</th></tr>
+     *   <tr><td>{@code auth_code}</td><td>付款码</td>
+     *       <td>不是 PII,是**能直接发起支付的凭据**,与「付款码刻意不落库」的决策同源</td></tr>
+     *   <tr><td>{@code legal_cert_no}</td><td>法人证件号</td><td>证件号</td></tr>
+     *   <tr><td>{@code account_idcard_num}</td><td>结算人证件号</td><td>证件号</td></tr>
+     *   <tr><td>{@code principal_cert_no}</td><td>身份证(安心签两腿)</td><td>证件号</td></tr>
+     *   <tr><td>{@code account_card_num}</td><td>结算卡号</td><td>卡号</td></tr>
+     *   <tr><td>{@code contact_phone}</td><td>联系电话</td><td>手机号</td></tr>
+     *   <tr><td>{@code account_card_phone}</td><td>结算卡手机号</td><td>手机号</td></tr>
+     * </table>
+     *
+     * <p>【★ 为什么这件事只能落在键层(出站路径)】出站那一行 trace 用
+     * {@link #maskByKey},而它**不能**改用 {@link #mask}(值层会把 {@code out_trade_no} /
+     * {@code pay_trace_no} 这些**我们自己的对账号**一起遮掉 —— 那等于把这条日志删了,
+     * 理由见 {@link #maskByKey} 的 javadoc)。而值层 {@link #LONG_DIGITS} 是
+     * {@code \d{12,}}:18 位证件号接得住,<b>11 位手机号接不住</b>。
+     * ⇒ 手机号这一类在出站路径上**只有键层能接住**,没有第二条路。
+     * (可执行证据:{@code LeshouyingSensitiveTest#keyLayer_masksNothingButTheListedKeys}
+     * 断言「同一个 11 位号码,键名换成不在名单上的 {@code return_msg} 之后,
+     * 连两层全上的 {@code mask} 都遮不住它」。)
+     *
+     * <p>【★★ 明确<u>不收</u>,且这是写下来的已知取舍】{@code merchant_name} /
+     * {@code merchant_name_short} / {@code merchant_name_business} / {@code contact_name} /
+     * {@code legal_name} / {@code account_card_name} / {@code contact_email} /
+     * {@code address_*} / {@code actual_addr}。
+     * <b>代价(不许省略地记在这里)</b>:名称与地址对小微商户而言**仍然是个人数据**
+     * ({@code merchant_name} 常常就是店主的姓名),联调开 TRACE 时它们会**明文进日志**。
+     * 之所以仍然不收,理由与反对出站用值层是**同一条**:这条日志的唯一用途是回答
+     * 「这次请求到底发了什么参数」,把大半个报文遮掉,就等于把这条日志删了
+     * —— 而它自述是「全模块唯一一处记录签名原文的地方」。
+     * 换句话说:**这是拿 PII 的可见性换可诊断性的一次明账,不是漏网。**
+     *
+     * <p>【★ 明确<u>不得</u>收进来的】{@code mch_no} / {@code out_trade_no} / {@code trade_no} /
+     * {@code pay_trace_no} —— 这四个是**我们自己的对账号**({@code mch_no} 还是我们自己
+     * 发布给运维的应答字段名),收进来 = 删掉这条日志,正是上面那条判据要防的。
+     * {@code license_no}(营业执照号,统一社会信用代码,**可公开查询**)也不收。
+     * <b>要加,先来问</b>,别顺手。
+     *
+     * <p>【★ 名单的形态约束:两两之间不得有前缀关系、不得重复】
+     * 当前 7 个两两之间 **0 对前缀关系**、无重复(判定器带正对照:对
+     * {@code ['auth_code','auth_code_v2']} 确实报得出 1 对)。
+     * 有了这条,alternation 的**书写顺序**就是无关变量(无害改动:换个顺序行为不变)。
+     * 将来若要加名字,先重跑这个检查再落地。
+     *
+     * <p>【★ 扩键层**必须仍然是「按键名」**】{@code LeshouyingSensitiveTest} 里
+     * {@code maskByKey_masksAuthCode_butLeavesOurOwnLongNumbersAlone} 钉着
+     * 「{@code out_trade_no=202609210000000123}(18 位、有键名)必须原样存活」
+     * —— 谁要是顺手加一条「位数规则」,它立刻红。<b>那正是那条用例存在的意义。</b>
+     *
+     * <p>【★★ 改键层<u>会</u>影响入站 —— 这一句必须写对,别写成「对入站无影响」】
+     * {@link #mask} 的第一件事就是调 {@link #maskByKey}(这是实测,看代码即知,
+     * 不是推论)⇒ <b>每一次入站遮罩都会跑键层。</b>
+     * 于是放宽名单有一个**白捡的副作用**:入站路径上 11 位手机号**过去根本没被遮**
+     * (值层阈值 ≥12),放宽之后它们也进遮罩了。
+     * <b>但这会改变入库内容</b>:{@code LsyNotifyService} 落库的
+     * {@code payload} 文本是 {@code LeshouyingSensitive.mask(rawBody)} 的产物
+     * ⇒ 含这批键名的回调报文,其 {@code payload} 会**多遮几个值**。
+     * 方向是**安全的那一侧**,但它**不是「无影响」**:入库内容是观测对象,
+     * 拿旧报文比对会看到差异。
+     *
+     * <p>【三条要知道的边界,别按直觉推广】
      *   1. **没有词边界**(实测,同 `Rx` 探针):加 `\b` 会挡掉 `_auth_code` 与 `xauth_code`
      *      (`_` 是词字符,前面没有边界),只有 `authcode` 还能命中 —— 即「加 `\b` 让匹配行为
      *      在 `authcode` 和 `auth_code` 之间变得不一致」。不加则三种都命中;
      *      代价是 `myauth_code=` 这种也会命中 —— 方向安全,接受。
+     *      ★ <b>本轮(扩名单)刻意<u>不</u>加 `\b`</b>。理由**不是**「原文故意如此」——
+     *      <b>查不到</b>任何说明它是有意设计的依据(本段通篇讲的是 `MASK` 里的 `$`/`\`、
+     *      以及尾巴 `[^"'&\s,}]*` 的「过度吃进」,**只字未提词边界**),所以不给它安一个意图。
+     *      理由是**改动最小化**:本轮的边界是「只扩名单、不动结构」,
+     *      而 `\b` 会改变匹配语义(那是一个独立的、可论证的改动)⇒ 要做就**单开一件事**。
      *   2. **`replaceAll("$1" + MASK)` 的风险在 `MASK`,不在 group 1**(实测,见 `Rx` 探针):
      *      `$1` 是**按引用**解析的,group 1 捕获到的文本原样拷进结果,永远不会被二次解析成
      *      替换语法 —— 输入里就算有 `$1`,输出里还是 `$1`。所以 group 1 的字符集与安全性无关。
@@ -84,13 +185,19 @@ public final class LeshouyingSensitive {
      *          `"$1" + "2xx"` 实测输出 **`a2xx`**(组 1 = `a`,`2xx` 当字面量),不是组 12。
      *          只有组 1 捕到空串时输出才是 `2xx` —— 那是个特例,别当成通例。
      *      当前 `MASK = "***"`,不含 `$` 也不含 `\`,安全。**改 `MASK` 时必须重查这两条。**
+     *      ★ 名单扩成 alternation 时**必须用非捕获组 `(?:…)`**:多一个捕获组会让
+     *      group 1 变成别的东西,而 `$1` 是硬编码引用的 —— 那种错误**不抛异常**,
+     *      只把键名也一起遮掉或遮错位置。{@code keyLayer_masksPiiKeys_keepsKeyNames}
+     *      断言「键名保留」正是为此。
      *   3. **尾巴 `[^"'&\s,}]*` 是「过度吃进」的**:它会把 `%31%33…`(URL 编码)整段吃掉。
      *      这正是我们要的 —— 见类注释里那条「看起来像残留、其实被覆盖」。
      *      代价是遇到 `auth_code=abc123def` 时会把整串非空白内容当值消费掉;
      *      在「值不含空白」的假设下这是对的,在含空白的畸形文本里会提前停(已被测试具名记录)。
      */
-    private static final Pattern AUTH_CODE_KEY = Pattern.compile(
-            "(?i)([\"']?auth_?code[\"']?\\s*[:=]\\s*[\"']?)[^\"'&\\s,}]*");
+    private static final Pattern MASKED_KEYS = Pattern.compile(
+            "(?i)([\"']?(?:auth_?code|legal_cert_no|account_idcard_num|principal_cert_no"
+                    + "|account_card_num|contact_phone|account_card_phone)[\"']?"
+                    + "\\s*[:=]\\s*[\"']?)[^\"'&\\s,}]*");
 
     private static final String MASK = "***";
 
@@ -128,6 +235,6 @@ public final class LeshouyingSensitive {
         if (text == null) {
             return null;
         }
-        return AUTH_CODE_KEY.matcher(text).replaceAll("$1" + MASK);
+        return MASKED_KEYS.matcher(text).replaceAll("$1" + MASK);
     }
 }

+ 97 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitiveTest.java

@@ -289,4 +289,101 @@ class LeshouyingSensitiveTest {
         assertTrue(LeshouyingSensitive.maskByKey(body).contains("134573607383541008"),
                 "maskByKey 遮不住 —— 这正是它只配用于「我们自己构造的报文」的原因");
     }
+
+    // ── H. 键层扩到 PII 键名(R42):进件腿的出站 trace 会带上这些字段 ──────────
+    //
+    // 【为什么只能扩在键层】出站那一行 trace 用的是 maskByKey,判据见它的 javadoc
+    //   (出站**不能**用值层:out_trade_no / pay_trace_no 是我们自己的对账号,遮掉 = 删掉这条日志)。
+    //   而值层 LONG_DIGITS 是 `\d{12,}` —— 18 位证件号接得住,**11 位手机号接不住**。
+    //   ⇒ 手机号这一类在出站路径上**只有键层能接住**,没有第二条路。
+    //
+    // 【判据(名单怎么来的)】值的形态**本身就是标识符**(证件号 / 卡号 / 手机号)⇒ 遮;
+    //   值是**业务语义**(名称 / 地址 / 邮箱)⇒ 不遮 —— 后者的正对照是
+    //   keyLayer_masksNothingButTheListedKeys(同一份报文里那些键必须原样存活)。
+    //
+    // 【扩键层必须仍然是「按键名」】`:268` 那条(out_trade_no 的 18 位必须原样存活)就是守卫:
+    //   谁要是顺手加一条「位数规则」,它立刻红。下面这些用例只按键名遮,**不加任何位数判据**。
+
+    /**
+     * 6 个新增 PII 键名**逐个**:值被遮成 {@code ***}、**键名保留**。
+     *
+     * <p>两列 = 出站报文在真实世界里的**两种形态**:左边是 JSON(上游报文形态),
+     * 右边是 Java {@code Map.toString()}({@code LeshouyingClient.traceOutbound} 打进日志的
+     * 就是 {@code signedPayload.toString()},即**右边这一种**)。两种都钉,
+     * 因为把日志改成打 JSON 是随时可能发生的一次改动,而两种形态的引号不同 ——
+     * 只钉一种的话,另一种静默失效(键层里那两处 {@code ["']?} 就是为它们存在的)。
+     *
+     * <p>★ 陪跑的 {@code auth_code} 是既有的第 7 个键名:扩名单不能把它挤掉。
+     *
+     * <p>★ <b>两个 11 位手机号是刻意选的</b>:值层 {@code \d{12,}} 看不见它们
+     * (下界是闭区间 12),所以这两行**只有键层能接住**。若把它们换成 18 位数字,
+     * 本用例对「键层到底有没有在工作」就失去分辨力 —— 值层也能遮同样的东西。
+     */
+    @Test
+    void keyLayer_masksPiiKeys_keepsKeyNames() {
+        String[][] cases = {
+                {"auth_code", "134573607383541001"},
+                {"legal_cert_no", "100000000000000001"},
+                {"account_idcard_num", "100000000000000002"},
+                {"principal_cert_no", "100000000000000003"},
+                {"account_card_num", "6200000000000001"},
+                {"contact_phone", "15800000001"},
+                {"account_card_phone", "15800000002"},
+        };
+        for (String[] c : cases) {
+            String key = c[0];
+            String value = c[1];
+
+            assertEquals("{\"" + key + "\":\"***\"}",
+                    LeshouyingSensitive.maskByKey("{\"" + key + "\":\"" + value + "\"}"),
+                    "JSON 形态:" + key + " 的值必须被遮、键名必须保留");
+
+            assertEquals("{" + key + "=***, inst_no=900075}",
+                    LeshouyingSensitive.maskByKey("{" + key + "=" + value + ", inst_no=900075}"),
+                    "Map.toString 形态(出站日志的真实形态):" + key + " 的值必须被遮、键名必须保留");
+        }
+    }
+
+    /**
+     * ★ <b>正对照:证明「是按名单遮的」,不是「遮多了」。</b>
+     *
+     * <p>与 {@link #keyLayer_masksPiiKeys_keepsKeyNames} 是同一份判据的两侧 ——
+     * 只有「该遮的遮了」而拿不出「不该遮的没被遮」,就分不清「名单精确」和「无差别乱遮」
+     * 两种实现。这一段报文里**每一个**键都**不在**名单上,所以 {@code maskByKey} 的输出
+     * 必须与输入**逐字节相同**(用 {@code assertEquals} 而不是逐条 {@code contains}:
+     * 等式把「多遮了别处」也一起钉住)。
+     *
+     * <p>三类陪跑者各自钉一件事:
+     * <ol>
+     *   <li><b>业务语义的 PII</b>({@code merchant_name} / {@code legal_name} /
+     *       {@code contact_email} / {@code actual_addr})—— 名单**有意不收**它们:
+     *       收了就等于删掉这条 trace 日志的唯一用途(理由与反对值层是同一条)。
+     *       它们是**写下来的已知取舍**,不是漏网。</li>
+     *   <li><b>我们自己的对账号</b>({@code out_trade_no} 的 18 位)—— 这正是
+     *       {@code :268} 那条既有用例钉的东西;这里再放一次,是为了让「名单精确」
+     *       与「不误伤对账号」在**同一份报文**里同时成立。</li>
+     *   <li><b>一段裸长数字</b>(不在任何键名之后)—— 键层对它是瞎的,这是
+     *       {@code maskByKey} 的既定性质({@code :278} 那条用例已钉)。</li>
+     * </ol>
+     *
+     * <p>★ <b>最后一条断言是这段用例的支点</b>:{@code contact_phone} 的键名换成
+     * {@code return_msg}(不在名单上)之后,**连 {@code mask()} 这个两层全上的入口都遮不住它**
+     * —— 值层看不见 11 位数字。这就是「手机号只有键层能接住」的可执行证据,
+     * 也是为什么键层的名单里必须有它。
+     */
+    @Test
+    void keyLayer_masksNothingButTheListedKeys() {
+        String body = "{\"merchant_name\":\"运来水果店\",\"legal_name\":\"张三\","
+                + "\"contact_email\":\"a@b.com\",\"actual_addr\":\"广东省广州市天河区\","
+                + "\"out_trade_no\":\"202609210000000123\",\"mcc_code\":125,"
+                + "\"note\":\"ref 202609210000000999\",\"return_msg\":\"15800000001\"}";
+
+        assertEquals(body, LeshouyingSensitive.maskByKey(body),
+                "名单外的键(含业务语义 PII 与我们自己的对账号)必须原样存活 —— "
+                        + "否则就是「无差别乱遮」,而不是「按名单遮」");
+
+        assertTrue(LeshouyingSensitive.mask(body).contains("15800000001"),
+                "11 位手机号落在**名单外**的键上时,连两层全上的 mask 都接不住它"
+                        + "(值层下界是 12)—— 这正是键层名单里必须有 contact_phone 的理由");
+    }
 }