|
|
@@ -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);
|
|
|
}
|
|
|
}
|