From 66929786c0b363a870f227a29591fe477a8cf7fd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 09:53:37 +0800 Subject: [PATCH] =?UTF-8?q?docs(core):=20M240=20=E5=A4=8D=E5=AE=A1=20P2-1/?= =?UTF-8?q?P2-2/P2-3=20=E2=80=94=E2=80=94=20=E5=8F=A3=E5=BE=84=E6=94=B6?= =?UTF-8?q?=E7=AA=84=E3=80=81=E5=A4=A7=E5=86=99=E5=88=A4=E6=8D=AE=E9=92=89?= =?UTF-8?q?=E4=BD=8F=E3=80=81=E6=89=BE=E5=9B=9E=E4=B8=A2=E6=8E=89=E7=9A=84?= =?UTF-8?q?=E8=B4=9F=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1:「抹除能力没有下降」是绝对承诺,已改成准确口径——收窄的就是三类形状 (`=` 边界、填充不计段长、全小写点分串),每条都在 shape 模块文档里写明,并用 `known_narrowings_old_masked_new_keeps` 把「旧行为抹、新行为留」钉住。三条的旧行为 是用 M240 之前的真实代码(`git show 74be510:…` + rustc 探针)实测的,不是推断。 P2-2:「必须含大写」原先没有任何测试钉住(复审实测:删掉它 13 条仍全绿)。 查明原因:DNS 形状的全小写点分串早就被"首段是主机名"那条豁免救下,大写判据 只对**非 DNS 形状**的全小写点分串承担区分作用(标签带 `_`、标签超 63 字符、 `/` 前只有一段主机名)。选择保留(复审的 (b)),并补 `the_uppercase_requirement_is_load_bearing`(`sophisticated_backpacks.upgrades.reference` 等两条)——删掉判据 → 该测试与 `lowercase_dotted_names_are_spared` 红、其余 15 条绿。 同时改正事实错误:不是"第三段必为大写",而是头的 base64url **第三个字符**恒为 `ey` + `I/J/K/L`(`7B 22 xx` 的第三组 6 位 = `b1` 低 4 位 ++ `b2` 高 2 位 → 索引 8..11)。 P2-3:找回重构时丢掉的老负例 `dotted_token_rule_does_not_eat_normal_text` (4 条用例 + 直接断言 `!has_dotted_token(...)`,含 `eyJhbGciOiJIUzI1NiJ9.short.abc`); 语料测试的 doc 注明它**只守一个方向**,不得当"正例仍被抹"的背书。 判据与阈值一个字没动(40 / 3×8 / 64),扫描器代码零改动,纯文档 + 测试。 --- crates/core/src/launch/redact.rs | 9 ++- crates/core/src/launch/redact/shape.rs | 49 ++++++++++++++--- crates/core/src/launch/redact/tests.rs | 76 ++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/crates/core/src/launch/redact.rs b/crates/core/src/launch/redact.rs index 92ca8e6..bf3d4c6 100644 --- a/crates/core/src/launch/redact.rs +++ b/crates/core/src/launch/redact.rs @@ -19,12 +19,15 @@ //! 修法不是抬阈值,而是给每条规则加**必要条件**(详见 [`shape`] 的表): //! //! - `=` 不算 token 字符(赋值边界):`-D=<品牌>` 不再被当成三段式 token; -//! - 带点 token 必须含大写字母、且首段不是主机名(JWT 头 `eyJ…` 必含大写、JWT 不含 `/`); +//! - 带点 token 必须含大写字母、且首段不是主机名(JWT 头的 base64url 第三个字符恒为 +//! `I/J/K/L`,且 base64url 不含 `/`); //! - ≥64 的不透明串若是「路径形状」(被 `/` 切开且每段都是词形名字)就整条保留, //! 但**段本身**仍够长的照抹(`/…/objects/<64 位 hex>` 里只有那段消失)。 //! -//! 三条出口都只放行"整段不可能是凭据"的形状,[`Redactor::with_secret`] 登记过的明文值 -//! 走的是第一条替换路径,与这些启发式无关——真 token 的抹除能力没有下降。 +//! **口径(别读成"抹除能力没有下降")**:登记过明文值的那条替换路径没动,JWT / sha256 / +//! base64 的正例全部仍被抹;但有三类形状是「旧行为抹、新行为留」——`=` 边界、填充不计段长、 +//! 全小写点分串。三条都在 [`shape`] 的「旧行为抹、新行为留」一节里逐条写明理由, +//! 前两条是本次要修的误杀,第三条是代价。 use std::borrow::Cow; use std::path::Path; diff --git a/crates/core/src/launch/redact/shape.rs b/crates/core/src/launch/redact/shape.rs index fe04854..1f40f05 100644 --- a/crates/core/src/launch/redact/shape.rs +++ b/crates/core/src/launch/redact/shape.rs @@ -4,16 +4,40 @@ //! 两者的字符集相互补充(`.` 只属于前者,`/` 属于两者),任何一边改了字符集,另一边的 //! 覆盖面就跟着变。 //! -//! # 三条"别抹"的判据(M239 的 D8 误杀) +//! # 四条"别抹"的判据(M239 的 D8 误杀) //! -//! 每条都只砍掉**真凭据不可能有的形状**,且都写成"必要条件":命中的串仍然按老规则抹, -//! 只是多了几个"这一串根本不可能是一段凭据"的出口: +//! 修法是给每条规则加**必要条件**,不是抬阈值(40 / 3×8 / 64 一个字没动): //! -//! | 误杀形状 | 出口判据 | 为什么真凭据不会从这里溜走 | +//! | 误杀形状 | 出口判据 | 为什么这一串不可能是真凭据 | //! |---|---|---| -//! | `-D=<品牌>`(`minecraft.launcher.brand=UnluckyLauncher`) | `=` 是赋值边界,不属于 token 字符集 | JWT 的 `.` 分段里 `=` 只可能是末尾填充 | -//! | CDN/镜像主机名(`resources.download.minecraft.net/ab/cdef`) | 首段是 DNS 主机名 / 整串没有大写 | JWT 头是 JSON,`base64url("{\"")` = `eyJ`,第三段必为大写 | -//! | 目录段/类名(`/srv/…/mods/x.jar`、`org/spongepowered/…`) | 段名是"词形"([`is_path_run`]) | 随机 base64 的一段里不可能每个字母块都恰好是全小写/全大写/CamelCase | +//! | `-D=<品牌>`(`minecraft.launcher.brand=UnluckyLauncher`) | `=` 不属于 dotted 字符集(赋值边界) | JWT 的 `.` 分段里 `=` 只可能是末尾填充,拆出去不损伤覆盖 | +//! | CDN/镜像主机名(`resources.download.minecraft.net/ab/cdef`) | 首段是 DNS 主机名 → 只判路径那半 | JWT 是 base64url,**不含 `/`**,所以"主机名 + 路径"形状不可能是 JWT | +//! | 目录段/类名(`/srv/…/mods/x.jar`、`org/spongepowered/…`) | 段名是"词形"([`is_path_run`]) | 随机 base64 的一段里不可能每个连续字母块都恰好是全小写/全大写/CamelCase | +//! | 全小写点分串(`sophisticatedbackpacks.upgrades.reference`) | ①主机名形状的走首段 DNS 豁免 ②**不是**主机名的靠"必须含大写字母"([`jwt_shaped`]) | JWT 头是 JSON 对象,其 base64url 前三个字符恒为 `ey` + `I/J/K/L`,所以真 JWT 必含大写 | +//! +//! 四条里前两条是**位置/字符集**判据,后两条是**形状**判据;都写成必要条件:判据不成立时 +//! 仍然照抹,只有"这一串根本不可能是凭据"时才放行。 +//! +//! 关于"必须含大写"的实际覆盖面(复审 P2-2 的怀疑成立一半):**DNS 形状**的全小写点分串 +//! 早在首段 DNS 豁免那一条就被救下了,跟大写判据无关;大写判据唯一扛住的是**不是 DNS 形状** +//! 的全小写点分串(标签带 `_`、标签超 63 字符、`/` 前只有一段主机名)。它保留下来,因为那类 +//! 形状同样不可能是 JWT,而且删掉它就会把误杀请回来——`the_uppercase_requirement_is_load_bearing` +//! 就是钉这一点的用例(删掉判据 → 该测试红)。 +//! +//! # 旧行为抹、新行为留的形状(三类,全部有测试钉住) +//! +//! 口径说明:**不是"抹除能力整体没有下降"**——收窄的就是下面三类,前两条是本次要修的误杀, +//! 第三条是代价。都没有放宽长度/段数阈值,也没有动"登记过的明文值"那条替换路径。 +//! +//! 1. **`=` 边界**:`-Dminecraft.launcher.brand=UnluckyLauncher` —— 旧行为把 `键=值` 粘成 +//! 一段,键里的点号凑够段数/总长;现在两半各自不达标。(这就是要修的品牌串误杀。) +//! 2. **填充不算段长**:`eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbm==` —— +//! 旧行为把 `==` 算进末段(8 ≥ 8),现在 `=` 在段外、末段实际 6 < 8,整串留下。 +//! 真 JWT 的签名段是 43+ 字符(HS256)/ 342(RS256),6–7 字符的末段只可能是被裁剪或 +//! 自造的票据;末段 ≥8 时(`…c2lnbmFy==`)仍照抹,缝合线由测试钉住。 +//! 3. **全小写点分串**:`sophisticatedbackpacks.upgrades.reference`(41 字符 / 三段各 ≥8) +//! —— 旧行为抹,现在放行(DNS 形状的走主机名豁免,非 DNS 形状的走"必须含大写")。 +//! 这是**刻意的**:真 JWT 必含大写(见上表末行),而全小写点分串正是点分类路径的常见形状。 /// 判定“像 token”的最短连续长度。 pub(super) const MAX_OPAQUE_RUN: usize = 64; @@ -42,7 +66,16 @@ pub(super) fn is_dotted_token(run: &str) -> bool { } /// 「带点 token」的形状:段数 ≥3、每段 ≥8 字节、总长 ≥40(老规则,一个字都没放宽), -/// 外加两条必要条件——含大写、首段不是主机名。 +/// 外加两条必要条件——首段不是主机名、**含大写字母**。 +/// +/// "必须含大写"依据的是 JWT 的构造而不是概率:头的明文是 JSON 对象,以 `{"` 开头。 +/// 三个字节 `7B 22 xx` 的 base64url 前三个字符 = `e`、`y`、`I`/`J`/`K`/`L` +/// (第三组 6 位 = `b1` 低 4 位 `0010` ++ `b2` 高 2 位 → 索引 8..11,全是大写)。 +/// 也就是说**第三个字符**必为大写,任何真 JWT 都躲不过这条。 +/// +/// 代价写在这里而不是藏起来:全小写点分串(`sophisticatedbackpacks.upgrades.reference`, +/// 以及非 DNS 形状的 `sophisticated_backpacks.upgrades.reference`,旧行为都会抹)因此被放行, +/// 见模块文档「旧行为抹、新行为留」第 3 条。 fn jwt_shaped(run: &str) -> bool { run.len() >= DOTTED_TOKEN_MIN_TOTAL && run.split('.').count() >= 3 diff --git a/crates/core/src/launch/redact/tests.rs b/crates/core/src/launch/redact/tests.rs index aebf616..b68ac94 100644 --- a/crates/core/src/launch/redact/tests.rs +++ b/crates/core/src/launch/redact/tests.rs @@ -112,6 +112,20 @@ fn brand_and_property_strings_survive() { } } +/// 老规则的负例(M240 重构时被丢掉过,复审 P2-3 要求找回):分段太短 / 总长不够 / 段数不够。 +#[test] +fn dotted_token_rule_does_not_eat_normal_text() { + for text in [ + "Service.Unavailable.today", + "1.2.0", + "xbl.xsts.entitlements.profile", + "eyJhbGciOiJIUzI1NiJ9.short.abc", + ] { + assert!(!has_dotted_token(text), "{text}"); + assert_eq!(scrub(text), text, "{text}"); + } +} + /// 整条 HMCL 形状的 argv(`--dry-run` 的面):一处都不许动。 #[test] fn hmcl_shaped_argv_survives_verbatim() { @@ -156,6 +170,9 @@ fn long_paths_and_class_paths_survive() { } /// 语料组:M239 的回归语料,形状规则**一行都不许改**。 +/// +/// **只守一个方向**:它证明"该保留的保留了",**不能**拿来背书"正例仍被抹"——正例那一方向 +/// 由 `jwts_are_masked_in_every_log_shape` / `opaque_secrets_are_masked_in_every_log_shape` 显式断言。 #[test] fn sample_corpus_is_untouched() { let files = corpus_files(); @@ -225,6 +242,65 @@ fn url_host_is_exempt_but_the_path_half_is_still_judged() { assert_eq!(scrub(url), url); } +/// 「旧行为抹、新行为留」的三类形状(复审 P2-1:口径写准,别留"抹除能力没有下降"的绝对承诺)。 +/// +/// 三条都用 **M240 之前的真实代码**(`git show 74be510:crates/core/src/launch/redact.rs` 直接 +/// 用 rustc 编成探针)实测过:旧行为输出 ``,现在逐字原样。前两条是本次要修的 +/// 误杀本身,第三条是全小写点分串的代价(理由见 `shape` 模块文档的「旧行为抹、新行为留」)。 +#[test] +fn known_narrowings_old_masked_new_keeps() { + // ① `=` 边界:键把值带过线(旧行为把 `键=值` 粘成一段凑够段数) + let brand = "-Dminecraft.launcher.brand=UnluckyLauncher"; + assert_eq!(scrub(brand), brand, "旧行为:"); + // ② 填充不算段长:末段实际 6 字符(旧行为把 `c2lnbm==` 算成 8 ≥ 8 → 抹) + let padded = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbm=="; + assert_eq!(scrub(padded), padded, "旧行为:"); + // ② 的另一侧:末段 8 字符仍照抹(占位符在前、填充留在外面) + assert_eq!( + scrub("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmFy=="), + format!("{TOKEN_PLACEHOLDER}==") + ); + // ③ 全小写点分串:DNS 形的靠主机名豁免、非 DNS 形的靠"必须含大写"(旧行为都会抹) + let lowercase = "sophisticatedbackpacks.upgrades.reference"; + assert_eq!(scrub(lowercase), lowercase, "旧行为:"); +} + +/// "必须含大写"这条判据**自己**在扛的用例(复审 P2-2 的 (b))。 +/// +/// 先划清它的实际覆盖面(复审的怀疑是对的):**DNS 形状**的全小写点分串(`a.b.c` 全小写) +/// 已经被 [`is_dns_host`] 那条主机名豁免救下了,跟大写判据无关。大写判据唯一扛住的是 +/// **不是 DNS 形状**的全小写点分串——例如标签里带 `_`(Java 标识符常见写法)、标签超 63 字符、 +/// 或 `/` 之前只有一段主机名。下面就是这一类。 +/// +/// 删掉 `jwt_shaped` 里那条大写判据,**这条测试与 `lowercase_dotted_names_are_spared` 会红, +/// 其余 15 条仍绿**(M240 复审实测:删判据 → 15 passed / 2 failed)。 +#[test] +fn the_uppercase_requirement_is_load_bearing() { + for text in [ + "sophisticated_backpacks.upgrades.reference", // 41 字符,段长 23/8/9,标签带 `_` + "spongepowered_mixins.injections.callback", // 40 字符,段长 20/10/8,标签带 `_` + ] { + assert_eq!( + scrub(text), + text, + "全小写、非 DNS 形状的点分串被抹(大写判据失效?):{text}" + ); + } +} + +/// 全小写点分串整体都不该被抹(两类出口各管一半,测试分开写以免把功劳记错): +/// DNS 形的靠主机名豁免,非 DNS 形的靠"必须含大写"。 +#[test] +fn lowercase_dotted_names_are_spared() { + for text in [ + "sophisticatedbackpacks.upgrades.reference", // DNS 形状 → 主机名豁免 + "libraries.minecraftservices.entitlements", // DNS 形状 → 主机名豁免 + "sophisticated_backpacks.upgrades.reference", // 非 DNS → 大写判据 + ] { + assert_eq!(scrub(text), text, "全小写点分串被抹:{text}"); + } +} + /// 两个入口必须一致:`scrub` 用 `has_*` 当闸门,闸门关着就永远不会调 `mask_*`。 #[test] fn the_has_gate_agrees_with_the_maskers() {