docs(core): M240 复审 P2-1/P2-2/P2-3 —— 口径收窄、大写判据钉住、找回丢掉的负例
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),扫描器代码零改动,纯文档 + 测试。
This commit is contained in:
1 parent
43f77d8865
commit
66929786c0
3 files changed
+123
-11
No files matched your search
@@ -19,12 +19,15 @@
|
||||
//! 修法不是抬阈值,而是给每条规则加**必要条件**(详见 [`shape`] 的表):
|
||||
//!
|
||||
//! - `=` 不算 token 字符(赋值边界):`-D<key>=<品牌>` 不再被当成三段式 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;
|
||||
|
||||
@@ -4,16 +4,40 @@
|
||||
//! 两者的字符集相互补充(`.` 只属于前者,`/` 属于两者),任何一边改了字符集,另一边的
|
||||
//! 覆盖面就跟着变。
|
||||
//!
|
||||
//! # 三条"别抹"的判据(M239 的 D8 误杀)
|
||||
//! # 四条"别抹"的判据(M239 的 D8 误杀)
|
||||
//!
|
||||
//! 每条都只砍掉**真凭据不可能有的形状**,且都写成"必要条件":命中的串仍然按老规则抹,
|
||||
//! 只是多了几个"这一串根本不可能是一段凭据"的出口:
|
||||
//! 修法是给每条规则加**必要条件**,不是抬阈值(40 / 3×8 / 64 一个字没动):
|
||||
//!
|
||||
//! | 误杀形状 | 出口判据 | 为什么真凭据不会从这里溜走 |
|
||||
//! | 误杀形状 | 出口判据 | 为什么这一串不可能是真凭据 |
|
||||
//! |---|---|---|
|
||||
//! | `-D<key>=<品牌>`(`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<key>=<品牌>`(`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
|
||||
|
||||
@@ -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 编成探针)实测过:旧行为输出 `<redacted:token>`,现在逐字原样。前两条是本次要修的
|
||||
/// 误杀本身,第三条是全小写点分串的代价(理由见 `shape` 模块文档的「旧行为抹、新行为留」)。
|
||||
#[test]
|
||||
fn known_narrowings_old_masked_new_keeps() {
|
||||
// ① `=` 边界:键把值带过线(旧行为把 `键=值` 粘成一段凑够段数)
|
||||
let brand = "-Dminecraft.launcher.brand=UnluckyLauncher";
|
||||
assert_eq!(scrub(brand), brand, "旧行为:<redacted:token>");
|
||||
// ② 填充不算段长:末段实际 6 字符(旧行为把 `c2lnbm==` 算成 8 ≥ 8 → 抹)
|
||||
let padded = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbm==";
|
||||
assert_eq!(scrub(padded), padded, "旧行为:<redacted:token>");
|
||||
// ② 的另一侧:末段 8 字符仍照抹(占位符在前、填充留在外面)
|
||||
assert_eq!(
|
||||
scrub("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmFy=="),
|
||||
format!("{TOKEN_PLACEHOLDER}==")
|
||||
);
|
||||
// ③ 全小写点分串:DNS 形的靠主机名豁免、非 DNS 形的靠"必须含大写"(旧行为都会抹)
|
||||
let lowercase = "sophisticatedbackpacks.upgrades.reference";
|
||||
assert_eq!(scrub(lowercase), lowercase, "旧行为:<redacted:token>");
|
||||
}
|
||||
|
||||
/// "必须含大写"这条判据**自己**在扛的用例(复审 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() {
|
||||
|
||||
Reference in new issue
Block a user